diff --git a/.claude/skills/android-testing/SKILL.md b/.claude/skills/android-testing/SKILL.md new file mode 100644 index 00000000000..cef45a987f1 --- /dev/null +++ b/.claude/skills/android-testing/SKILL.md @@ -0,0 +1,235 @@ +--- +name: android-testing +description: > + Guidelines for writing unit tests (mockito-kotlin, Turbine, runTest) and + UI instrumented tests (Robot pattern, CoroutineTracker, Compose test tags) + for the DHIS2 Android KMP project. Load this when creating, fixing, or + reviewing any test in the codebase. +--- + +# DHIS2 Android Testing Guidelines + +## Testing Stack + +- **Unit Tests**: `mockito-kotlin` (`mock()`, `whenever()`, `verify()`), JUnit / `kotlin.test` +- **Flow tests**: Turbine (`app.cash.turbine`) + `kotlinx-coroutines-test` +- **UI Tests**: Compose Testing + Espresso with Robot pattern +- **Test locations**: + - `commonTest/` — platform-agnostic unit tests (`kotlin.test` annotations: `@Test`, `@BeforeTest`) + - `androidUnitTest/` — Android-specific unit tests (JUnit `@Test`) + - `androidInstrumentedTest/` / `androidTest/` — UI/instrumented tests + +## Run Commands + +```bash +# Shortcut: lint + all unit tests (mirrors CI) +./run_tests.sh + +# All unit tests (legacy + KMP host + KMP debug) +./gradlew testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest + +# Desktop targets in KMP modules +./gradlew desktopTest + +# Single KMP module test class (commonTest + androidUnitTest source sets) +./gradlew :login:testAndroidHostTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest" + +# Single KMP module test class (androidUnitTest source set only) +./gradlew :login:testAndroidDebugUnitTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest" + +# Single legacy Android module test class +./gradlew :form:testDebugUnitTest --tests "org.dhis2.form.ui.FormViewModelTest" + +# Single test method (commonTest + androidUnitTest source sets) +./gradlew :login:testAndroidHostTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest.initial screen is set correctly when starting" +``` + +## Critical Rule: No Hard-Coded Delays + +ViewModels use `launchUseCase { }` which wraps `CoroutineTracker`. Espresso's +`IdlingResource` automatically waits for tracked coroutines. `Thread.sleep()` and +hard-coded timeouts are **forbidden**. + +```kotlin +// ✅ CORRECT — IdlingResource waits automatically +@Test +fun shouldLoadData() { + exampleRobot(composeTestRule) { + clickLoadButton() + verifyDataDisplayed() // no delay needed + } +} + +// ❌ WRONG +@Test +fun shouldLoadData() { + clickLoadButton() + Thread.sleep(2000) // FORBIDDEN + verifyDataDisplayed() +} +``` + +## Mocking: mockito-kotlin only + +Use `mock()`, `whenever()`, `verify()` from `org.mockito.kotlin`. Do **not** use MockK. + +```kotlin +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +``` + +## Unit Test Patterns + +### Use Case test +```kotlin +class SavePinUseCaseTest { + private val repository: SessionRepository = mock() + private val useCase = SavePinUseCase(repository) + + @Test + fun `should return success when pin is saved`() = runTest { + whenever(repository.savePin("1234")).thenReturn(Unit) + + val result = useCase("1234") + + assertTrue(result.isSuccess) + verify(repository).savePin("1234") + } +} +``` + +### ViewModel test +```kotlin +class ExampleViewModelTest { + private val useCase: GetDataUseCase = mock() + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var viewModel: ExampleViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `should emit success state when use case succeeds`() = runTest { + whenever(useCase()).thenReturn(Result.success(flowOf(listOf(item)))) + + viewModel = ExampleViewModel(useCase) + + viewModel.uiState.test { + assertEquals(UiState.Success(listOf(item)), awaitItem()) + } + } +} +``` + +### Repository test +```kotlin +class ExampleRepositoryTest { + // D2 chains calls across multiple intermediate objects — RETURNS_DEEP_STUBS is required + // so that d2.someModule().someRepository().blockingGet() doesn't NPE on the intermediates. + private val d2: D2 = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + private val domainErrorMapper: DomainErrorMapper = mock() + private val repository = ExampleRepositoryImpl(d2, domainErrorMapper) + + @Test + fun `should map D2Error to domain error`() = runTest { + val d2Error = D2Error.builder().errorCode(D2ErrorCode.API_RESPONSE_PROCESS_ERROR).build() + whenever(d2.exampleModule().examples().blockingGet()).thenThrow(d2Error) + whenever(domainErrorMapper.mapToDomainError(d2Error)).thenReturn(DomainException("error")) + + val result = runCatching { repository.getData() } + + assertTrue(result.isFailure) + verify(domainErrorMapper).mapToDomainError(d2Error) + } +} +``` + +## UI Tests: Robot Pattern + +All UI tests go in `androidInstrumentedTest/`. Always use the Robot pattern. Tests extend +`BaseTest`, which provides `mockWebServerRobot` — a helper that stubs HTTP responses from the +DHIS2 server so tests run fully offline against a local `MockWebServer`. Register stubs +**before** launching the robot body. + +```kotlin +fun exampleRobot(rule: ComposeTestRule, body: ExampleRobot.() -> Unit) = + ExampleRobot(rule).apply { body() } + +class ExampleRobot(val rule: ComposeTestRule) : BaseRobot() { + fun typeUsername(username: String) { + rule.waitUntilExactlyOneExists(hasTestTag(USERNAME_TAG), TIMEOUT) + rule.onNodeWithTag(USERNAME_TAG).performClick() + rule.onAllNodesWithTag("INPUT_TEXT_FIELD")[0].performTextInput(username) + } + + fun clickSubmitButton() { + rule.waitUntilExactlyOneExists(hasTestTag(SUBMIT_TAG), TIMEOUT) + rule.onNodeWithTag(SUBMIT_TAG).performClick() + } +} + +class ExampleTest : BaseTest() { + @get:Rule val rule = createComposeRule() + + @Test + fun shouldPerformSuccessfulAction() { + // Stub the network response before any UI interaction + mockWebServerRobot.addResponse(GET, "/api/endpoint", MOCK_RESPONSE, 200) + exampleRobot(rule) { + typeUsername("user") + clickSubmitButton() + verifySuccessMessageDisplayed() + } + // Call cleanDatabase() after any test that writes to the local DB — + // it clears all DHIS2 SDK tables so state doesn't leak into the next test. + cleanDatabase() + } +} +``` + +## Test Tags + +Export constants from the screen file. Format: `{SCREEN}_{COMPONENT}_TAG`. + +```kotlin +const val LOGIN_BUTTON_TAG = "LOGIN_BUTTON_TAG" +const val USERNAME_INPUT_TAG = "USERNAME_INPUT_TAG" + +@Composable +fun LoginScreen() { + InputField(modifier = Modifier.testTag(USERNAME_INPUT_TAG)) + Button(modifier = Modifier.testTag(LOGIN_BUTTON_TAG)) { ... } +} +``` + +## DHIS2 Design System Inputs + +DHIS2 input components are composite. Click the wrapper to focus, then target the +inner `"INPUT_TEXT_FIELD"` node. Use `performTextInput()`, never `performTextReplacement()`. + +```kotlin +// ✅ CORRECT +rule.onNodeWithTag(USERNAME_TAG).performClick() +rule.onAllNodesWithTag("INPUT_TEXT_FIELD")[0].performTextInput(username) + +// ❌ WRONG +rule.onNodeWithTag(USERNAME_TAG).performTextReplacement(username) +``` + +## Common Mistakes to Avoid + +- Using `Thread.sleep()` or any hard-coded delays +- Using MockK (`mockk()`, `every {}`, `coEvery {}`) — use mockito-kotlin instead +- Using `performTextReplacement()` on DHIS2 design system components +- Not exporting test tag constants from screen files +- Not extending `BaseRobot` for robot classes +- Not cleaning up after tests (`cleanDatabase()`, clear preferences) +- Testing implementation details instead of user flows diff --git a/.editorconfig b/.editorconfig index 9778c7012d1..f706f5dee15 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,3 +6,4 @@ ktlint_standard_no-wildcard-imports = enabled ktlint_standard_trailing-comma-on-call-site = enabled ktlint_standard_trailing-comma-on-declaration-site = enabled ktlint_standard_import-ordering = enabled +ktlint_experimental_no-unused-imports = enabled diff --git a/.github/agents/testing.agent.md b/.github/agents/testing.agent.md index c2825f82740..df73f8d9888 100644 --- a/.github/agents/testing.agent.md +++ b/.github/agents/testing.agent.md @@ -5,26 +5,19 @@ # For format details, see: https://gh.io/customagents/config name: Testing expert -description: > - Expert Kotlin/Android testing engineer for the DHIS2 Android Capture App. - Generates, reviews, and fixes unit tests (Mockito Kotlin/JUnit), UI instrumented tests - (Compose Testing + Espresso Robot pattern), and enforces async best practices - using CoroutineTracker. Targets KMP modules with commonTest, androidHostTest, - and androidDeviceTest source sets. -tools: - - githubread - - lexical-code-search - - semantic-code-search - - github-coding-agent-immersive +description: Agent expert on Android testing --- # DHIS2 Android Testing Expert Agent -You are an expert testing engineer specializing in the DHIS2 Android Capture App. Your role is to create, maintain, and improve tests following the project's strict testing guidelines and architecture patterns. +You are an expert testing engineer specializing in the DHIS2 Android Capture App. +Your role is to create, maintain, and improve tests following the project's strict +testing guidelines and architecture patterns. ## Project Context -This is a Kotlin Multiplatform (KMP) project migrating to Compose Multiplatform, targeting Android, iOS, and Desktop platforms. The app uses: +This is a Kotlin Multiplatform (KMP) project migrating to Compose Multiplatform, +targeting Android, iOS, and Desktop platforms. The app uses: - DHIS2 Android SDK (`org.hisp.dhis.android.core.*`) for all data operations - DHIS2 Mobile UI (`org.hisp.dhis.mobile.ui.designsystem.*`) design system @@ -34,84 +27,76 @@ This is a Kotlin Multiplatform (KMP) project migrating to Compose Multiplatform, ## Testing Stack -- Unit Tests: Mockito (`org.mockito.kotlin`) for mocking, JUnit -- UI Tests: Compose Testing, Espresso with Robot pattern -- Test Locations: - - `commonTest/` — Platform-agnostic tests - - `androidHostTest/` — Android unit tests (runs on the JVM) - - `androidDeviceTest/` — UI/instrumented tests (runs on device/emulator) +- **Unit Tests**: `mockito-kotlin` for mocking (`mock()`, `whenever()`, `verify()`), JUnit / `kotlin.test` +- **Flow tests**: Turbine (`app.cash.turbine`) + `kotlinx-coroutines-test` +- **UI Tests**: Compose Testing + Espresso with Robot pattern +- **Test locations**: + - `commonTest/` — platform-agnostic unit tests (use `kotlin.test` annotations) + - `androidUnitTest/` — Android-specific unit tests (use `@Test` from JUnit) + - `androidInstrumentedTest/` / `androidTest/` — UI/instrumented tests -## Choosing the Right Source Set +## Run Commands -| Source set | When to use | -|---|---| -| `commonTest/` | Pure Kotlin logic with no Android APIs: use cases, domain models, pure mappers | -| `androidHostTest/` | Android-specific unit tests running on the JVM: repository impls with D2, ViewModels | -| `androidDeviceTest/` | UI flows that require a real or emulated Android device | +```bash +# All unit tests +./gradlew testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest -> **AGP 9.x / `com.android.kotlin.multiplatform.library` plugin:** KMP modules use -> `androidHostTest` for JVM-based unit tests and `androidDeviceTest` for instrumented device tests. -> These source sets are created on demand via `withHostTest {}` / `withDeviceTest {}` in the -> `androidLibrary {}` DSL block. The old names `androidUnitTest` and `androidInstrumentedTest` -> belong to the legacy `com.android.library` + `kotlin("multiplatform")` setup and must **not** be -> used in modules that have adopted the new plugin. +# Single KMP module test class +./gradlew :login:testAndroidDebugUnitTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest" -## Critical Testing Rules +# Single legacy Android module test class +./gradlew :form:testDebugUnitTest --tests "org.dhis2.form.ui.FormViewModelTest" -### Async Handling — NEVER USE HARD-CODED DELAYS +# Single test method +./gradlew :login:testAndroidDebugUnitTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest.initial screen is set correctly when starting" +``` -**CRITICAL**: Use `CoroutineTracker` with `launchUseCase` for all async operations in tests: +## Critical Testing Rules -- ViewModels use `launchUseCase { }` extension which increments/decrements `CoroutineTracker` -- Espresso's `IdlingResource` automatically waits for tracked operations -- This enables fast, reliable tests without `Thread.sleep()` or manual waits -- **NEVER** write `Thread.sleep()`, `delay()`, or hard-coded timeouts in tests +### Async Handling — NEVER use hard-coded delays + +ViewModels use `launchUseCase { }` which wraps `CoroutineTracker`. Espresso's +`IdlingResource` automatically waits for tracked coroutines to complete. This makes +`Thread.sleep()` and hard-coded timeouts unnecessary and forbidden. ```kotlin -// ✅ CORRECT - ViewModel uses launchUseCase +// ✅ CORRECT — ViewModel uses launchUseCase class ExampleViewModel(private val useCase: GetDataUseCase) : ViewModel() { fun loadData() { - launchUseCase { // Automatically tracked + launchUseCase { // increments/decrements CoroutineTracker automatically val result = useCase() // ... handle result } } } -// ✅ CORRECT - Test waits automatically via IdlingResource +// ✅ CORRECT — Test waits automatically via IdlingResource @Test -fun `should load data`() { +fun shouldLoadData() { exampleRobot(composeTestRule) { clickLoadButton() - // IdlingResource waits for launchUseCase to complete - verifyDataDisplayed() // No delay needed! + verifyDataDisplayed() // no delay needed } } -// ❌ WRONG - Never do this +// ❌ WRONG — never do this @Test -fun `should load data`() { +fun shouldLoadData() { clickLoadButton() - Thread.sleep(2000) // NEVER DO THIS + Thread.sleep(2000) // FORBIDDEN verifyDataDisplayed() } ``` ## UI Testing Guidelines — Robot Pattern -### Location - -All UI tests go in `androidDeviceTest/`. - -### Pattern - -Use the Robot pattern for all UI interactions: +All UI tests go in `androidInstrumentedTest/`. Always use the Robot pattern. ```kotlin // Robot function wrapper fun exampleRobot( composeTestRule: ComposeTestRule, - robotBody: ExampleRobot.() -> Unit + robotBody: ExampleRobot.() -> Unit, ) { ExampleRobot(composeTestRule).apply { robotBody() } } @@ -133,149 +118,114 @@ class ExampleRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.waitUntilExactlyOneExists(hasTestTag(SUCCESS_TAG), TIMEOUT) } } -``` - -### Test Structure -```kotlin +// Test class class ExampleTest : BaseTest() { @get:Rule val composeTestRule = createComposeRule() @Test - fun `should perform successful action`() { - // Setup mocks + fun shouldPerformSuccessfulAction() { mockWebServerRobot.addResponse(GET, "/api/endpoint", MOCK_RESPONSE, 200) - // Execute test using robot exampleRobot(composeTestRule) { typeUsername("user") clickSubmitButton() verifySuccessMessageDisplayed() } - // Cleanup cleanDatabase() } } ``` -## Test Tags for Compose UI +### Test Tags for Compose UI -**ALWAYS** add test tags to interactive components: +Always add test tags to interactive components. Export constants from the screen file. ```kotlin -// ✅ In the Screen composable - export tag constants +// In the screen composable file const val LOGIN_BUTTON_TAG = "LOGIN_BUTTON_TAG" const val USERNAME_INPUT_TAG = "USERNAME_INPUT_TAG" @Composable fun LoginScreen() { - InputField( - modifier = Modifier.testTag(USERNAME_INPUT_TAG) - ) - Button( - modifier = Modifier.testTag(LOGIN_BUTTON_TAG) - ) + InputField(modifier = Modifier.testTag(USERNAME_INPUT_TAG)) + Button(modifier = Modifier.testTag(LOGIN_BUTTON_TAG)) { ... } } ``` Format: `{SCREEN}_{COMPONENT}_TAG` (e.g., `LOGIN_BUTTON_TAG`, `HOME_MENU_TAG`) -## DHIS2 Design System Components +### DHIS2 Design System Components -DHIS2 components are composite. Special handling required: +DHIS2 components are composite. Click the wrapper tag to focus, then target the +inner `"INPUT_TEXT_FIELD"` node. Always use `performTextInput()`, not +`performTextReplacement()`. ```kotlin -// ✅ CORRECT - For InputField and similar components +// ✅ CORRECT fun typeUsername(username: String) { - // 1. Click wrapper to focus composeTestRule.onNodeWithTag(USERNAME_TAG).performClick() - - // 2. Find inner INPUT_TEXT_FIELD - composeTestRule.onAllNodesWithTag("INPUT_TEXT_FIELD")[0] - .performTextInput(username) // Use performTextInput, NOT performTextReplacement + composeTestRule.onAllNodesWithTag("INPUT_TEXT_FIELD")[0].performTextInput(username) } // ❌ WRONG fun typeUsername(username: String) { - composeTestRule.onNodeWithTag(USERNAME_TAG) - .performTextReplacement(username) // Won't work with composite components + composeTestRule.onNodeWithTag(USERNAME_TAG).performTextReplacement(username) } ``` -## Mock Server Usage - -Use `MockWebServerRobot` for API mocking: +### Mock Server ```kotlin -@Test -fun `should handle api response`() { - // Setup mock response - mockWebServerRobot.addResponse( - method = GET, - path = "/api/dataElements", - response = MOCK_DATA_ELEMENTS_JSON, - responseCode = 200 - ) - - // Run test - exampleRobot(composeTestRule) { - clickSyncButton() - verifyDataSynced() - } -} +mockWebServerRobot.addResponse( + method = GET, + path = "/api/dataElements", + response = MOCK_DATA_ELEMENTS_JSON, + responseCode = 200, +) ``` -## Best Practices Checklist +## Unit Testing Guidelines -- ✅ Use `waitUntilExactlyOneExists()` before interacting with elements -- ✅ Robot methods have descriptive names (`clickLoginButton()`, not `click()`) -- ✅ Keep robots focused on actions, separate assertion methods -- ✅ Test user flows, not isolated components -- ✅ Mock all external dependencies (network, SDK, databases) -- ✅ Clean up after tests (call `cleanDatabase()`, clear preferences) -- ❌ NEVER use `Thread.sleep()` or hard-coded delays -- ✅ Use `performTextInput()` for DHIS2 design system inputs -- ✅ Export test tag constants from screen files +### Mocking library: mockito-kotlin -## Unit Testing Guidelines +Use `mock()`, `whenever()`, `verify()` from `org.mockito.kotlin`. Do **not** use MockK. -### Repository Tests +```kotlin +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +``` -Mock DHIS2 SDK components and `DomainErrorMapper`: +### Repository Tests ```kotlin class ExampleRepositoryTest { - private val d2: D2 = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + private val d2: D2 = mock() private val domainErrorMapper: DomainErrorMapper = mock() private val repository = ExampleRepositoryImpl(d2, domainErrorMapper) @Test fun `should map SDK data to domain models`() = runTest { - // Given - val sdkData: List = mock() - whenever(d2.exampleModule().examples().get()) doReturn sdkData + val sdkData = listOf() + whenever(d2.exampleModule().examples().blockingGet()).thenReturn(sdkData) - // When val result = repository.getData() - // Then - verify(d2.exampleModule().examples()).get() - // Assert domain mapping + verify(d2.exampleModule().examples()).blockingGet() } @Test fun `should map D2Error to domain error`() = runTest { - // Given val d2Error = D2Error.builder().errorCode(D2ErrorCode.API_RESPONSE_PROCESS_ERROR).build() - whenever(d2.exampleModule().examples().get()).thenThrow(d2Error) - whenever(domainErrorMapper.mapToDomainError(d2Error)) doReturn DomainException("Mapped error") + whenever(d2.exampleModule().examples().blockingGet()).thenThrow(d2Error) + whenever(domainErrorMapper.mapToDomainError(d2Error)).thenReturn(DomainException("Mapped error")) - // When/Then - assertThrows { - repository.getData() - } + val result = runCatching { repository.getData() } + + assertTrue(result.isFailure) verify(domainErrorMapper).mapToDomainError(d2Error) } } @@ -283,35 +233,24 @@ class ExampleRepositoryTest { ### ViewModel Tests -Test state transitions and use case coordination. Always configure `Dispatchers.setMain` to avoid coroutine dispatcher issues in unit tests. `UnconfinedTestDispatcher` is preferred here because it runs coroutines eagerly without suspending, so ViewModel `init` blocks and `launchUseCase` calls complete synchronously during test setup: - ```kotlin class ExampleViewModelTest { private val getDataUseCase: GetDataUseCase = mock() + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var viewModel: ExampleViewModel - @Before + @BeforeTest fun setUp() { - // UnconfinedTestDispatcher runs coroutines eagerly, enabling - // synchronous execution of ViewModel init and launchUseCase blocks - Dispatchers.setMain(UnconfinedTestDispatcher()) - } - - @After - fun tearDown() { - Dispatchers.resetMain() + Dispatchers.setMain(testDispatcher) } @Test fun `should emit success state when use case succeeds`() = runTest { - // Given val data = listOf(ExampleData("test")) - whenever(getDataUseCase()) doReturn Result.success(flowOf(data)) + whenever(getDataUseCase()).thenReturn(Result.success(flowOf(data))) - // When viewModel = ExampleViewModel(getDataUseCase) - // Then assertEquals(UiState.Success(data), viewModel.uiState.value) } } @@ -319,8 +258,6 @@ class ExampleViewModelTest { ### Use Case Tests -Test business logic and error handling: - ```kotlin class GetDataUseCaseTest { private val repository: ExampleRepository = mock() @@ -328,82 +265,66 @@ class GetDataUseCaseTest { @Test fun `should filter invalid data`() = runTest { - // Given - val allData = listOf( - ExampleData(isValid = true), - ExampleData(isValid = false) - ) - whenever(repository.getData()) doReturn flowOf(allData) - - // When + val allData = listOf(ExampleData(isValid = true), ExampleData(isValid = false)) + whenever(repository.getData()).thenReturn(flowOf(allData)) + val result = useCase(Unit) - // Then result.onSuccess { flow -> - flow.collect { data -> + flow.test { + val data = awaitItem() assertTrue(data.all { it.isValid }) + awaitComplete() } } } } ``` -## Test Naming Convention - -- Use backtick-style descriptive names for all tests -- Format: `` `should [expected behavior] when [condition]` `` — the `when [condition]` clause is optional; omit it when the condition is obvious from the expected behavior alone -- Examples: - - `` `should emit error state when use case fails` `` - - `` `should display success message when login succeeds` `` - - `` `should map D2Error to domain error when SDK throws` `` - - `` `should filter invalid data` `` (condition is implicit) - ## Test Organization ``` -modulekmm/ -├── src/ -│ ├── commonTest/kotlin/ # Shared unit tests -│ │ ├── domain/ # Use case tests -│ │ ├── data/ # Repository interface tests -│ ├── androidHostTest/kotlin/ # Android-specific unit tests -│ │ ├── data/ # Repository implementation tests -│ │ ├── ui/ # ViewModel tests -│ ├── androidDeviceTest/ # UI tests with Robot pattern -│ │ ├── robots/ # Robot classes -│ │ ├── tests/ # Test classes +modulekmm/src/ +├── commonTest/kotlin/ # Shared unit tests (kotlin.test + mockito-kotlin + turbine) +│ ├── domain/ # Use case tests +│ └── data/ # Repository interface tests +├── androidUnitTest/kotlin/ # Android-specific unit tests +│ ├── data/ # Repository implementation tests +│ └── ui/ # ViewModel tests +└── androidInstrumentedTest/ # UI tests with Robot pattern + ├── robots/ # Robot classes + └── tests/ # Test classes ``` -## When Writing Tests +## Best Practices Checklist -- Identify test type: Unit (repository, use case, ViewModel) or UI (instrumented) -- For UI tests: Always use Robot pattern, export test tags, use `CoroutineTracker` -- For unit tests: Mock dependencies with Mockito Kotlin, test business logic and mappings -- Mock external dependencies: SDK, network, database -- Clean up: Database, preferences, mock servers -- Never delay: Trust `CoroutineTracker` and `IdlingResource` +- Use `waitUntilExactlyOneExists()` before interacting with elements +- Robot methods use descriptive names (`clickLoginButton()`, not `click()`) +- Keep robots focused on actions; use separate methods for assertions +- Test user flows, not isolated components +- Mock all external dependencies (network, SDK, databases) +- Clean up after tests (`cleanDatabase()`, clear preferences) +- Use `performTextInput()` for DHIS2 design system inputs +- Export test tag constants from screen files ## Common Mistakes to Avoid -- ❌ Using `Thread.sleep()` or hard-coded delays -- ❌ Using `performTextReplacement()` on DHIS2 design system components -- ❌ Forgetting to export test tag constants -- ❌ Not extending `BaseRobot` for robot classes -- ❌ Not cleaning up after tests -- ❌ Testing implementation details instead of user flows -- ❌ Forgetting to mock external dependencies -- ❌ Using old source set names `androidUnitTest` or `androidInstrumentedTest` in KMP modules that have adopted the `com.android.kotlin.multiplatform.library` plugin — the correct names are `androidHostTest` (JVM unit tests) and `androidDeviceTest` (device/instrumented tests) +- Using `Thread.sleep()` or any hard-coded delays +- Using `performTextReplacement()` on DHIS2 design system components +- Using MockK (`mockk()`, `every {}`, `coEvery {}`) — use mockito-kotlin instead +- Forgetting to export test tag constants from screen files +- Not extending `BaseRobot` for robot classes +- Not cleaning up after tests +- Testing implementation details instead of user flows +- Forgetting to mock external dependencies before the test runs ## Your Responsibilities When asked to create or fix tests: -- Analyze the component/feature being tested -- Determine test type (unit vs UI) -- Create/update test following all guidelines above -- Ensure proper mocking, async handling, and cleanup -- Verify test follows Robot pattern (for UI) or proper structure (for unit) -- Add test tags to components if missing -- Run tests to verify they pass - -You are the testing expert. Write clean, maintainable, reliable tests that follow DHIS2 project standards. +1. Identify test type: unit (use case / repository / ViewModel) or UI (instrumented) +2. Place tests in the correct source set (`commonTest`, `androidUnitTest`, or `androidInstrumentedTest`) +3. Use `mockito-kotlin` for all mocking — never MockK +4. For UI tests: apply the Robot pattern, export test tags, rely on `CoroutineTracker` +5. Ensure proper cleanup (database, preferences, mock server) +6. Run the relevant Gradle task to verify the test passes before finishing diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b75843dd40d..e72b95fcd40 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,8 +19,12 @@ updates: update-types: ["version-update:semver-major", "version-update:semver-minor"] - dependency-name: "com.squareup.okhttp3:*" - dependency-name: "org.hisp.dhis.mobile:designsystem" - - dependency-name: "org.hisp.dhis:android-core" + - dependency-name: "org.hisp.dhis.rules:rule-engine-jvm" + - dependency-name: "org.hisp.dhis.lib.expression:expression-parser-jvm" groups: + dhis2-android-core: + patterns: + - "org.hisp.dhis:android-core" gradle-updates: patterns: - "*" # Group all Gradle updates into one PR diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 17dd450f794..6134ac3ef22 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -45,7 +45,7 @@ jobs: encodedString: ${{ secrets.KEYSTORE }} - name: Build Release APKs - run: ./gradlew app:assembleDhis2Release app:assembleDhis2PlayServicesRelease + run: ./gradlew --dependency-verification strict app:assembleDhis2Release app:assembleDhis2PlayServicesRelease env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -69,7 +69,7 @@ jobs: TRAINING_KEY_PASSWORD: ${{ secrets.TRAINING_KEY_PASSWORD }} TRAINING_STORE_PASSWORD: ${{ secrets.TRAINING_STORE_PASSWORD }} TRAINING_STORE_FILE: ${{ steps.decode_training_keystore.outputs.filePath }} - run: ./gradlew app:assembleDhis2TrainingRelease + run: ./gradlew --dependency-verification strict app:assembleDhis2TrainingRelease - name: Read version name from file working-directory: ./gradle @@ -78,21 +78,21 @@ jobs: # Upload DhisRelease APK - name: Upload DhisRelease APK - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - DhisRelease APK path: ${{ env.main_project_module }}/build/outputs/apk/dhis2/release/dhis2-v${{ steps.read-version.outputs.vName }}.apk # Upload DhisPlayServicesRelease APK - name: Upload DhisPlayServicesRelease APK - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - DhisPlayServicesRelease APK path: ${{ env.main_project_module }}/build/outputs/apk/dhis2PlayServices/release/dhis2-v${{ steps.read-version.outputs.vName }}-googlePlay.apk # Upload Training Release APK - name: Upload Training Release APK - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - Training Release APK path: ${{ env.main_project_module }}/build/outputs/apk/dhis2Training/release/dhis2-v${{ steps.read-version.outputs.vName }}-training.apk \ No newline at end of file diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml index e156128ed9e..a9eccc75424 100644 --- a/.github/workflows/continuous-delivery.yml +++ b/.github/workflows/continuous-delivery.yml @@ -42,20 +42,43 @@ jobs: - name: Change wrapper permissions run: chmod +x ./gradlew + - name: Decode Keystore + id: decode_keystore + uses: timheuer/base64-to-file@784a1a4a994315802b7d8e2084e116e783d157be + with: + fileName: 'debug.keystore' + encodedString: ${{ secrets.DEBUG_KEYSTORE_BASE64 }} + # Create APK Debug - name: Build apk debug project (APK) - ${{ env.main_project_module }} module run: ./gradlew assembleDhis2Debug env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - - - name: Read version name from file - working-directory: ./gradle - id: read-version - run: echo "vName=$(grep 'vName' libs.versions.toml | awk -F' = ' '{print $2}' | tr -d '"')" >> "$GITHUB_OUTPUT" + DEBUG_KEYSTORE_ALIAS: ${{ secrets.DEBUG_KEYSTORE_ALIAS }} + DEBUG_KEY_PASS: ${{ secrets.DEBUG_KEY_PASS }} + DEBUG_KEYSTORE_PASSWORD: ${{ secrets.DEBUG_KEYSTORE_PASSWORD }} + DEBUG_KEYSTORE_PATH: ${{ steps.decode_keystore.outputs.filePath }} + OPEN_ID_AUTH_SCHEME: ${{ secrets.OPEN_ID_AUTH_SCHEME }} + OPEN_ID_TYPE: ${{ secrets.OPEN_ID_TYPE }} + OPEN_ID_SERVER: ${{ secrets.OPEN_ID_SERVER }} + OPEN_ID_CLIENT: ${{ secrets.OPEN_ID_CLIENT }} + OPEN_ID_REDIRECT_URI: ${{ secrets.OPEN_ID_REDIRECT_URI }} + OPEN_ID_DISCOVERY_URI: ${{ secrets.OPEN_ID_DISCOVERY_URI }} + OPEN_ID_AUTHORIZATION_URL: ${{ secrets.OPEN_ID_AUTHORIZATION_URL }} + OPEN_ID_TOKEN_URL: ${{ secrets.OPEN_ID_TOKEN_URL }} + OPEN_ID_BUTTON_TEXT: ${{ secrets.OPEN_ID_BUTTON_TEXT }} + + - name: Locate built APK + id: apk-path + run: | + echo "Searching for APKs under ${{ env.main_project_module }}/build/outputs/apk" + find "${{ env.main_project_module }}/build/outputs/apk" -type f -name "*.apk" + APK_PATH=$(find "${{ env.main_project_module }}/build/outputs/apk" -type f -name "*.apk" -print -quit) + echo "apk_path=$APK_PATH" >> "$GITHUB_OUTPUT" # Upload Artifact Build - name: Upload Android artifacts - uses: actions/upload-artifact@v6.0.0 + uses: actions/upload-artifact@v7.0.0 with: name: ${{ env.repository_name }} - Android APK - ${{ steps.date.outputs.date }} - path: ${{ env.main_project_module }}/build/outputs/apk/dhis2/debug/dhis2-v${{ steps.read-version.outputs.vName }}.apk + path: ${{ steps.apk-path.outputs.apk_path }} diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index fbd6b94d124..da2d40b7f33 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -49,7 +49,7 @@ jobs: encodedString: ${{ secrets.KEYSTORE }} - name: Build Release APKs - run: ./gradlew app:assembleDhis2Release app:assembleDhis2PlayServicesRelease + run: ./gradlew --dependency-verification strict app:assembleDhis2Release app:assembleDhis2PlayServicesRelease env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -73,7 +73,7 @@ jobs: TRAINING_KEY_PASSWORD: ${{ secrets.TRAINING_KEY_PASSWORD }} TRAINING_STORE_PASSWORD: ${{ secrets.TRAINING_STORE_PASSWORD }} TRAINING_STORE_FILE: ${{ steps.decode_training_keystore.outputs.filePath }} - run: ./gradlew app:assembleDhis2TrainingRelease + run: ./gradlew --dependency-verification strict app:assembleDhis2TrainingRelease - name: Read version name from file working-directory: ./gradle @@ -94,7 +94,7 @@ jobs: - name: Upload to Github if: ${{ inputs.github_release }} - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd with: allowUpdates: true draft: true diff --git a/.github/workflows/fix-kmp-translations.yml b/.github/workflows/fix-kmp-translations.yml new file mode 100644 index 00000000000..56d67418046 --- /dev/null +++ b/.github/workflows/fix-kmp-translations.yml @@ -0,0 +1,61 @@ +name: Fix KMP Translations + +on: + pull_request: + types: [opened, synchronize] + +jobs: + fix-kmp-translations: + if: startsWith(github.head_ref, 'develop-transifex-') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Remove escaped question marks from KMP compose resources + run: | + for dir in \ + "tracker/src/commonMain/composeResources" \ + "aggregates/src/commonMain/composeResources" \ + "commonskmm/src/commonMain/composeResources" \ + "login/src/commonMain/composeResources" \ + "sync/src/commonMain/composeResources"; do + if [ -d "$dir" ]; then + find "$dir" -name "*.xml" -exec sed -i 's/\\?/?/g' {} \; + fi + done + git add -A + + - name: Check if meaningful changes remain vs base branch + id: check + run: | + if git diff --cached --quiet origin/${{ github.base_ref }}; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push fix + if: steps.check.outputs.has_changes == 'true' + run: | + if ! git diff --cached --quiet; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "fix(translations): remove KMP-incompatible escaped question marks [skip ci]" + git push + fi + + - name: Close PR — no meaningful changes remain + if: steps.check.outputs.has_changes == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr comment ${{ github.event.pull_request.number }} \ + --body "Closing automatically: after removing Android-style escaped \`\?\` characters (incompatible with KMP Compose resources), no translation changes remain." + gh pr close ${{ github.event.pull_request.number }} --delete-branch diff --git a/.github/workflows/simcapture-github-release-signed-apk.yml b/.github/workflows/simcapture-github-release-signed-apk.yml index 2fda9ea3943..3ed4ac7e437 100644 --- a/.github/workflows/simcapture-github-release-signed-apk.yml +++ b/.github/workflows/simcapture-github-release-signed-apk.yml @@ -114,7 +114,7 @@ jobs: release_notes=$(cat <> $GITHUB_OUTPUT + echo "✅ verification-metadata.xml is up to date." + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Fail for fork PRs with outdated metadata + if: steps.check-diff.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository + run: | + echo "⚠️ Fork PR: verification-metadata.xml is out of date but cannot be auto-committed." + echo "Please run './gradlew --write-verification-metadata sha256 help' locally and commit the result." + exit 1 + + - name: Commit updated metadata + if: steps.check-diff.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository + uses: flex-development/gh-commit@177be4b5bca1b102400ca7220932d9ff7980fddb + with: + message: 'chore: update dependency verification metadata' + ref: ${{ github.head_ref }} + token: ${{ secrets.DHIS2_BOT_GITHUB_TOKEN }} diff --git a/.tx/config b/.tx/config index af74d17517a..dbefa2b2d9d 100644 --- a/.tx/config +++ b/.tx/config @@ -50,14 +50,6 @@ type = ANDROID minimum_perc = 0 resource_name = Main App -[o:hisp-uio:p:dhis2-android-capture-app:r:ui-strings-xml] -file_filter = ui-components/src/main/res/values-/strings.xml -source_file = ui-components/src/main/res/values/strings.xml -source_lang = en -type = ANDROID -minimum_perc = 0 -resource_name = UI Components - [o:hisp-uio:p:dhis2-android-capture-app:r:table-strings-xml] file_filter = compose-table/src/main/res/values-/strings.xml source_file = compose-table/src/main/res/values/strings.xml @@ -105,4 +97,3 @@ source_lang = en type = ANDROID minimum_perc = 0 resource_name = Sync - diff --git a/AGENTS.md b/AGENTS.md index e92e6e44c05..104fec32f4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,470 +1,192 @@ -# DHIS2 Android Capture App - Development Guidelines +# DHIS2 Android Capture App - Agent Guidelines -This document serves as a comprehensive guide for developers working on the DHIS2 Android Capture -App, outlining best practices, architecture patterns, and implementation examples. It is designed to -be useful for both human developers and AI assistants like Copilot. +This is a **Kotlin Multiplatform (KMP)** Android project migrating to Compose Multiplatform, +targeting Android, Desktop, and iOS. The app uses MVVM + Repository + Use Case architecture. -## Project Overview +--- -This project is a DHIS2 Android application that is migrating from a traditional Android app to a * -*Kotlin Multiplatform (KMP)** project. The migration is ongoing with the goal of eventually becoming -a full Compose Multiplatform application supporting Android, iOS, and Desktop platforms. +## Build & Lint Commands -## Technology Stack +```bash +# Full lint check (ktlint 1.7.1, ktlint_official style) +./gradlew ktlintCheck -### Core Technologies +# Auto-format all sources +./gradlew ktlintFormat -- **Kotlin**: Primary programming language -- **Kotlin Multiplatform (KMP)**: Target platform for cross-platform development -- **Compose Multiplatform**: UI framework for all platforms -- **Gradle**: Build system with Kotlin DSL +# Run all unit tests (debug + KMP host tests) +./gradlew testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest -### UI Framework +# Shortcut: lint + all unit tests (mirrors CI) +./run_tests.sh -- **Primary UI**: Use `@dhis2/dhis2-mobile-ui` design system (based on Compose Multiplatform) - - Use latest stable version (check `gradle/libs.versions.toml` for current version) - - Import: `org.hisp.dhis.mobile.ui.designsystem.*` - - Always prefer DHIS2 design system components over Material components when available +# Run a single test class (KMP commonTest) +./gradlew :login:testAndroidHostTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest" -### Data Layer +# Run a single test method (KMP commonTest) +./gradlew :login:testAndroidHostTest --tests "org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModelTest.initial screen is set correctly when starting" -- **DHIS2 Android SDK**: Use `@dhis2/dhis2-android-sdk` for all data operations - - Use latest stable version (check `gradle/libs.versions.toml` for current version) - - Import: `org.hisp.dhis.android.core.*` - - Handles persistence, offline/online synchronization, and DHIS2 API communication - - Never create direct network calls or database operations - use the SDK +# Build debug APK +./gradlew assembleDhis2Debug -## Architecture and Patterns - -This section describes the core architecture patterns used in the project, along with implementation -examples and best practices. - -### MVVM Architecture - -The Model-View-ViewModel (MVVM) pattern is used to separate concerns between the UI (View), business -logic (ViewModel), and data (Model). +# Build all modules +./gradlew assemble +``` -- **ViewModels**: Manage UI state and business logic - - Use `androidx.lifecycle.ViewModel` or platform-specific equivalents - - Expose state via `StateFlow` and `Flow` - - Handle UI events and coordinate with repositories/use cases +**Gradle task naming by module type:** +- Legacy Android modules (`form`, `commons`, `tracker`, etc.): `testDebugUnitTest` +- KMP modules (`login`, `commonskmm`, `sync`, `aggregates`), `commonTest` source set: `testAndroidHostTest` +- KMP modules, `androidUnitTest` source set: `testAndroidDebugUnitTest` +- Desktop targets in KMP modules: `desktopTest` -- **Views/Composables**: UI layer that observes ViewModel state - - Use `@Composable` functions for UI components - - Collect state using `collectAsState()` - - Keep composables pure and stateless when possible - - Use Compose multiplatform previews (`@Preview`) to validate UI components +--- -#### ViewModel Implementation Example +## Project Structure -```kotlin -class ExampleViewModel( - private val getDataUseCase: GetDataUseCase -) : ViewModel() { - private val _uiState = MutableStateFlow(UiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - loadData() - } - - private fun loadData() { - // Use the `launchUseCase` ViewModel extension which wraps coroutine tracking and uses - // a background dispatcher by default (it increments/decrements CoroutineTracker). - launchUseCase { - // `getDataUseCase()` is an extension for `UseCase` that calls the use case with Unit - val result = getDataUseCase() - result.fold( - onSuccess = { flow -> - flow - .catch { _uiState.value = UiState.Error(it.message ?: "Unknown error") } - .collect { _uiState.value = UiState.Success(it) } - }, - onFailure = { throwable -> - _uiState.value = UiState.Error(throwable.message ?: "Unknown error") - } - ) - } - } -} ``` - -#### Composable Implementation Example - -```kotlin -@Composable -fun ExampleScreen( - viewModel: ExampleViewModel = koinViewModel() -) { - val uiState by viewModel.uiState.collectAsState() - - DHIS2Theme { - when (uiState) { - is UiState.Loading -> LoadingIndicator() - is UiState.Success -> ExampleContent(uiState.data) - is UiState.Error -> ErrorMessage(uiState.message) - } - } -} +root/ +├── app/ # Main Android application +├── commonskmm/ # KMP shared utilities, base classes, DI helpers +├── login/ # KMP login feature (Android + Desktop) +├── sync/ # KMP sync feature +├── aggregates/ # KMP aggregate data feature +├── tracker/ # Android tracker feature +├── form/ # Android form module +├── commons/ # Android shared utilities (legacy) +├── compose-table/ # Compose table component +├── dhis2-mobile-program-rules/ # KMP program rules engine +└── gradle/libs.versions.toml # Central dependency catalog ``` -### Repository Pattern - -Repositories provide an abstraction layer over data sources, handling data access and mapping -between SDK models and domain models. - -- **Repositories**: Abstract data access layer - - Define interfaces in `commonMain` - - Implement platform-specific versions in `androidMain`, `desktopMain`, etc. - - Use DHIS2 SDK for data operations - - Handle data mapping between SDK models and domain models - - Map SDK exceptions to domain errors: repository implementations should translate platform/SDK - exceptions into domain-level errors using the project's `DomainErrorMapper` (or equivalent). - This keeps the domain layer SDK-agnostic and makes error handling consistent across the app. - - Imports you will commonly need in Android implementations: - ``` - import org.dhis2.mobile.commons.error.DomainErrorMapper - import org.hisp.dhis.android.core.maintenance.D2Error - ``` - - Recommendations: - - Keep repository method signatures `suspend` and let them throw domain-level exceptions - rather than returning raw SDK exceptions. - - Catch/translate only SDK-specific exceptions; allow unexpected exceptions to bubble up - or wrap them in a generic domain error if appropriate. - - Write unit tests that mock `DomainErrorMapper` to assert error mapping behavior. - -#### Repository Implementation Example - -```kotlin -class ExampleRepositoryImpl( - private val d2: D2, - private val domainErrorMapper: DomainErrorMapper -) : ExampleRepository { - override suspend fun getData(): Flow> { - return try { - d2.exampleModule().examples() - .get() - .asFlow() - .map { it.map { example -> example.toDomainModel() } } - } catch (d2Error: D2Error) { - throw domainErrorMapper.mapToDomainError(d2Error) - } - } -} +**KMP module source sets:** ``` - -### Use Cases - -Use cases encapsulate complex business logic, providing a clear interface for ViewModels to interact -with the domain layer. - -- **Use Cases**: Encapsulate complex business logic - - Single responsibility principle - - Implement the shared `UseCase` interface: `UseCase` (see - `commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/domain/UseCase.kt`). Use cases must - implement/extend this interface so they return a `Result` from their `invoke` method. - - For parameterless use cases use `UseCase` and the provided extension - `suspend operator fun UseCase.invoke() = this(Unit)` to call them without passing - `Unit` explicitly. - - Return `Result` (often wrapping a `Flow` when the use case emits a stream). Handle - exceptions inside the use case and wrap success/failure using `Result.success` / - `Result.failure`. - - Coordinate between multiple repositories if needed - - Return `Flow` or `suspend` functions for async operations - - Place in domain layer - -#### Use Case Implementation Example - -```kotlin -class GetDataUseCase( - private val repository: ExampleRepository -) : UseCase>> { - override suspend operator fun invoke(input: Unit): Result>> { - return try { - val flow = repository.getData() - .map { data -> data.filter { it.isValid } } - Result.success(flow) - } catch (e: Exception) { - Result.failure(e) - } - } -} +modulekmm/src/ +├── commonMain/kotlin/ # Shared business logic, interfaces, use cases +├── commonTest/kotlin/ # Shared unit tests (kotlin-test + mockito-kotlin + turbine) +├── androidMain/kotlin/ # Android implementations, SDK access +├── androidUnitTest/kotlin/ # Android-specific unit tests +├── desktopMain/kotlin/ # Desktop implementations +└── composeResources/ # Shared Compose resources (strings, images) ``` -### State Management +--- -Use sealed classes to represent different UI states in a type-safe manner. +## Code Style (enforced by ktlint 1.7.1) -- **Sealed Classes**: Use for representing different states - ```kotlin - sealed class UiState { - object Loading : UiState() - data class Success(val data: T) : UiState() - data class Error(val message: String) : UiState() - } - ``` - -### Reactive Programming - -Leverage Kotlin's Flow and Coroutines for reactive and asynchronous programming. - -- **Flow**: For reactive data streams - - Use `StateFlow` for state that can be observed - - Use `Flow` for data streams - - Combine flows using operators like `combine`, `flatMapLatest` - -- **Coroutines**: For asynchronous programming - - Use `viewModelScope` in ViewModels - - Handle errors with try-catch blocks - - Use `Dispatchers.IO` for I/O operations - -### Dependency Injection +Config in `.editorconfig`: +- **Style**: `ktlint_official` +- **No wildcard imports** (`ktlint_standard_no-wildcard-imports = enabled`) +- **No unused imports** (`ktlint_standard_no-unused-imports = enabled`) +- **Trailing commas required** on both call and declaration sites +- **Ordered imports** (`ktlint_standard_import-ordering = enabled`) +- **Function naming**: standard rule disabled — composables may use PascalCase per Compose conventions -Koin is used for dependency injection, supporting multiplatform development. - -- **Koin**: Dependency injection framework - - Use latest stable version (check `gradle/libs.versions.toml` for current version) - - Define modules in `commonMain` when possible - - Use `expect`/`actual` pattern for platform-specific dependencies - - Module structure: `val commonModule: Module = module { ... }` - -#### Koin Module Definition Example - -```kotlin -val exampleModule = module { - single { ExampleRepositoryImpl(get()) } - single { GetDataUseCase(get()) } - viewModel { ExampleViewModel(get()) } -} -``` +**General Kotlin conventions:** +- JVM target: Java 17 (`sourceCompatibility = JavaVersion.VERSION_17`) +- Prefer `data class` over plain class for models +- Use `sealed class` / `sealed interface` for UI state +- Use `object` for singletons, companion objects for constants +- Prefer expression bodies for single-expression functions +- Document public APIs with KDoc -## Project Structure +--- -### Multiplatform Module Organization +## Architecture Patterns +### Layer structure (per feature module) ``` -modulekmm/ -├── src/ -│ ├── commonMain/kotlin/ # Shared code -│ ├── commonTest/kotlin/ # Shared tests -│ ├── androidMain/kotlin/ # Android-specific code -│ ├── androidUnitTest/kotlin/# Android unit tests -│ ├── desktopMain/kotlin/ # Desktop-specific code -│ └── iosMain/kotlin/ # iOS-specific code (when applicable) +domain/ + model/ # Pure data classes / sealed states + usecase/ # Business logic, implements UseCase + repository/ # Repository interfaces +data/ + repository/ # Repository implementations (androidMain) +ui/ + state/ # UiState sealed classes + viewmodel/ # ViewModels (expose StateFlow) + screen/ # @Composable screens + component/ # Reusable composables +di/ # Koin module definitions ``` -### Code Organization - -- **Domain Layer**: Models, use cases, repository interfaces -- **Data Layer**: Repository implementations, data sources -- **UI Layer**: Composables, ViewModels, navigation -- **DI Layer**: Dependency injection modules - -### Platform-Specific Code - -- Use `expect`/`actual` pattern for platform differences -- Keep platform-specific code minimal -- Prefer shared implementations in `commonMain` - -## Development Guidelines - -### Compose Multiplatform - -- **Components**: Always check DHIS2 design system first - - Use components from `org.hisp.dhis.mobile.ui.designsystem.component.*` - - Use theme from `org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme` -- **Navigation**: Use Compose Navigation for multiplatform -- **Resources**: Place in `commonMain/composeResources/` -- **Theming**: Use DHIS2Theme wrapper - -### Data Operations - -- **Never bypass the SDK**: Always use DHIS2 Android SDK for all data operations - - Use components from `org.hisp.dhis.android.core.*` -- **Offline-first**: Design with offline capabilities in mind -- **Sync handling**: Let the SDK handle synchronization -- **Error handling**: Handle SDK exceptions appropriately - -### Testing - -- **Unit Tests**: Place in appropriate test directories -- **Shared Tests**: Use `commonTest` for platform-agnostic tests -- **Mocking**: Use Mockito (`org.mockito.kotlin`) for mocking — `mock()`, `whenever()`, `doReturn`, `verify()` -- **Repository Tests**: Mock DHIS2 SDK components -- **Integration Tests**: Test full flows from ViewModel through use cases to a mocked repository (see `TwoFAScreenConfigurationIntegrationTest` for a reference) -- **UI Tests**: Follow the Robot pattern for instrumented tests (see detailed section below) - -### UI Testing Guidelines - -- **Location**: Place UI tests in `androidInstrumentedTest` -- **Pattern**: Use Robot pattern for test actions and assertions -- **Async handling**: Use `CoroutineTracker` with `launchUseCase` - never use hard-coded delays - - Espresso's `IdlingResource` automatically waits for tracked operations to complete - - This enables faster, more reliable tests without manual wait mechanisms -- **Test tags**: Add `Modifier.testTag()` to interactive UI components - - Format: `{SCREEN}_{COMPONENT}_TAG` (e.g., `LOGIN_BUTTON_TAG`) - - Export constants from screen files for test imports -- **DHIS2 design system components**: These are composite components - - Click the wrapper with your test tag to focus it - - Use `"INPUT_TEXT_FIELD"` tag to find inner fields - - Use `performTextInput()` (not `performTextReplacement()`) -- **Mock server**: Use `MockWebServerRobot` for API mocking -- **Best practices**: - 1. Use `waitUntilExactlyOneExists()` for element visibility - 2. Use descriptive robot method names (e.g., `clickLoginButton()`) - 3. Keep robots focused on actions, not assertions - 4. Test user flows, not isolated components - 5. Mock all external dependencies (network, SDK responses) - 6. Clean up after tests (databases, preferences) - -#### Example: Robot Class +### UseCase interface (commonskmm) +All use cases must implement `UseCase` from +`commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/domain/UseCase.kt`: ```kotlin -fun exampleRobot( - composeTestRule: ComposeTestRule, - robotBody: ExampleRobot.() -> Unit -) { - ExampleRobot(composeTestRule).apply { robotBody() } -} - -class ExampleRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { - fun typeUsername(username: String) { - composeTestRule.waitUntilExactlyOneExists(hasTestTag(USERNAME_TAG), TIMEOUT) - composeTestRule.onNodeWithTag(USERNAME_TAG).performClick() - composeTestRule.onAllNodesWithTag("INPUT_TEXT_FIELD")[0].performTextInput(username) - } - - fun clickSubmitButton() { - composeTestRule.waitUntilExactlyOneExists(hasTestTag(SUBMIT_TAG), TIMEOUT) - composeTestRule.onNodeWithTag(SUBMIT_TAG).performClick() - } +// Interface definition +fun interface UseCase { + suspend operator fun invoke(input: R): Result } +// Parameterless convenience extension +suspend operator fun UseCase.invoke() = this(Unit) ``` -#### Example: Test Structure - +Implementation pattern: ```kotlin -class ExampleTest : BaseTest() { - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun shouldPerformSuccessfulAction() { - mockWebServerRobot.addResponse(GET, "/api/endpoint", MOCK_RESPONSE, 200) - - exampleRobot(composeTestRule) { - typeUsername("user") - clickSubmitButton() - // IdlingResource handles async automatically - checkSuccessMessageDisplayed() +class SavePinUseCase(private val repo: SessionRepository) : UseCase { + override suspend fun invoke(input: String): Result = + try { + repo.savePin(input) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) } - - cleanDatabase() - } } ``` -### Integration Testing Guidelines - -Integration tests wire together the ViewModel, use cases, and a mocked repository to verify -complete feature flows end-to-end without touching the network or a real database. +### ViewModel pattern +- Use `launchUseCase { }` (not `viewModelScope.launch`) — it wraps `CoroutineTracker` + which integrates with Espresso's `IdlingResource` for reliable UI tests +- Expose state via `StateFlow`; collect in composables with `collectAsState()` -- **Location**: Place in `commonTest` (shared across platforms) when the ViewModel and use cases live in `commonMain` -- **Pattern**: Instantiate real use cases with a mocked repository; inject a `StandardTestDispatcher`; observe the ViewModel's `StateFlow` via [Turbine](https://github.com/cashapp/turbine) (`flow.test { … }`) -- **Mocking**: Use Mockito (`org.mockito.kotlin`) — `mock()`, `whenever()`, `doReturn`, `doReturnConsecutively` -- **Async handling**: Use `StandardTestDispatcher` + `Dispatchers.setMain(testDispatcher)` / `Dispatchers.resetMain()` and `runTest { … }` -- **Best practices**: - 1. Use `@BeforeTest` / `@AfterTest` (from `kotlin.test`) to set up and tear down the dispatcher - 2. Follow the Given / When / Then comment structure - 3. Assert every intermediate state (loading, success, error) not just the final result - 4. Use `cancelAndIgnoreRemainingEvents()` to cleanly finish a Turbine block - -#### Example: Integration Test Structure +### Repository pattern (Android implementations) +- Translate `D2Error` → domain errors via `DomainErrorMapper` +- Required imports for Android impls: + ```kotlin + import org.dhis2.mobile.commons.error.DomainErrorMapper + import org.hisp.dhis.android.core.maintenance.D2Error + ``` +### Dependency Injection (Koin 4.x) ```kotlin -@OptIn(ExperimentalCoroutinesApi::class) -class ExampleIntegrationTest { - private val testDispatcher = StandardTestDispatcher() - private lateinit var repository: ExampleRepository - private lateinit var viewModel: ExampleViewModel - private val dispatchers = Dispatcher(testDispatcher, testDispatcher, testDispatcher) - - @BeforeTest - fun setup() { - Dispatchers.setMain(testDispatcher) - repository = mock() - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `Given repository returns data, When ViewModel loads, Then success state is shown`() = - runTest { - // Given - whenever(repository.getData()) doReturn Result.success(listOf("item")) - - // When - viewModel = ExampleViewModel(GetDataUseCase(repository), dispatchers) - - // Then - viewModel.uiState.test { - assertEquals(UiState.Loading, awaitItem()) - assertTrue(awaitItem() is UiState.Success) - cancelAndIgnoreRemainingEvents() - } - } +val featureModule = module { + single { MyRepositoryImpl(get(), get()) } + factory { MyUseCase(get()) } + viewModel { MyViewModel(get()) } } ``` +- Define modules in `commonMain` where possible; use `expect`/`actual` for platform DI +- Inject ViewModels in composables with `koinViewModel()` -### Code Style - -- **Kotlin conventions**: Follow official Kotlin coding conventions -- **ktlint**: Project uses ktlint for formatting -- **Imports**: Organize imports, prefer explicit imports -- **Documentation**: Document public APIs with KDoc - -## Best Practices & Migration Guidelines +--- -### Core Development Practices +## UI Guidelines -1. **Always use DHIS2 design system components** before falling back to Material components -2. **Never create direct database or network operations** - use DHIS2 SDK exclusively -3. **Keep business logic in ViewModels or Use Cases**, not in Composables -4. **Use sealed classes for state representation** -5. **Prefer composition over inheritance** -6. **Write tests for business logic and repositories** -7. **Handle loading and error states appropriately** -8. **Follow offline-first design principles** -9. **Keep platform-specific code minimal** - use `expect`/`actual` pattern -10. **Use meaningful commit messages and follow Git flow** +- **Always prefer** DHIS2 design system components (`org.hisp.dhis.mobile.ui.designsystem.*`) + over Material components +- Wrap screens in `DHIS2Theme { }` from `org.hisp.dhis.mobile.ui.designsystem.theme` +- Place shared Compose resources in `commonMain/composeResources/` +- Use multiplatform Compose Navigation (`org.jetbrains.androidx.navigation:navigation-compose`) +- Add `@Preview` annotations to validate composables in isolation -### Important Warnings +--- -- **Coroutine cancellation**: Remember to handle coroutine cancellation properly -- **SDK exception handling**: DHIS2 SDK operations might throw exceptions - always handle them -- **Component availability**: Some DHIS2 design system components might not be available yet - check documentation -- **RxJava migration**: When migrating from RxJava to Coroutines/Flow, ensure proper error handling -- **Platform-specific resources**: Handle resources differently in multiplatform (not all platforms support identical APIs) +## Testing -### Migrating from Android to KMP +- **Unit tests**: `mockito-kotlin` + `kotlin.test` in `commonTest`; `mockito-kotlin` + JUnit in `androidUnitTest` and legacy modules +- **Flow assertions**: Turbine (`app.cash.turbine`) + `kotlinx-coroutines-test` +- **UI tests**: Compose Testing + Espresso, Robot pattern, located in `androidInstrumentedTest/` +- **ViewModel coroutines**: always use `launchUseCase { }` — it wraps `CoroutineTracker` which integrates with Espresso's `IdlingResource`; never use `Thread.sleep()` -- **Code organization**: Move shared logic to `commonMain`; extract platform-specific code to `androidMain`, `desktopMain`, etc. -- **UI conversion**: Convert View-based UI to Compose Multiplatform -- **Dependency injection**: Update to use Koin multiplatform -- **Libraries**: Prefer multiplatform libraries over platform-specific ones -- **Compatibility**: Check compatibility with Compose Multiplatform before selecting dependencies -- **Documentation**: Document public APIs with KDoc +For patterns, examples, and common mistakes load the **android-testing** skill. +--- -## Resources +## Key Constraints -- [DHIS2 Mobile UI Documentation](https://ui.dhis2.nu/components) -- [DHIS2 Android SDK Documentation](https://docs.dhis2.org/en/develop/using-the-api/dhis-core-version-master/android-sdk.html) -- [Compose Multiplatform Documentation](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-getting-started.html) -- [Koin Multiplatform Documentation](https://insert-koin.io/docs/reference/koin-mp/kmp) -- [Android Architecture Guidelines](https://developer.android.com/topic/architecture) -- [Compose Testing Documentation](https://developer.android.com/jetpack/compose/testing) -- [Espresso Idling Resources](https://developer.android.com/training/testing/espresso/idling-resource) +1. **Never create direct network or database calls** — use the DHIS2 Android SDK (`org.hisp.dhis.android.core.*`) +2. **Offline-first**: design features to work without connectivity; let the SDK handle sync +3. **KMP first**: put business logic in `commonMain`; keep `androidMain` to SDK/platform specifics +4. **No RxJava in new code**: migrate to Coroutines/Flow; wrap existing RxJava at boundaries +5. **ktlint must pass** before committing — run `./gradlew ktlintFormat` then `ktlintCheck` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..9aa330bf2e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@AGENTS.md \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index de22090ec9f..0cf65b9502b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -66,13 +66,18 @@ pipeline { } } stage('Unit tests') { + when { + expression { + return !isSkipUnitTest() + } + } environment { ANDROID_HOME = '/opt/android-sdk' } steps { script { echo 'Running unit tests' - sh './gradlew testDebugUnitTest testDhis2DebugUnitTest --stacktrace --no-daemon' + sh './gradlew testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest --stacktrace --no-daemon' } } } @@ -202,3 +207,9 @@ def isSkipSizeCheck() { def prDescription = env.CHANGE_DESCRIPTION ?: "" return (prTitle.contains("[skip size]") || prDescription.contains("[skip size]")) } + +def isSkipUnitTest() { + def prTitle = env.CHANGE_TITLE ?: "" + def prDescription = env.CHANGE_DESCRIPTION ?: "" + return (prTitle.contains("[skip unitTest]") || prDescription.contains("[skip unitTest]")) +} diff --git a/README.md b/README.md index 6ef96328ed8..8651296213f 100644 --- a/README.md +++ b/README.md @@ -12,33 +12,33 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Infra: fork sync from upstream | [simcapture-sync-fork-upstream-main.yml](.github/workflows/simcapture-sync-fork-upstream-main.yml) | New file | GitHub Action to sync upstream's `main` to this repo's `upstream-main` nightly on Mon-Fri | | Infra: automatic PR on upstream release | [simcapture-upstream-release-pr.yml](.github/workflows/simcapture-upstream-release-pr.yml) | New file | GitHub Action to open a PR when `upstream-main` has an upstream release newly merged into it | | Infra: signed APK releases | [simcapture-github-release-signed-apk.yml](.github/workflows/simcapture-github-release-signed-apk.yml) | New file | GitHub Action to create a GitHub Release on a merge to `main` | -| Infra: signed APK releases | [app/build.gradle.kts#L85](app/build.gradle.kts#L85) | Code change | App's Package ID set to `com.simprints.simcapture` | +| Infra: 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-L70](README.md#L1-L70) | Code change | This section in README | -| Enrolment+ Possible Duplicates | [CustomIntentActivityResultContract.kt#L68](form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt#L68) | Code addition | Treats non-identify Simprints callouts that return identification matches as `PossibleDuplicates` results | -| Enrolment+ Possible Duplicates | [FormView.kt#L654](form/src/main/java/org/dhis2/form/ui/FormView.kt#L654) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | -| Enrolment+ Possible Duplicates | [EnrollmentActivity.kt#L281](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L281) | Code addition | Launches possible duplicates search from the enrolment form, carrying the biometric field and returned GUID matches | +| Docs: fork-specific README | [README.md#L1-L82](README.md#L1-L82) | 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 | [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#L554](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L554) | 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#L307](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L307) | Code addition | Replaces the default "+ New" action button with `None of the above` while reviewing possible duplicates | -| Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L502](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L502) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | -| Enrolment+ Enrol Last | [SearchTEActivity.kt#L655](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L655) | 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#L145](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L145) | Code addition | Receives the possible duplicates return flow and launches save-time Enrol Last before enrolment finishes | +| Enrolment+ Possible Duplicates | [SearchTEIViewModel.kt#L676](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L676) | 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#L315](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L315) | Code addition | Replaces the default "+ New" action button with `None of the above` while reviewing possible duplicates | +| Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L608](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L608) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | +| Enrolment+ Enrol Last | [SearchTEActivity.kt#L649](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L649) | 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#L29](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt#L29) | Code addition | Keeps separate pending Enrol Last source state for possible-duplicates returns | +| 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#L83](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L83) | Code addition | Launches Enrol Last before completing a valid enrollment save and resumes finish after SID returns | +| Identification+ Enrol Last | [EnrollmentActivity.kt#L313](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L313) | 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#L182](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L182) | 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#L571](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L571) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | -| Identification+ Enrol Last | [SimprintsSearchViewModel.kt#L96](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L96) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | -| Identification+ Enrol Last | [SearchTEIViewModel.kt#L1266](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1266) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | -| Identification+ Enrol Last | [SearchTEList.kt#L289](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L289) | 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#L688](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L688) | 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#L61](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L61) | Code change | Counts pending Enrol Last values as present for form completion, sections, and mandatory validation | -| Identification+ Enrol Last | [Injector.kt#L268](form/src/main/java/org/dhis2/form/di/Injector.kt#L268) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | -| Identification+ Enrol Last | [CustomIntentProvider.kt#L75](form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt#L75) | Code addition | Shows pending biometric placeholder state, clears it on user clear, and routes Simprints enrollment results | +| 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#L1440](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1440) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | +| Identification+ Enrol Last | [SearchTEList.kt#L297](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L297) | 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#L694](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L694) | 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#L270](form/src/main/java/org/dhis2/form/di/Injector.kt#L270) | 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 | | Identification+ Enrol Last | [SimprintsD2Repository.kt](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsD2Repository.kt) | New file | Provides D2 access for enrollment context and TEA value reads/writes used by Simprints flows | @@ -48,25 +48,25 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification+ Enrol Last | [SimprintsSearchUtils.kt](commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsSearchUtils.kt) | New file | Detects biometric identification queries and filters biometric GUIDs before carrying enrollment data | | 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#L824](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L824) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | -| Identification Confirm Identity | [SearchTEModule.java#L358](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L358) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | -| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L46](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L46) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | +| Identification Confirm Identity | [SearchTEActivity.kt#L126](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L126) | Code addition | Registers the confirm-identity SID launcher and waits for Simprints navigation before opening dashboards | +| Identification Confirm Identity | [SearchTEIViewModel.kt#L959](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L959) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | +| Identification Confirm Identity | [SearchTEModule.java#L357](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L357) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | +| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L33](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L33) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | | Identification Confirm Identity | [SimprintsSearchViewModelFactory.kt](app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt) | New file | Creates activity-scoped Simprints search ViewModel so Confirm Identity and Enrol Last state survives configuration changes | | Identification Confirm Identity | [SimprintsSearchViewModel.kt](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt) | New file | Coordinates biometric search handoff for Enrol Last labels and Confirm Identity navigation | | Identification Confirm Identity | [SimprintsResolveConfirmIdentityCalloutUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt) | New file | Resolves the selected TEI's biometric GUID and prepares the `CONFIRM_IDENTITY` callout | | ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L105](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L105) | Code addition | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit value | -| Identification result ordering by score | [SearchTEIViewModel.kt#L692](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L692) | 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#L350](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L350) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | -| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L47](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L47) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | +| Identification result ordering by score | [SearchTEIViewModel.kt#L696](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L696) | 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#L365](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L365) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | +| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L34](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L34) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | | Identification result ordering by score | [SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt) | New file | Orders matching TEIs to follow the SID identification response order | -| Identification with less buttons | [ParameterSelectorItemProvider.kt#L92](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt#L92) | Code addition | Starts Simprints identification callout immediately on biometric search field click instead of expanding the field | -| Identification with less buttons | [SearchTEUi.kt#L211](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L211) | Code change | Keeps the Biometric search label read-only | -| Identification with less buttons | [SearchParametersScreen.kt#L140](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt#L140) | 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#L1231](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1231) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | -| Identification with less buttons | [SearchTEActivity.kt#L620](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L620) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | -| MFID Auto-Open Record | [SearchParametersScreen.kt#L498](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt#L498) | Code addition | Detects credential-linked identification results that qualify for the MFID direct-open shortcut | -| MFID Auto-Open Record | [SimprintsSearchViewModel.kt#L187](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L187) | Code addition | Consumes an eligible single MFID identification to open the matched record directly instead of showing the result list | +| Identification with less buttons | [SearchTEIViewModel.kt#L1659](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1659) | 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#L93](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt#L93) | 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#L1385](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1385) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | +| Identification with less buttons | [SearchTEActivity.kt#L611](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L611) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | +| MFID Auto-Open Record | [SearchParametersScreenProvider.kt#L97](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt#L97) | Code addition | Detects credential-linked identification results that qualify for the MFID direct-open shortcut | +| MFID Auto-Open Record | [SimprintsSearchViewModel.kt#L190](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L190) | 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 | ### Releases @@ -86,9 +86,29 @@ The upstream `README` is preserved below. [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dhis2_dhis2-android-capture-app&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=dhis2_dhis2-android-capture-app) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dhis2_dhis2-android-capture-app&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=dhis2_dhis2-android-capture-app) -Check the [Wiki](https://github.com/dhis2/dhis2-android-capture-app/wiki) for information about how -to build the project and its architecture **(WIP)** +## Introduction -### What is this repository for? ### +DHIS2 Android Capture App is a mobile application that allows offline data collection for DHIS2. It enables health workers and data collectors to access and update DHIS2 data from their mobile devices without requiring constant internet connectivity. -DHIS2 Android application. +Main features: + +- **Offline-first data capture**. Enter data for events, tracked entities, and data sets without an internet connection. Data is stored locally and synchronized when connectivity is available. +- **Complete DHIS2 workflows**. Support for Tracker programs, Event programs, and Data Sets with validation rules, program rules, and indicators. +- **Native mobile experience**. Built with modern Android technologies to provide a fast, intuitive, and reliable user experience. +- **Synchronized with DHIS2**. Seamlessly integrates with DHIS2 server instances, ensuring data consistency and compatibility across DHIS2 versions. + +## Documentation + +Implementation guidance and user documentation can be found in the [Android Implementation Guide](https://docs.dhis2.org/en/implement/android-implementation/executive-summary.html) in DHIS2 documentation. + +Developer-oriented documentation and architecture details can be found in [AGENTS.md](AGENTS.md). + +## Getting Started + +This app is built using the [DHIS2 Android SDK](https://github.com/dhis2/dhis2-android-sdk) and the [DHIS2 Mobile UI library](https://developers.dhis2.org/docs/mobile/mobile-ui/overview). To get started with development, check the [AGENTS.md](AGENTS.md) file for comprehensive development guidelines, architecture patterns, and best practices. + +## Community + +Community support can be found in the [DHIS2 Community portal](https://community.dhis2.org/c/implementation/mobile). Any feedback on the app will be highly appreciated. + +To report bugs or request new features, visit the [DHIS2 Jira project](https://jira.dhis2.org/projects/ANDROAPP/issues). diff --git a/RELEASE.md b/RELEASE.md index ed20a609629..d6427abcdc4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,75 +1,7 @@ -# Release notes - Android App for DHIS2 - 3.3.1 +# Release notes - Android App for DHIS2 - 3.4.0.1 -### Bug +This patch release includes a hotfix. -[ANDROAPP-6870](https://dhis2.atlassian.net/browse/ANDROAPP-6870) Let the rule-engine apply the logic for useCodeForOptionSet in RuleVariable +## Bugs fixed -[ANDROAPP-6975](https://dhis2.atlassian.net/browse/ANDROAPP-6975) Crash when rotating the device with the save dialog open - -[ANDROAPP-7211](https://dhis2.atlassian.net/browse/ANDROAPP-7211) NoSuchElementException: Collection contains no element matching the predicate. - -[ANDROAPP-7235](https://dhis2.atlassian.net/browse/ANDROAPP-7235) Program rules not triggered when moving between fields manually - -[ANDROAPP-7260](https://dhis2.atlassian.net/browse/ANDROAPP-7260) Incorrect behavior when creating a new event in timeline view - -[ANDROAPP-7261](https://dhis2.atlassian.net/browse/ANDROAPP-7261) Keyboard blocks the last field when entering data \(screen doesn’t scroll\) - -[ANDROAPP-7269](https://dhis2.atlassian.net/browse/ANDROAPP-7269) Crash on search - -[ANDROAPP-7293](https://dhis2.atlassian.net/browse/ANDROAPP-7293) Bottom sheet landscape behavior - -[ANDROAPP-7345](https://dhis2.atlassian.net/browse/ANDROAPP-7345) Changes to enrollment date not respected by program rules \(version 3.2.1.2\) - -[ANDROAPP-7368](https://dhis2.atlassian.net/browse/ANDROAPP-7368) crash: when trying to update fields in Tracker - -[ANDROAPP-7394](https://dhis2.atlassian.net/browse/ANDROAPP-7394) Login blocked after logout “The user is already logged in” error - -[ANDROAPP-7400](https://dhis2.atlassian.net/browse/ANDROAPP-7400) Crash - changing org unit and dates - -[ANDROAPP-7402](https://dhis2.atlassian.net/browse/ANDROAPP-7402) Bottom sheet org unit not displaying buttons - -[ANDROAPP-7403](https://dhis2.atlassian.net/browse/ANDROAPP-7403) Home cards lose proper layout - -[ANDROAPP-7411](https://dhis2.atlassian.net/browse/ANDROAPP-7411) Android sync by working lists: After updating predefined list views settings, changes aren't reflected in android app after syncing - -[ANDROAPP-7415](https://dhis2.atlassian.net/browse/ANDROAPP-7415) Android: WORKING LIST incorrect result in app - -[ANDROAPP-7419](https://dhis2.atlassian.net/browse/ANDROAPP-7419) Server selection title is missing - -[ANDROAPP-7421](https://dhis2.atlassian.net/browse/ANDROAPP-7421) Data set table not opening after clicking next for default category combo - -[ANDROAPP-7425](https://dhis2.atlassian.net/browse/ANDROAPP-7425) NullPointerException: ProgramFragment - -[ANDROAPP-7428](https://dhis2.atlassian.net/browse/ANDROAPP-7428) LMIS program is using completed enrollment - -[ANDROAPP-7442](https://dhis2.atlassian.net/browse/ANDROAPP-7442) Program rules not triggering for completed enrollments - -### Task - -[ANDROAPP-7286](https://dhis2.atlassian.net/browse/ANDROAPP-7286) Replace deprecated categoryComboUid usages with categoryCombo in Program and DataElement - -[ANDROAPP-7288](https://dhis2.atlassian.net/browse/ANDROAPP-7288) Implement UseCase interface - -[ANDROAPP-7318](https://dhis2.atlassian.net/browse/ANDROAPP-7318) Create AGENTS.md file - -[ANDROAPP-7349](https://dhis2.atlassian.net/browse/ANDROAPP-7349) Sonarcloud - Use full commit SHA hash for this dependency. - -[ANDROAPP-7373](https://dhis2.atlassian.net/browse/ANDROAPP-7373) LogoutUser use case improvements - -[ANDROAPP-7384](https://dhis2.atlassian.net/browse/ANDROAPP-7384) Update transifex tracker configuration - -[ANDROAPP-7386](https://dhis2.atlassian.net/browse/ANDROAPP-7386) QA: Remove duplicated UI modules - -[ANDROAPP-7388](https://dhis2.atlassian.net/browse/ANDROAPP-7388) Create sync module - -[ANDROAPP-7395](https://dhis2.atlassian.net/browse/ANDROAPP-7395) Review settings repository data loading for log out request - -[ANDROAPP-7396](https://dhis2.atlassian.net/browse/ANDROAPP-7396) Remove and update usage to design systems' deprecated methods - -[ANDROAPP-7424](https://dhis2.atlassian.net/browse/ANDROAPP-7424) Gradle warnings: Remove RX binding dependency and zxing dependency - -[ANDROAPP-7426](https://dhis2.atlassian.net/browse/ANDROAPP-7426) Upload proguard mapping on Sentry - -[ANDROAPP-7440](https://dhis2.atlassian.net/browse/ANDROAPP-7440) Update Expression parser to 1.2.2 - -[ANDROAPP-7441](https://dhis2.atlassian.net/browse/ANDROAPP-7441) Remove username from Sentry reports \ No newline at end of file +* [ANDROAPP-7634](https://dhis2.atlassian.net/browse/ANDROAPP-7634) SMS module is accidentally enabled when ASWA is installed diff --git a/aggregates/build.gradle.kts b/aggregates/build.gradle.kts index 9eedb8161a5..6243cf76e34 100644 --- a/aggregates/build.gradle.kts +++ b/aggregates/build.gradle.kts @@ -1,16 +1,22 @@ import kotlin.text.set plugins { - kotlin("multiplatform") + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) - id("com.android.library") + alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.kotlin.compose.compiler) } kotlin { - androidTarget { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + androidLibrary { + namespace = "org.dhis2.mobile.aggregates" + compileSdk = libs.versions.sdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } + androidResources { enable = true } + withHostTestBuilder {}.configure {} + withDeviceTestBuilder {}.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } @@ -26,17 +32,17 @@ kotlin { } commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.ui) - implementation(compose.material3) - api(compose.materialIconsExtended) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + api(libs.compose.material3.iconsExtendend) val designSystem = libs.dhis2.mobile.designsystem implementation("${designSystem.get().group}:${designSystem.get().name}:${designSystem.get().version}"){ isChanging= true } implementation(libs.compose.material3.window) - implementation(compose.components.resources) + implementation(libs.compose.components.resources) implementation(project(":commonskmm")) // Koin @@ -54,7 +60,7 @@ kotlin { implementation(libs.test.turbine) implementation(libs.test.kotlinCoroutines) implementation(libs.test.mockitoKotlin) - implementation(compose.components.resources) + implementation(libs.compose.components.resources) } androidMain.dependencies { @@ -65,13 +71,16 @@ kotlin { implementation(libs.koin.androidx.compose) implementation(project(":commons")) implementation(project(":dhis2_android_maps")) + compileOnly(libs.androidx.compose.uitooling) } - androidUnitTest.dependencies { } + getByName("androidHostTest") { + dependencies { implementation(libs.junit.jupiter) } + } val desktopMain by getting { dependencies { - implementation(compose.desktop.common) + implementation(libs.compose.desktop.common) } } } @@ -83,30 +92,6 @@ compose.resources { generateResClass = always } -android { - namespace = "org.dhis2.mobile.aggregates" - compileSdk = libs.versions.sdk.get().toInt() - - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - sourceSets["main"].res.srcDirs("src/androidMain/res") - - defaultConfig { - minSdk = libs.versions.minSdk.get().toInt() - } - compileOptions { - isCoreLibraryDesugaringEnabled = true - - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - dependencies { - coreLibraryDesugaring(libs.desugar) - } -} - dependencies { - testImplementation(libs.junit.jupiter) - debugImplementation(libs.androidx.compose.preview) - debugImplementation(libs.androidx.compose.uitooling) -} \ No newline at end of file + coreLibraryDesugaring(libs.desugar) +} diff --git a/aggregates/src/androidUnitTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt b/aggregates/src/androidHostTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt similarity index 100% rename from aggregates/src/androidUnitTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt rename to aggregates/src/androidHostTest/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImplTest.kt diff --git a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt index 66e74b898e7..eb0086a8113 100644 --- a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt +++ b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt @@ -179,6 +179,9 @@ internal class DataSetInstanceRepositoryImpl( DataSetNonEditableReason.EXPIRED -> NonEditableReason.Expired + + DataSetNonEditableReason.PERIOD_NOT_IN_DATA_INPUT_PERIODS -> + NonEditableReason.PeriodNotInDataInputPeriods } } ?: NonEditableReason.None, ) @@ -336,7 +339,10 @@ internal class DataSetInstanceRepositoryImpl( .blockingGet() ?.let { DataSetRenderingConfig( - useVerticalTabs = it.displayOptions()?.tabsDirection() == TabsDirection.VERTICAL, + useVerticalTabs = + it + .displayOptions() + ?.tabsDirection() == TabsDirection.VERTICAL, ) } ?: DataSetRenderingConfig( useVerticalTabs = true, @@ -415,11 +421,12 @@ internal class DataSetInstanceRepositoryImpl( .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - dataElementUid, - categoryOptionComboUid, - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = dataElementUid, + categoryOptionCombo = categoryOptionComboUid, + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingGet() ?.syncState() @@ -817,6 +824,7 @@ internal class DataSetInstanceRepositoryImpl( orgUnitUid: String, categoryOptionComboUid: String, attrOptionComboUid: String, + sourceDataSetUid: String, ): Pair? { val dataElement = d2 @@ -847,6 +855,7 @@ internal class DataSetInstanceRepositoryImpl( dataElement = dataElementUid, categoryOptionCombo = categoryOptionComboUid, attributeOptionCombo = attrOptionComboUid, + sourceDataSet = sourceDataSetUid, ).blockingGet() ?.value() ?.toDoubleOrNull() @@ -982,6 +991,7 @@ internal class DataSetInstanceRepositoryImpl( attrOptionComboUid: String, dataElementUid: String, categoryOptionComboUid: String, + sourceDataSetUid: String, ) = d2 .dataValueModule() .dataValues() @@ -993,6 +1003,7 @@ internal class DataSetInstanceRepositoryImpl( dataElementUid, categoryOptionComboUid, attrOptionComboUid, + sourceDataSetUid, ).blockingGet() ?.value() @@ -1003,6 +1014,7 @@ internal class DataSetInstanceRepositoryImpl( dataElementUid: String, categoryOptionComboUid: String, value: String?, + sourceDataSetUid: String, ): Result { val valueRepository = d2 @@ -1014,6 +1026,7 @@ internal class DataSetInstanceRepositoryImpl( dataElement = dataElementUid, categoryOptionCombo = categoryOptionComboUid, attributeOptionCombo = attrOptionComboUid, + sourceDataSet = sourceDataSetUid, ) val dataElement = @@ -1257,6 +1270,7 @@ internal class DataSetInstanceRepositoryImpl( ValidationResultStatus.valueOf(result.status().name), mapViolations( violations = result.violations(), + dataSetUid = dataSetUid, periodId = periodId, orgUnitUid = orgUnitUid, attrOptionComboUid = attrOptionComboUid, @@ -1291,6 +1305,7 @@ internal class DataSetInstanceRepositoryImpl( private fun mapViolations( violations: List, + dataSetUid: String, periodId: String, orgUnitUid: String, attrOptionComboUid: String, @@ -1301,6 +1316,7 @@ internal class DataSetInstanceRepositoryImpl( it.validationRule().instruction(), mapDataElements( dataElementUids = it.dataElementUids(), + dataSetUid = dataSetUid, periodId = periodId, orgUnitUid = orgUnitUid, attrOptionComboUid = attrOptionComboUid, @@ -1310,6 +1326,7 @@ internal class DataSetInstanceRepositoryImpl( private fun mapDataElements( dataElementUids: Set, + dataSetUid: String, periodId: String, orgUnitUid: String, attrOptionComboUid: String, @@ -1348,21 +1365,23 @@ internal class DataSetInstanceRepositoryImpl( .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - de.uid(), - catOptCombo.uid(), - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = de.uid(), + categoryOptionCombo = catOptCombo.uid(), + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingExists() && d2 .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - de.uid(), - catOptCombo.uid(), - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = de.uid(), + categoryOptionCombo = catOptCombo.uid(), + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingGet() ?.deleted() != true ) { @@ -1370,11 +1389,12 @@ internal class DataSetInstanceRepositoryImpl( .dataValueModule() .dataValues() .value( - periodId, - orgUnitUid, - de.uid(), - catOptCombo.uid(), - attrOptionComboUid, + period = periodId, + organisationUnit = orgUnitUid, + dataElement = de.uid(), + categoryOptionCombo = catOptCombo.uid(), + attributeOptionCombo = attrOptionComboUid, + sourceDataSet = dataSetUid, ).blockingGet() ?.value() ?: "-" } else { diff --git a/aggregates/src/commonMain/composeResources/values-ar/strings.xml b/aggregates/src/commonMain/composeResources/values-ar/strings.xml index a0ae43f4fd9..c7fe2d34814 100644 --- a/aggregates/src/commonMain/composeResources/values-ar/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-ar/strings.xml @@ -16,8 +16,6 @@ خارج النطاق تنسيق غير صحيح غير مدعوم - كل شيء يبدو جيدا - هل تريد أيضاً إكمال مجموعة البيانات؟ كل شيء يبدو جيدا هل تريد أيضاً تعيين حزمة البيانات كمكتملة؟ ليس الان diff --git a/aggregates/src/commonMain/composeResources/values-cs/strings.xml b/aggregates/src/commonMain/composeResources/values-cs/strings.xml index 7f61c5f1b0e..3d7e271b474 100644 --- a/aggregates/src/commonMain/composeResources/values-cs/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-cs/strings.xml @@ -16,8 +16,6 @@ Mimo rozsah Nesprávný formát Není podporováno - Vše vypadá dobře - Chcete také doplnit datovou sadu? Vše vypadá dobře Chcete také vyplnit soubor dat? Teď ne diff --git a/aggregates/src/commonMain/composeResources/values-es/strings.xml b/aggregates/src/commonMain/composeResources/values-es/strings.xml index ad88a405f9c..3f4f8932e27 100644 --- a/aggregates/src/commonMain/composeResources/values-es/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-es/strings.xml @@ -16,8 +16,6 @@ Fuera de rango Formato incorrecto No soportado - Todo se ve bien - ¿También deseas completar el conjunto de datos? Todo se ve bien ¿Quiere completar también el set de datos? Ahora no @@ -28,7 +26,7 @@ Aceptar ¡Guardado y completado! Ha ocurrido un error al completar el conjunto de datos - ¿Quiere validar la calidad de los datos? + ¿Quieree validar la calidad de los datos? No Error diff --git a/aggregates/src/commonMain/composeResources/values-hi-rIN/strings.xml b/aggregates/src/commonMain/composeResources/values-hi-rIN/strings.xml index 8abd1a6dddf..4ac6537b450 100644 --- a/aggregates/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -16,8 +16,6 @@ सीमा से बाहर गलत फ़ॉर्मैट सपोर्टेड नहीं है - सब ठीक है - क्या आप डेटा सेट पूरा करना चाहते हैं? सब ठीक है क्या आप डेटा सेट पूरा करना चाहते हैं? अभी नहीं @@ -72,6 +70,7 @@ %2$s is the attribute option combo name --> आप %2$s में पीरियड %1$s के लिए डेटा संपादित नहीं कर सकते + यह डेटा संपादन योग्य नहीं है क्योंकि चुनी गई अवधि डेटा इनपुट अवधि के बाहर है यह डेटा संपादन योग्य नहीं है क्योंकि इसे क्लोज़ मार्क किया गया है यह डेटा संपादन योग्य नहीं है क्योंकि संपादन का समय समाप्त हो गया है diff --git a/aggregates/src/commonMain/composeResources/values-nb/strings.xml b/aggregates/src/commonMain/composeResources/values-nb/strings.xml index 8b5aad1dfc8..e39b890960e 100644 --- a/aggregates/src/commonMain/composeResources/values-nb/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-nb/strings.xml @@ -12,7 +12,6 @@ Breddegrad Lengdegrad Ikke støttet - Alt ser bra ut Alt ser bra ut Vil du også fullføre datasettet? Fullfør diff --git a/aggregates/src/commonMain/composeResources/values-ru/strings.xml b/aggregates/src/commonMain/composeResources/values-ru/strings.xml index 01f9d546603..723005f01f7 100644 --- a/aggregates/src/commonMain/composeResources/values-ru/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-ru/strings.xml @@ -16,8 +16,6 @@ Вне спектра Неверный формат Не поддерживается - Все выглядит хорошо - Вы также хотите завершить набор данных? Все выглядит хорошо Вы также хотите завершить набор данных? Не сейчас diff --git a/aggregates/src/commonMain/composeResources/values-vi/strings.xml b/aggregates/src/commonMain/composeResources/values-vi/strings.xml index 159448a4796..6277c585fc2 100644 --- a/aggregates/src/commonMain/composeResources/values-vi/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-vi/strings.xml @@ -16,8 +16,6 @@ Ngoài phạm vi Định dạng không chính xác Không được hỗ trợ - Mọi thứ đều ổn - Bạn cũng muốn hoàn tất biểu nhập phải không? Mọi thứ đều ổn Bạn cũng muốn hoàn tất biểu nhập phải không? Không phải bây giờ diff --git a/aggregates/src/commonMain/composeResources/values-zh/strings.xml b/aggregates/src/commonMain/composeResources/values-zh/strings.xml index 9cf949ce08b..a7f5020fabd 100644 --- a/aggregates/src/commonMain/composeResources/values-zh/strings.xml +++ b/aggregates/src/commonMain/composeResources/values-zh/strings.xml @@ -16,8 +16,6 @@ 超出范围 格式不正确 不支持 - 所有都很好 - 您还想完成数据集吗? 所有都很好 你要完成数据集? 现在不要 @@ -72,6 +70,7 @@ %2$s is the attribute option combo name --> 您不能编辑 %2$s中的时段%1$s的数据。 + 该数据 不可编辑,因为所选期间不在数据 输入周期内 此数据不可编辑,因为已标记为关闭状态 该数据不可编辑,因为其编辑时间已过 diff --git a/aggregates/src/commonMain/composeResources/values/strings.xml b/aggregates/src/commonMain/composeResources/values/strings.xml index bc1831ca71d..4f631732d2c 100644 --- a/aggregates/src/commonMain/composeResources/values/strings.xml +++ b/aggregates/src/commonMain/composeResources/values/strings.xml @@ -16,8 +16,6 @@ Out of range Incorrect format Not supported - Everything looks good - Do you also want to complete the dataset? Everything looks good Do you also want to complete the data set? Not now @@ -72,6 +70,7 @@ %2$s is the attribute option combo name --> You cannot edit data for the period %1$s in %2$s + This data is not editable because the selected period is outside the data input period This data is not editable because it is marked as closed This data is not editable because its edition time has expired diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt index b05c6f6d4db..9ef94b10437 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt @@ -87,6 +87,7 @@ internal interface DataSetInstanceRepository { attrOptionComboUid: String, dataElementUid: String, categoryOptionComboUid: String, + sourceDataSetUid: String, ): String? suspend fun updateValue( @@ -96,6 +97,7 @@ internal interface DataSetInstanceRepository { dataElementUid: String, categoryOptionComboUid: String, value: String?, + sourceDataSetUid: String, ): Result suspend fun categoryOptionComboFromCategoryOptions( @@ -165,6 +167,7 @@ internal interface DataSetInstanceRepository { orgUnitUid: String, categoryOptionComboUid: String, attrOptionComboUid: String, + sourceDataSetUid: String, ): Pair? suspend fun uploadFile( diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt index 806d71cba6c..db2a48536d8 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueData.kt @@ -36,6 +36,7 @@ internal class GetDataValueData( orgUnitUid = orgUnitUid, attrOptionComboUid = attrOptionComboUid, categoryOptionComboUid = key.second, + sourceDataSetUid = datasetUid, )?.first, ) } diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt index 10e579b42b6..0ae33c4e922 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt @@ -45,6 +45,7 @@ internal class GetDataValueInput( attrOptionComboUid = attrOptionComboUid, dataElementUid = dataElementUid, categoryOptionComboUid = categoryOptionComboUid, + sourceDataSetUid = dataSetUid, ) val conflicts = repository.conflicts( @@ -63,6 +64,7 @@ internal class GetDataValueInput( orgUnitUid = orgUnitUid, categoryOptionComboUid = categoryOptionComboUid, attrOptionComboUid = attrOptionComboUid, + sourceDataSetUid = dataSetUid, ) CellInfo( diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt index 5ae00192639..d92058d2c90 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/SetDataValue.kt @@ -25,6 +25,7 @@ internal class SetDataValue( dataElementUid = dataElementUid, categoryOptionComboUid = categoryOptionComboUid, value = value, + sourceDataSetUid = dataSetUid, ) } } diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt index 6845e8b4baa..cdd01bb9875 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetDetails.kt @@ -53,6 +53,8 @@ sealed class NonEditableReason { data object Closed : NonEditableReason() data object Expired : NonEditableReason() + + data object PeriodNotInDataInputPeriods : NonEditableReason() } enum class TextAlignment { diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt index 6be8ccf6780..95e836b5cd5 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt @@ -70,6 +70,7 @@ import org.dhis2.mobile.aggregates.resources.empty_section_message import org.dhis2.mobile.aggregates.resources.no_data_write_access import org.dhis2.mobile.aggregates.resources.org_unit_not_in_capture_scope import org.dhis2.mobile.aggregates.resources.period_not_in_attribute_option_combo_range +import org.dhis2.mobile.aggregates.resources.period_not_in_data_input_periods import org.dhis2.mobile.aggregates.resources.period_not_in_org_unit_range import org.dhis2.mobile.aggregates.ui.component.HtmlContentBox import org.dhis2.mobile.aggregates.ui.component.ValidationBar @@ -575,6 +576,9 @@ private fun nonEditableReasonLabel(edition: DataSetEdition) = edition.nonEditableReason.periodLabel, edition.nonEditableReason.orgUnitLabel, ) + + is NonEditableReason.PeriodNotInDataInputPeriods -> + stringResource(Res.string.period_not_in_data_input_periods) } /** diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt index 18190b8143c..8c2fa2a7234 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt @@ -223,7 +223,7 @@ internal fun InputProvider( onAction(UiAction.OnValueChanged(inputData.id, value)) } }, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, modifier = modifierWithFocus, ) } @@ -329,7 +329,7 @@ internal fun InputProvider( dateTextValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, dateTextValue.text)) }, - onNextClicked = { onAction(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction(UiAction.OnNextClick(inputData.id)) }, modifier = modifierWithFocus, ) } @@ -342,7 +342,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -457,7 +457,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -477,7 +477,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -497,7 +497,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -517,7 +517,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -537,7 +537,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { if (textValue.text != it?.text) { textValue = it ?: TextFieldValue() @@ -559,7 +559,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -783,7 +783,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -824,7 +824,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -847,7 +847,7 @@ internal fun InputProvider( inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, onCallActionClicked = { onAction(UiAction.OnCall(inputData.id, textValue.text)) }, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -867,7 +867,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -909,7 +909,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) @@ -928,7 +928,7 @@ internal fun InputProvider( legendData = inputData.legendData, inputTextFieldValue = textValue, isRequiredField = inputData.isRequired, - onNextClicked = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, + onImeActionClick = { onAction.invoke(UiAction.OnNextClick(inputData.id)) }, onValueChanged = { textValue = it ?: TextFieldValue() onAction(UiAction.OnValueChanged(inputData.id, textValue.text)) diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/DataSetSnackbarHost.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/DataSetSnackbarHost.kt index f79c5fbffc1..96070444493 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/DataSetSnackbarHost.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/DataSetSnackbarHost.kt @@ -6,8 +6,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor -import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import org.hisp.dhis.mobile.ui.designsystem.theme.dropShadow @Composable @@ -16,8 +14,6 @@ fun DataSetSnackbarHost(hostState: SnackbarHostState) { Snackbar( modifier = Modifier.dropShadow(shape = SnackbarDefaults.shape), snackbarData = data, - containerColor = SurfaceColor.SurfaceBright, - contentColor = TextColor.OnSurface, ) } } diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt index 0cdcbd9608c..89593684995 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputDataUiState.kt @@ -28,7 +28,7 @@ internal sealed class CellSelectionState( val inputType: InputType, private val inputExtra: InputExtra, val inputShellState: InputShellState, - val inputStyle: InputStyle = InputStyle.DataInputStyle(), + val inputStyle: InputStyle = InputStyle.DarkInputStyle(), val supportingText: List?, val legendData: LegendData?, val isRequired: Boolean, diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt index 9906c538bb9..3c3511eba23 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt @@ -58,6 +58,7 @@ import org.dhis2.mobile.aggregates.ui.states.OverwrittenDimension import org.dhis2.mobile.aggregates.ui.states.ValidationBarUiState import org.dhis2.mobile.aggregates.ui.states.mapper.InputDataUiStateMapper import org.dhis2.mobile.commons.coroutine.CoroutineTracker +import org.dhis2.mobile.commons.extensions.launchUseCase import org.dhis2.mobile.commons.input.CallbackStatus import org.dhis2.mobile.commons.input.InputType import org.dhis2.mobile.commons.input.UiAction @@ -104,7 +105,7 @@ internal class DataSetTableViewModel( ) fun loadDataSet() { - viewModelScope.launch(dispatcher.io()) { + launchUseCase(dispatcher.io()) { val dataSetInstanceData = getDataSetInstanceData(this) val initialSection = dataSetInstanceData.initialSectionToLoad @@ -120,65 +121,51 @@ internal class DataSetTableViewModel( } else { dataSetInstanceData.dataSetSections.firstOrNull()?.uid ?: NO_SECTION_UID } - _dataSetScreenState.value = - DataSetScreenState.Loaded( - dataSetDetails = - dataSetInstanceData.dataSetDetails.copy( - customTitle = - dataSetInstanceData.dataSetDetails.customTitle.copy( - header = dataSetSectionTitle, - ), - ), - dataSetSections = dataSetInstanceData.dataSetSections, - renderingConfig = dataSetInstanceData.dataSetRenderingConfig, - dataSetSectionTable = - DataSetSectionTable( - sectionToLoad, - emptyList(), - overridingDimensions = overwrittenWidths(sectionToLoad), - loading = true, - ), - initialSection = dataSetInstanceData.initialSectionToLoad, - selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), - ) + val initialDimensions = overwrittenWidths(sectionToLoad) + withContext(dispatcher.main()) { + _dataSetScreenState.value = + DataSetScreenState.Loaded( + dataSetDetails = + dataSetInstanceData.dataSetDetails.copy( + customTitle = + dataSetInstanceData.dataSetDetails.customTitle.copy( + header = dataSetSectionTitle, + ), + ), + dataSetSections = dataSetInstanceData.dataSetSections, + renderingConfig = dataSetInstanceData.dataSetRenderingConfig, + dataSetSectionTable = + DataSetSectionTable( + sectionToLoad, + emptyList(), + overridingDimensions = initialDimensions, + loading = true, + ), + initialSection = dataSetInstanceData.initialSectionToLoad, + selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), + ) + } - val sectionTable = async { sectionData(sectionToLoad) } + val tableModels = sectionData(sectionToLoad) - _dataSetScreenState.update { - when (it) { - is DataSetScreenState.Loaded -> - it.copy( - dataSetDetails = - dataSetInstanceData.dataSetDetails.copy( - customTitle = - dataSetInstanceData.dataSetDetails.customTitle.copy( - header = dataSetSectionTitle, - ), - ), - dataSetSectionTable = - DataSetSectionTable( - sectionToLoad, - sectionTable.await(), - overridingDimensions = overwrittenWidths(sectionToLoad), - loading = false, - ), - ) - - DataSetScreenState.Loading -> - DataSetScreenState.Loaded( - dataSetDetails = dataSetInstanceData.dataSetDetails, - dataSetSections = dataSetInstanceData.dataSetSections, - renderingConfig = dataSetInstanceData.dataSetRenderingConfig, - dataSetSectionTable = - DataSetSectionTable( - sectionToLoad, - sectionTable.await(), - overridingDimensions = overwrittenWidths(sectionToLoad), - loading = true, - ), - initialSection = initialSection, - selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), - ) + withContext(dispatcher.main()) { + _dataSetScreenState.update { + (it as? DataSetScreenState.Loaded)?.copy( + dataSetDetails = + dataSetInstanceData.dataSetDetails.copy( + customTitle = + dataSetInstanceData.dataSetDetails.customTitle.copy( + header = dataSetSectionTitle, + ), + ), + dataSetSectionTable = + DataSetSectionTable( + sectionToLoad, + tableModels, + overridingDimensions = it.dataSetSectionTable.overridingDimensions, + loading = false, + ), + ) ?: it } } } @@ -188,65 +175,69 @@ internal class DataSetTableViewModel( if (_dataSetScreenState.value.currentSection() == sectionUid) return sectionChangeJob?.takeIf { it.isActive }?.cancel() sectionChangeJob = - viewModelScope.launch(dispatcher.io()) { + launchUseCase(dispatcher.io()) { val selectedSectionIndex = (dataSetScreenState.value as? DataSetScreenState.Loaded) ?.dataSetSections ?.indexOfFirst { it.uid == sectionUid } - CoroutineTracker.increment() - _dataSetScreenState.update { - if (it is DataSetScreenState.Loaded) { - val dataSetSectionTitle = - if (it.dataSetDetails.customTitle.isConfiguredTitle) { - it.dataSetDetails.customTitle.header - } else { - it.dataSetSections.firstOrNull { section -> section.uid == sectionUid }?.title - } - it.copy( - dataSetDetails = - it.dataSetDetails.copy( - customTitle = - DataSetCustomTitle( - header = dataSetSectionTitle, - subHeader = it.dataSetDetails.customTitle.subHeader, - textAlignment = it.dataSetDetails.customTitle.textAlignment, - isConfiguredTitle = it.dataSetDetails.customTitle.isConfiguredTitle, - ), - ), - dataSetSectionTable = - it.dataSetSectionTable.copy( - id = sectionUid, - tableModels = emptyList(), - overridingDimensions = overwrittenWidths(sectionUid), - loading = true, - ), - selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), - initialSection = selectedSectionIndex ?: 0, - ) - } else { - it + val initialDimensions = overwrittenWidths(sectionUid) + withContext(dispatcher.main()) { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + val dataSetSectionTitle = + if (it.dataSetDetails.customTitle.isConfiguredTitle) { + it.dataSetDetails.customTitle.header + } else { + it.dataSetSections.firstOrNull { section -> section.uid == sectionUid }?.title + } + it.copy( + dataSetDetails = + it.dataSetDetails.copy( + customTitle = + DataSetCustomTitle( + header = dataSetSectionTitle, + subHeader = it.dataSetDetails.customTitle.subHeader, + textAlignment = it.dataSetDetails.customTitle.textAlignment, + isConfiguredTitle = it.dataSetDetails.customTitle.isConfiguredTitle, + ), + ), + dataSetSectionTable = + it.dataSetSectionTable.copy( + id = sectionUid, + tableModels = emptyList(), + overridingDimensions = initialDimensions, + loading = true, + ), + selectedCellInfo = CellSelectionState.Default(TableSelection.Unselected()), + initialSection = selectedSectionIndex ?: 0, + ) + } else { + it + } } } - val sectionData = async { sectionData(sectionUid) } - _dataSetScreenState.update { - if (it is DataSetScreenState.Loaded) { - it.copy( - dataSetSectionTable = - it.dataSetSectionTable.copy( - id = sectionUid, - tableModels = sectionData.await(), - overridingDimensions = overwrittenWidths(sectionUid), - loading = false, - ), - initialSection = selectedSectionIndex ?: 0, - ) - } else { - it + val tableModels = sectionData(sectionUid) + withContext(dispatcher.main()) { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + dataSetSectionTable = + it.dataSetSectionTable.copy( + id = sectionUid, + tableModels = tableModels, + // Preserve any resize the user applied while loading + overridingDimensions = it.dataSetSectionTable.overridingDimensions, + loading = false, + ), + initialSection = selectedSectionIndex ?: 0, + ) + } else { + it + } } } - CoroutineTracker.decrement() } } @@ -379,7 +370,14 @@ internal class DataSetTableViewModel( } else { it.dataSetSectionTable }, - selectedCellInfo = if (showInputDialog) inputData else CellSelectionState.Default(TableSelection.Unselected()), + selectedCellInfo = + if (showInputDialog) { + inputData + } else { + CellSelectionState.Default( + TableSelection.Unselected(), + ) + }, ) ?: it } CoroutineTracker.decrement() @@ -432,11 +430,16 @@ internal class DataSetTableViewModel( selectedCellInfo is CellSelectionState.InputDataUiState && selectedCellInfo.inputType is InputType.MultiText -> !selectedCellInfo.multiTextExtras().optionsFetched + else -> false } - updateSelectedCell(uiAction.id, fetchOptions, showInputDialog = uiAction.showInputDialog) + updateSelectedCell( + uiAction.id, + fetchOptions, + showInputDialog = uiAction.showInputDialog, + ) }, onFailure = { updateSelectedCell( @@ -572,22 +575,20 @@ internal class DataSetTableViewModel( } private fun updateFileLoadingState(state: UploadFileState) { - viewModelScope.launch(dispatcher.io()) { - _dataSetScreenState.update { - (it as? DataSetScreenState.Loaded)?.copy( - selectedCellInfo = - if (it.selectedCellInfo is CellSelectionState.InputDataUiState) { - it.selectedCellInfo.copy( - inputExtra = - it.selectedCellInfo.fileExtras().copy( - fileState = state, - ), - ) - } else { - it.selectedCellInfo - }, - ) ?: it - } + _dataSetScreenState.update { + (it as? DataSetScreenState.Loaded)?.copy( + selectedCellInfo = + if (it.selectedCellInfo is CellSelectionState.InputDataUiState) { + it.selectedCellInfo.copy( + inputExtra = + it.selectedCellInfo.fileExtras().copy( + fileState = state, + ), + ) + } else { + it.selectedCellInfo + }, + ) ?: it } } @@ -613,13 +614,15 @@ internal class DataSetTableViewModel( ?.dataSetSectionTable ?.id ?: return@launch val updatedDimensions = overwrittenWidths(currentSectionId) - _dataSetScreenState.update { state -> - (state as? DataSetScreenState.Loaded)?.copy( - dataSetSectionTable = - state.dataSetSectionTable.copy( - overridingDimensions = updatedDimensions, - ), - ) ?: state + withContext(dispatcher.main()) { + _dataSetScreenState.update { state -> + (state as? DataSetScreenState.Loaded)?.copy( + dataSetSectionTable = + state.dataSetSectionTable.copy( + overridingDimensions = updatedDimensions, + ), + ) ?: state + } } } } @@ -737,14 +740,11 @@ internal class DataSetTableViewModel( it } } - CoroutineTracker.decrement() } } private fun checkValidationRules() { - viewModelScope.launch { - CoroutineTracker.increment() - + launchUseCase { val rules = withContext(dispatcher.io()) { runValidationRules() @@ -793,7 +793,6 @@ internal class DataSetTableViewModel( } } } - CoroutineTracker.decrement() } } @@ -823,8 +822,7 @@ internal class DataSetTableViewModel( } private fun attemptToFinish() { - viewModelScope.launch { - CoroutineTracker.increment() + launchUseCase { val onSavedMessage = resourceManager.provideSaved() val result = @@ -851,13 +849,11 @@ internal class DataSetTableViewModel( } } } - CoroutineTracker.decrement() } } private fun attemptToComplete() { - viewModelScope.launch { - CoroutineTracker.increment() + launchUseCase { val result = withContext(dispatcher.io()) { completeDataSet() @@ -907,7 +903,6 @@ internal class DataSetTableViewModel( showSnackbar(resourceManager.provideErrorOnCompleteDataset()) } } - CoroutineTracker.decrement() } } diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt index b6c8a3d380f..574902e8bdc 100644 --- a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt +++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt @@ -46,7 +46,7 @@ internal class GetDataValueInputTest : KoinTest { private val repository = mock { onRunBlocking { dataElementInfo(any(), any(), any()) } doReturn mockedDataElementInfo - onRunBlocking { value(any(), any(), any(), any(), any()) } doReturn "Current value" + onRunBlocking { value(any(), any(), any(), any(), any(), any()) } doReturn "Current value" onRunBlocking { conflicts(any(), any(), any(), any(), any(), any()) } doReturn Pair( emptyList(), diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt index 781babd905d..d1a838d8d67 100644 --- a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt +++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt @@ -182,8 +182,13 @@ internal class DataSetTableViewModelTest : KoinTest { dataSetSections = listOf( DataSetSection( - uid = "sectionUid", - title = "sectionTitle", + uid = "section_uid1", + title = "sectionTitle1", + misconfiguredRows = emptyList(), + ), + DataSetSection( + uid = "section_uid2", + title = "sectionTitle2", misconfiguredRows = emptyList(), ), ), @@ -437,8 +442,8 @@ internal class DataSetTableViewModelTest : KoinTest { if (this is DataSetScreenState.Loaded) { assertTrue(this.selectedCellInfo is InputDataUiState) require(this.selectedCellInfo is InputDataUiState) - assertEquals("Legend label 2", this.selectedCellInfo?.legendData?.title) - assertEquals("#CD5C5C".toColor(), this.selectedCellInfo?.legendData?.color) + assertEquals("Legend label 2", this.selectedCellInfo.legendData?.title) + assertEquals("#CD5C5C".toColor(), this.selectedCellInfo.legendData?.color) } else { assertTrue(false) } @@ -965,16 +970,15 @@ internal class DataSetTableViewModelTest : KoinTest { runTest { viewModel.dataSetScreenState.test { awaitInitialization() - viewModel.onSectionSelected("section_uid1") + viewModel.onSectionSelected("section_uid2") with(awaitItem()) { assertTrue(this is DataSetScreenState.Loaded) assertTrue((this as DataSetScreenState.Loaded).dataSetSectionTable.loading) - assertTrue(this.currentSection() == "section_uid1") + assertTrue(this.currentSection() == "section_uid2") } - viewModel.onSectionSelected("section_uid2") with(awaitItem()) { assertTrue(this is DataSetScreenState.Loaded) - assertTrue((this as DataSetScreenState.Loaded).dataSetSectionTable.loading) + assertTrue(!(this as DataSetScreenState.Loaded).dataSetSectionTable.loading) assertTrue(this.currentSection() == "section_uid2") } viewModel.onSectionSelected("section_uid1") diff --git a/app/build.gradle.kts b/app/build.gradle.kts index baeb1c83b85..625c81e1f14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,14 +1,19 @@ -@file:Suppress("UnstableApiUsage") - import com.android.build.api.variant.impl.VariantOutputImpl import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.text.SimpleDateFormat import java.util.Date +import java.util.Properties + +val localProps = + Properties().also { props -> + rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use(props::load) + } + +fun envOrLocal(key: String, default: String = "") = System.getenv(key) ?: localProps.getProperty(key) ?: default plugins { id("com.android.application") - kotlin("android") - kotlin("kapt") + alias(libs.plugins.legacy.kapt) id("com.google.devtools.ksp") id("kotlin-parcelize") alias(libs.plugins.kotlin.serialization) @@ -17,27 +22,52 @@ plugins { } apply(from = "${project.rootDir}/jacoco/jacoco.gradle.kts") -android { +val getBuildDate by extra { + fun(): String { + return SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date()) + } +} - val getBuildDate by extra { - fun(): String { - return SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date()) +val getCommitHash by extra { + fun(): String { + return try { + val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD") + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + process.inputStream.bufferedReader().readText().trim() + } catch (e: Exception) { + "unknown" } } +} - val getCommitHash by extra { - fun(): String { - return try { - val process = ProcessBuilder("git", "rev-parse", "--short", "HEAD") - .redirectOutput(ProcessBuilder.Redirect.PIPE) - .redirectError(ProcessBuilder.Redirect.PIPE) - .start() - process.inputStream.bufferedReader().readText().trim() - } catch (e: Exception) { - "unknown" +val getBranchName by extra { + fun(): String { + val envBranchName = System.getenv("GITHUB_HEAD_REF") + ?: System.getenv("GITHUB_REF_NAME") + + return try { + if (!envBranchName.isNullOrBlank()) { + return envBranchName.replace(Regex("[/\\\\:*?\"<>|]"), "-") } + val process = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD") + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + val branchName = process.inputStream.bufferedReader().readText().trim() + branchName.replace(Regex("[/\\\\:*?\"<>|]"), "-") + } catch (e: Exception) { + "unknown" } } +} + +base { + archivesName.set("dhis2-v" + libs.versions.vName.get()) +} + +android { signingConfigs { create("release") { @@ -56,6 +86,15 @@ android { } storePassword = System.getenv("TRAINING_STORE_PASSWORD") } + val customKeystorePath = System.getenv("DEBUG_KEYSTORE_PATH") + if (customKeystorePath != null ) { + getByName("debug") { + keyAlias = System.getenv("DEBUG_KEYSTORE_ALIAS") + keyPassword = System.getenv("DEBUG_KEY_PASS") + storeFile = file(customKeystorePath) + storePassword = System.getenv("DEBUG_KEYSTORE_PASSWORD") + } + } } testOptions { @@ -74,23 +113,18 @@ android { } } + compileSdk = libs.versions.sdk.get().toInt() namespace = "org.dhis2" testNamespace = "org.dhis2.test" - base { - archivesName.set("dhis2-v" + libs.versions.vName.get()) - } - defaultConfig { applicationId = "com.simprints.simcapture" - compileSdk = libs.versions.sdk.get().toInt() targetSdk = libs.versions.sdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt() versionCode = libs.versions.vCode.get().toInt() versionName = libs.versions.vName.get() testInstrumentationRunner = "org.dhis2.Dhis2Runner" vectorDrawables.useSupportLibrary = true - multiDexEnabled = true val bitriseSentryDSN = System.getenv("SENTRY_DSN") ?: "" @@ -99,6 +133,37 @@ android { buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") buildConfigField("String", "SENTRY_DSN", "\"${bitriseSentryDSN}\"") + + // Open id configuration + val openIdAuthScheme = envOrLocal("OPEN_ID_AUTH_SCHEME", "open.id.app.fallback") + manifestPlaceholders["openIdAuthScheme"] = openIdAuthScheme + + val openIdType = envOrLocal("OPEN_ID_TYPE") + buildConfigField("String", "OPEN_ID_TYPE", "\"$openIdType\"") + + val openIdServer = envOrLocal("OPEN_ID_SERVER") + buildConfigField("String", "OPEN_ID_SERVER", "\"$openIdServer\"") + + val openIdClient = envOrLocal("OPEN_ID_CLIENT") + buildConfigField("String", "OPEN_ID_CLIENT", "\"$openIdClient\"") + + val openIdRedirectUri = envOrLocal("OPEN_ID_REDIRECT_URI") + buildConfigField("String", "OPEN_ID_REDIRECT_URI", "\"$openIdRedirectUri\"") + + val openIdDiscoveryUri = envOrLocal("OPEN_ID_DISCOVERY_URI") + buildConfigField("String", "OPEN_ID_DISCOVERY_URI", "\"$openIdDiscoveryUri\"") + + val openIdAuthorizationUrl = envOrLocal("OPEN_ID_AUTHORIZATION_URL") + buildConfigField("String", "OPEN_ID_AUTHORIZATION_URL", "\"$openIdAuthorizationUrl\"") + + val openIdTokenUrl = envOrLocal("OPEN_ID_TOKEN_URL") + buildConfigField("String", "OPEN_ID_TOKEN_URL", "\"$openIdTokenUrl\"") + + val openIdButtonText = envOrLocal("OPEN_ID_BUTTON_TEXT") + buildConfigField("String", "OPEN_ID_BUTTON_TEXT", "\"$openIdButtonText\"") + + val openIdPrompt = envOrLocal("OPEN_ID_PROMPT") + buildConfigField("String", "OPEN_ID_PROMPT", "\"$openIdPrompt\"") } packaging { jniLibs { @@ -123,6 +188,9 @@ android { "META-INF/gradle/incremental.annotation.processors" ) ) + // Compose Multiplatform string resources from KMP modules can duplicate + // when multiple modules package the same locale strings.xml as Java resources. + pickFirsts.addAll(listOf("values*/**")) } } @@ -199,41 +267,42 @@ android { abortOnError = false checkReleaseBuilds = false } +} - androidComponents { - onVariants { variant -> - val buildType = variant.buildType - val flavorName = variant.flavorName +androidComponents { + onVariants { variant -> + val buildType = variant.buildType + val flavorName = variant.flavorName - // Apply suffix only for training flavor in release buildType - if (buildType == "release" && flavorName == "dhis2Training") { - variant.applicationId.set("${variant.applicationId.get()}.training") - } - - variant.outputs.forEach { output -> - if (output is VariantOutputImpl) { - val suffix = when { - buildType == "release" && flavorName == "dhis2Training" -> "-training" - buildType == "release" && flavorName == "dhis2PlayServices" -> "-googlePlay" - else -> "" - } + // Apply suffix only for training flavor in release buildType + if (buildType == "release" && flavorName == "dhis2Training") { + variant.applicationId.set("${variant.applicationId.get()}.training") + } - output.outputFileName = "dhis2-v${libs.versions.vName.get()}$suffix.apk" + variant.outputs.forEach { output -> + if (output is VariantOutputImpl) { + val suffix = when { + buildType == "release" && flavorName == "dhis2Training" -> "-training" + buildType == "release" && flavorName == "dhis2PlayServices" -> "-googlePlay" + buildType == "debug" -> "-${getBranchName()}" + else -> "" } - } + output.outputFileName = "dhis2-v${libs.versions.vName.get()}$suffix.apk" + } } - } - ksp { - arg("room.schemaLocation", "$projectDir/schemas") - arg("room.incremental", "true") - arg("room.expandProjection", "true") - // Enable debug logs - arg("ksp.logging.level", "DEBUG") } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") + arg("room.expandProjection", "true") + // Enable debug logs + arg("ksp.logging.level", "DEBUG") +} + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) @@ -242,6 +311,10 @@ kotlin { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) implementation(project(":dhis_android_analytics")) @@ -255,6 +328,7 @@ dependencies { implementation(project(":aggregates")) implementation(project(":commonskmm")) implementation(project(":login")) + implementation(project(":sync")) implementation(libs.security.conscrypt) implementation(libs.security.rootbeer) @@ -264,7 +338,6 @@ dependencies { implementation(libs.androidx.annotation) implementation(libs.androidx.cardview) implementation(libs.androidx.legacy.support.v4) - implementation(libs.androidx.multidex) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.work) implementation(libs.androidx.workrx) @@ -272,7 +345,6 @@ dependencies { implementation(libs.androidx.biometric) implementation(libs.androidx.material3) implementation(libs.google.guava) - implementation(libs.github.pinlock) implementation(libs.github.fancyshowcase) implementation(libs.lottie) implementation(libs.network.okhttp) @@ -316,6 +388,9 @@ dependencies { androidTestImplementation(libs.test.rx2.idler) androidTestImplementation(libs.test.compose.ui.test) androidTestImplementation(libs.test.hamcrest) + androidTestImplementation(libs.koin.test) + androidTestImplementation(libs.koin.test.junit4) + debugImplementation(libs.test.ui.test.manifest) } sentry { diff --git a/app/fabric.properties b/app/fabric.properties deleted file mode 100644 index 1cf0e86e8c6..00000000000 --- a/app/fabric.properties +++ /dev/null @@ -1,3 +0,0 @@ -#Contains API Secret used to validate your application. Commit to internal source control; avoid making secret public. -#Mon Jun 11 18:43:35 CEST 2018 -apiSecret=989160e9e12be69d75ecbbf110f1b768e5909ce910ae926a63d046d47133b9ab diff --git a/app/src/androidTest/assets/databases/dhis_test.db b/app/src/androidTest/assets/databases/dhis_test.db index 3e6c7f617d0..401d0461abf 100644 Binary files a/app/src/androidTest/assets/databases/dhis_test.db and b/app/src/androidTest/assets/databases/dhis_test.db differ diff --git a/app/src/androidTest/assets/mocks/settingswebapp/generalsettings.json b/app/src/androidTest/assets/mocks/settingswebapp/generalsettings.json new file mode 100644 index 00000000000..8b14c43405f --- /dev/null +++ b/app/src/androidTest/assets/mocks/settingswebapp/generalsettings.json @@ -0,0 +1,9 @@ +{ + "encryptDB": false, + "mobileConfiguration": { + "disableAllSettings": false + }, + "reservedValues": 100, + "smsGateway": "", + "smsResultSender": "" +} diff --git a/app/src/androidTest/java/org/dhis2/AppTest.kt b/app/src/androidTest/java/org/dhis2/AppTest.kt index f594dd84e89..aac5b3b125c 100644 --- a/app/src/androidTest/java/org/dhis2/AppTest.kt +++ b/app/src/androidTest/java/org/dhis2/AppTest.kt @@ -1,7 +1,5 @@ package org.dhis2 -import androidx.lifecycle.MutableLiveData -import androidx.work.WorkInfo import org.dhis2.common.di.TestingInjector import org.dhis2.common.keystore.KeyStoreRobot import org.dhis2.common.preferences.PreferencesTestingModule @@ -10,8 +8,6 @@ import org.dhis2.commons.schedulers.SchedulersProviderImpl import org.dhis2.data.server.ServerModule import org.dhis2.data.user.UserModule import org.dhis2.usescases.BaseTest.Companion.MOCK_SERVER_URL -import org.dhis2.usescases.sync.MockedWorkManagerController -import org.dhis2.usescases.sync.MockedWorkManagerModule import org.dhis2.utils.analytics.AnalyticsModule import org.hisp.dhis.android.core.D2Manager import org.hisp.dhis.android.core.D2Manager.blockingInstantiateD2 @@ -20,8 +16,6 @@ import org.hisp.dhis.android.core.D2Manager.setTestingSecureStore class AppTest : App() { - val mutableWorkInfoStatuses = MutableLiveData>() - fun restoreDB() { } @@ -68,13 +62,6 @@ class AppTest : App() { .schedulerModule(SchedulerModule(SchedulersProviderImpl())) .analyticsModule(AnalyticsModule()) .preferenceModule(PreferencesTestingModule()) - .workManagerController( - MockedWorkManagerModule( - MockedWorkManagerController( - mutableWorkInfoStatuses - ) - ) - ) } companion object { diff --git a/app/src/androidTest/java/org/dhis2/DBTestLoader.kt b/app/src/androidTest/java/org/dhis2/DBTestLoader.kt index cff717391f2..e294aa278cf 100644 --- a/app/src/androidTest/java/org/dhis2/DBTestLoader.kt +++ b/app/src/androidTest/java/org/dhis2/DBTestLoader.kt @@ -23,7 +23,10 @@ class DBTestLoader(private val context: Context) { Timber.tag("RUNNER_LOG").d("Copying database") val input = InstrumentationRegistry.getInstrumentation() .context.assets.open("databases/$DB_NAME_TEST") - val output = FileOutputStream("$databasePath/$DB_NAME") + + file.parentFile?.mkdirs() + + val output = FileOutputStream(file) writeExtractedFileToDisk(input, output) Timber.d("Database copy done") } catch (e: IOException) { @@ -51,4 +54,4 @@ class DBTestLoader(private val context: Context) { const val DB_NAME_TEST = "dhis_test.db" const val DB_NAME = "127-0-0-1-8080_android_unencrypted.db" } -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt b/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt index bbc2ad5e85c..57f243124e9 100644 --- a/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt +++ b/app/src/androidTest/java/org/dhis2/common/BaseRobot.kt @@ -10,10 +10,12 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithText import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.FailureHandler import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.base.DefaultFailureHandler import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.util.TreeIterables import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation @@ -105,6 +107,13 @@ open class BaseRobot { val maxTries = waitMillis / waitMillisPerTry.toInt() var tries = 0 + val defaultFailureHandler = DefaultFailureHandler(getInstrumentation().targetContext) + val failureHandler = FailureHandler { error, matcher -> + if (error is NoMatchingViewException) { + throw error + } + defaultFailureHandler.handle(error, matcher) + } for (i in 0..maxTries) try { @@ -112,7 +121,9 @@ open class BaseRobot { tries++ // Search the root for the view - onView(isRoot()).perform(searchFor(viewMatcher)) + onView(isRoot()) + .withFailureHandler(failureHandler) + .perform(searchFor(viewMatcher)) // If we're here, we found our view. Now return it return onView(viewMatcher) diff --git a/app/src/androidTest/java/org/dhis2/common/db/TestingDatabase.kt b/app/src/androidTest/java/org/dhis2/common/db/TestingDatabase.kt deleted file mode 100644 index 6bf505b016c..00000000000 --- a/app/src/androidTest/java/org/dhis2/common/db/TestingDatabase.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.dhis2.common.db - -import android.os.Environment -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.dhis2.AppTest -import org.dhis2.data.server.ServerModule -import org.dhis2.usescases.BaseTest -import org.hisp.dhis.android.core.D2Manager -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import timber.log.Timber -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream - -@RunWith(AndroidJUnit4::class) -class TestingDatabase : BaseTest() { - - companion object { - const val url = "https://play.dhis2.org/android-current/" - const val username = "android" - const val password = "Android123" - } - - @Ignore("This tests exports the Database to SD card, uncomment to use it locally") - @Test - fun copyDatabase() { - - /* Download db */ - val d2 = - D2Manager.blockingInstantiateD2(ServerModule.getD2Configuration(ApplicationProvider.getApplicationContext())) - d2?.userModule() - ?.logIn(username, password, url) - ?.blockingGet() - d2?.metadataModule()?.blockingDownload() - - /* Export Db to sdcard */ - try { - val sd = Environment.getExternalStorageDirectory() - - if (sd.canWrite()) { - val currentDB = - context.getDatabasePath("play-dhis2-org-android-current_android_unencrypted.db") - val backupDBPath = "dhis_test.db" - val backupDB = File(sd, backupDBPath) - - if (currentDB.exists()) { - val src = FileInputStream(currentDB).channel - val dst = FileOutputStream(backupDB).channel - dst.transferFrom(src, 0, src.size()) - src.close() - dst.close() - } - } - } catch (e: Exception) { - Timber.e(e) - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt b/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt index 76b6b8edc7c..fa9ddbf28ff 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/UseCaseTestsSuite.kt @@ -6,7 +6,6 @@ import org.dhis2.usescases.event.EventTest import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.EventInitialTest import org.dhis2.usescases.login.LoginTest import org.dhis2.usescases.main.MainTest -import org.dhis2.usescases.pin.PinTest import org.dhis2.usescases.programevent.ProgramEventTest import org.dhis2.usescases.searchte.SearchTETest import org.dhis2.usescases.settings.SettingsTest @@ -23,12 +22,12 @@ import org.junit.runners.Suite EventInitialTest::class, EventTest::class, MainTest::class, - PinTest::class, ProgramEventTest::class, SearchTETest::class, SettingsTest::class, SyncActivityTest::class, TeiDashboardTest::class, SchedulingDialogUiTest::class, + LoginTest::class, ) class UseCaseTestsSuite diff --git a/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt b/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt index 2e3261c54e1..8e993e8df84 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/about/AboutTest.kt @@ -1,13 +1,15 @@ package org.dhis2.usescases.about +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.ActivityTestRule -import org.dhis2.bindings.buildInfo import org.dhis2.BuildConfig import org.dhis2.R +import org.dhis2.bindings.buildInfo import org.dhis2.usescases.BaseTest import org.dhis2.usescases.main.MainActivity -import org.dhis2.usescases.main.homeRobot +import org.dhis2.usescases.main.MainScreenType import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -18,24 +20,27 @@ class AboutTest : BaseTest() { @get:Rule val rule = ActivityTestRule(MainActivity::class.java, false, false) + @get:Rule + val composeTestRule = createComposeRule() + @Test fun shouldCheckVersionsWhenOpenAboutScreen() { startActivity() val appVersion = getAppVersionName() val sdkVersion = getSDKVersionName() - homeRobot { - clickOnNavigationDrawerMenu() - clickAbout() - } - aboutRobot { checkVersionNames(appVersion, sdkVersion) } } private fun startActivity() { - rule.launchActivity(null) + rule.launchActivity( + MainActivity.intent( + ApplicationProvider.getApplicationContext(), + MainScreenType.About, + ) + ) } private fun getAppVersionName(): String { diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt index 0216ff9c92a..bb7caf6f126 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetDetailRobot.kt @@ -84,7 +84,7 @@ internal class DataSetDetailRobot( } fun checkDataSetIsNotCompletedAndModified(catCombo: String, orgUnit: String ) { - onView(withId(R.id.recycler)) + waitForView(withId(R.id.recycler)) .check( matches( hasItem( diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt index 6f8df0645b7..85a98e6a34b 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetInitialRobot.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -30,23 +29,23 @@ internal class DataSetInitialRobot( ) : BaseRobot() { fun clickOnInputOrgUnit() { - onView(withId(R.id.dataSetOrgUnitInputLayout)).perform(click()) + waitForView(withId(R.id.dataSetOrgUnitInputLayout)).perform(click()) } fun clickOnInputPeriod() { - onView(withId(R.id.dataSetPeriodInputLayout)).perform(click()) + waitForView(withId(R.id.dataSetPeriodInputLayout)).perform(click()) } fun clickOnActionButton() { - onView(withId(R.id.actionButton)).perform(click()) + waitForView(withId(R.id.actionButton)).perform(click()) } fun clickOnInputCatCombo() { - onView(withId(R.id.input_layout)).perform(click()) + waitForView(withId(R.id.input_layout)).perform(click()) } fun selectCatCombo(catCombo: String) { - onView(withText(catCombo)).perform(click()) + waitForView(withText(catCombo)).perform(click()) } fun chooseDate(date: String) { @@ -59,6 +58,7 @@ internal class DataSetInitialRobot( ).performClick() composeTestRule.onNodeWithContentDescription("Date", substring = true).performTextReplacement(date) composeTestRule.onNodeWithText("Accept", true).performClick() + composeTestRule.waitForIdle() } fun checkActionInputIsNotDisplayed() { diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt index ae070a9fa4b..1805133dbdf 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTableRobot.kt @@ -2,7 +2,6 @@ package org.dhis2.usescases.datasets -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsProperties.TestTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher @@ -31,7 +30,6 @@ import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.printToLog import androidx.compose.ui.test.swipeRight -import androidx.core.graphics.toColorInt import org.dhis2.common.BaseRobot import org.dhis2.composetable.ui.semantics.CELL_TEST_TAG import org.dhis2.composetable.ui.semantics.MANDATORY_ICON_TEST_TAG @@ -115,8 +113,19 @@ internal class DataSetTableRobot( composeTestRule.onNodeWithTag(SYNC_BUTTON_TAG) .assertIsDisplayed() .performClick() + + // Wait for the dialog to appear before interacting with it + composeTestRule.waitUntilExactlyOneExists( + hasText("Refresh"), + TIMEOUT, + ) composeTestRule.onNodeWithText("Refresh") .assertIsDisplayed() + + composeTestRule.waitUntilExactlyOneExists( + hasText("Not now"), + TIMEOUT, + ) composeTestRule.onNodeWithText("Not now") .assertIsDisplayed() .performClick() @@ -244,7 +253,7 @@ internal class DataSetTableRobot( fun assertTableIsDisplayed() { composeTestRule.waitUntilExactlyOneExists( hasTestTag("TABLE_SCROLLABLE_COLUMN"), - timeoutMillis = 3000 + timeoutMillis = TIMEOUT ) } @@ -252,7 +261,7 @@ internal class DataSetTableRobot( fun assertImmunizationTableIsDisplayed() { composeTestRule.waitUntilAtLeastOneExists( hasText("Fixed"), - timeoutMillis = 5000 + timeoutMillis = TIMEOUT ) } @@ -400,6 +409,10 @@ internal class DataSetTableRobot( fun checkItemWithTextIsDisplayed(text: String) { assertTableIsDisplayed() + composeTestRule.waitUntilAtLeastOneExists( + hasText(text, substring = true), + timeoutMillis = 10000 + ) composeTestRule.onNodeWithText(text, substring = true, useUnmergedTree = true) .assertIsDisplayed() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt index 17f23469945..55b340e3c09 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/datasets/DataSetTest.kt @@ -307,6 +307,7 @@ class DataSetTest : BaseTest() { val cellId = "PGRlPlhOcmpYcVpySEQ4Ojxjb2M+SGxsdlg1MGNYQzA=" val threeDaysFromNowStr = threeDaysFromNow.format(formatter) val fiveDaysAgoStr = fiveDaysAgo.format(formatter) + enterDataSetStep( uid = dataSetUid, name = dataSetName, @@ -318,20 +319,12 @@ class DataSetTest : BaseTest() { ) checkTableIsNotEditable() - dataSetTableRobot(composeTestRule) { - tapOnSaveButton() - } - composeTestRule.waitForIdle() + createDailyPeriodDataSetInstanceStep( date = threeDaysFromNowStr, orgUnit = orgUnit, catCombo = catCombo ) - // Wait for table to be ready after creating the second dataset instance - composeTestRule.waitUntilExactlyOneExists( - hasTestTag("TABLE_SCROLLABLE_COLUMN"), - timeoutMillis = 10000 - ) tableIsVisible() enterDataStep( tableId = tableId, @@ -374,24 +367,25 @@ class DataSetTest : BaseTest() { } } - private suspend fun waitForTableToBeVisible() { - composeTestRule.awaitIdle() + private fun waitForTableToBeVisible() { + composeTestRule.waitForIdle() dataSetRobot { clickOnDataSetAtPosition(0) } tableIsVisible() } - private suspend fun checkTableIsNotEditable() { + private fun checkTableIsNotEditable() { tableIsVisible() composeTestRule.onNodeWithTag("TABLE_SCROLLABLE_COLUMN").printToLog("TABLE_LOG") dataSetTableRobot(composeTestRule) { checkItemWithTextIsDisplayed("This data is not editable") + tapOnSaveButton() + composeTestRule.waitForIdle() } - composeTestRule.waitForIdle() } - private suspend fun checkContentBoxesAreDisplayed() { + private fun checkContentBoxesAreDisplayed() { tableIsVisible() // Check top and bottom content is displayed in initial section dataSetDetailRobot(composeTestRule) { @@ -449,7 +443,7 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkCategoryIsMovedToRow() { + private fun checkCategoryIsMovedToRow() { val cellIdSection8 = "PGRlPlAzakpINVR1NVZDLCA8Y28+RmJMWlMzdWVXYlE6PGNvPg==" val cellId2Section8 = "PGRlPkZRMm84VUJsY3JTLCA8Y28+RmJMWlMzdWVXYlE6PGNvPg==" val cellIdSection16 = "PGRlPkFyUzdWeXVMOTVmLCA8Y28+RmJMWlMzdWVXYlE6PGNvPg==" @@ -560,7 +554,7 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkAutomaticGroupingDisabled() { + private fun checkAutomaticGroupingDisabled() { val table19 = "t3aNCvHsoSn_0" val table219 = "aN8uN5b15YG_1" val table20 = "ck7mRNwGDjP_1" @@ -637,7 +631,7 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkPivotOptions() { + private fun checkPivotOptions() { val table5 = "aN8uN5b15YG" val table23 = "aN8uN5b15YG_1" val cellIdSection5 = "PGNvYz5ET0M3ZW1MenlSaTo8ZGU+TFNKNW1LcHlFdjE=" @@ -706,29 +700,29 @@ class DataSetTest : BaseTest() { } } - private suspend fun tableIsVisible() { - composeTestRule.awaitIdle() + private fun tableIsVisible() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { assertTableIsDisplayed() } } - private suspend fun checkImmunizationTableIsDisplayed() { - composeTestRule.awaitIdle() + private fun checkImmunizationTableIsDisplayed() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { assertImmunizationTableIsDisplayed() } } - private suspend fun syncButtonIsAvailableStep() { - composeTestRule.awaitIdle() + private fun syncButtonIsAvailableStep() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { syncIsAvailable() } } - private suspend fun checkTotals() { - composeTestRule.awaitIdle() + private fun checkTotals() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { totalsAreDisplayed( tableId = "dzjKKQq0cSO", @@ -793,14 +787,14 @@ class DataSetTest : BaseTest() { } } - private suspend fun checkIndicatorsStep() { - composeTestRule.awaitIdle() + private fun checkIndicatorsStep() { + composeTestRule.waitForIdle() dataSetTableRobot(composeTestRule) { indicatorTableIsDisplayed() } } - private suspend fun reenterDataSetToCheckValueSavedStep() { + private fun reenterDataSetToCheckValueSavedStep() { val cell00Id = "PGRlPnM0Nm01TVMwaHh1Ojxjb2M+UHJsdDBDMVJGMHM=" dataSetTableRobot(composeTestRule) { @@ -842,7 +836,7 @@ class DataSetTest : BaseTest() { clickOnFilterBy(filter = "Period") clickOnFromToDate() chooseDate("08082024") - chooseDate("09092026") + chooseDate("09092027") checkFilterCounter("2") } diff --git a/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt index d818ea3e31e..13f9abb3a6b 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/event/EventRegistrationRobot.kt @@ -3,13 +3,10 @@ package org.dhis2.usescases.event import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.platform.app.InstrumentationRegistry import org.dhis2.R import org.dhis2.common.BaseRobot import org.dhis2.common.matchers.hasCompletedPercentage @@ -25,45 +22,8 @@ fun eventRegistrationRobot( class EventRegistrationRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { - fun openMenuMoreOptions() { - onView(withId(R.id.moreOptions)).perform(click()) - } - - fun clickOnDelete() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - composeTestRule.onNodeWithText(getString(R.string.delete)).performClick() - } - } - fun checkEventDataEntryIsOpened(completion: Int, orgUnit: String) { onView(withId(R.id.completion)).check(matches(hasCompletedPercentage(completion))) composeTestRule.onNodeWithText(orgUnit).performScrollTo().assertIsDisplayed() } - - fun clickOnShare() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - composeTestRule.onNodeWithText(getString(R.string.share)).performClick() - } - } - - private fun clickOnNextQR() { - waitForView(withId(R.id.next)).perform(click()) - } - - fun clickOnAllQR(listQR: Int) { - var qrLength = 1 - - while (qrLength < listQR) { - clickOnNextQR() - qrLength++ - } - } - - fun clickOnDeleteDialog() { - onView(withId(R.id.possitive)).perform(click()) - } - - fun clickNextButton() { - waitForView(withId(R.id.action_button)).perform(click()) - } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt index 59904781ca9..a8d0f167239 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/flow/teiFlow/TeiFlowRobot.kt @@ -116,8 +116,10 @@ class TeiFlowRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { teiDashboardRobot(composeTestRule) { clickOnMenuMoreOptions() clickOnTimelineEvents() + composeTestRule.waitForIdle() clickOnMenuMoreOptions() clickOnMenuComplete() + composeTestRule.waitForIdle() checkCanNotAddEvent() checkAllEventsAreClosed() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt index fce28c37788..25863c4453c 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/login/LoginRobot.kt @@ -1,34 +1,24 @@ package org.dhis2.usescases.login import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextReplacement -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent -import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry -import org.dhis2.R import org.dhis2.common.BaseRobot -import org.dhis2.commons.dialogs.bottomsheet.CLICKABLE_TEXT_TAG +import org.dhis2.mobile.login.accounts.ui.screen.ACCOUNT_ITEM_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_ERROR_INFO_BAR_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_LOGIN_BUTTON_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_PASSWORD_INPUT_TAG +import org.dhis2.mobile.login.main.ui.screen.CREDENTIALS_USERNAME_INPUT_TAG import org.dhis2.mobile.login.main.ui.screen.SERVER_VALIDATION_CONTENT_BUTTON_TAG -import org.dhis2.usescases.BaseTest.Companion.MOCK_SERVER_URL -import org.dhis2.usescases.about.PolicyView -import org.dhis2.usescases.qrScanner.ScanActivity -import org.dhis2.utils.WebViewActivity -import org.hamcrest.CoreMatchers fun loginRobot( @@ -42,7 +32,6 @@ fun loginRobot( class LoginRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { - val context = InstrumentationRegistry.getInstrumentation().targetContext @OptIn(ExperimentalTestApi::class) @@ -54,75 +43,144 @@ class LoginRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithTag(SERVER_VALIDATION_CONTENT_BUTTON_TAG).performClick() } + @OptIn(ExperimentalTestApi::class) fun typeServerToValidate(server: String) { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag("INPUT_QR_CODE_FIELD"), + TIMEOUT + ) composeTestRule.onNodeWithTag("INPUT_QR_CODE_FIELD").performTextReplacement(server) } - fun clickQRButton() { - composeTestRule.onNodeWithTag("INPUT_QR_CODE_BUTTON").performClick() + @OptIn(ExperimentalTestApi::class) + fun checkServerInputIsDisplayed() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag("INPUT_QR_CODE_FIELD"), + TIMEOUT, + ) + composeTestRule.onNodeWithTag("INPUT_QR_CODE_FIELD").assertIsDisplayed() } - - fun checkAuthErrorAlertIsVisible() { - waitForView(withText(LOGIN_ERROR_TITLE)).check(matches(isDisplayed())) + @OptIn(ExperimentalTestApi::class) + fun typeUsername(username: String) { + // Wait for credentials screen to appear + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_USERNAME_INPUT_TAG), + TIMEOUT, + ) + // Click on the username input to focus it + composeTestRule.onNodeWithTag(CREDENTIALS_USERNAME_INPUT_TAG).performClick() + // Now find the inner text field and type the username + composeTestRule.onNodeWithTag("INPUT_USER_FIELD").performTextReplacement(username) } - fun clickOKAuthErrorAlert() { - onView(withText(OK)).perform(click()) + @OptIn(ExperimentalTestApi::class) + fun typePassword(password: String) { + // Wait for password field to appear + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_PASSWORD_INPUT_TAG), + TIMEOUT, + ) + // Click on the password input to focus it + composeTestRule.onNodeWithTag(CREDENTIALS_PASSWORD_INPUT_TAG).performClick() + // Now find the inner text field and type the password + composeTestRule.onNodeWithTag("INPUT_PASSWORD_TEXT_FIELD").performTextReplacement(password) } - fun checkUnblockSessionViewIsVisible() { - onView(withId(R.id.cardview_pin)).check(matches(isDisplayed())) + @OptIn(ExperimentalTestApi::class) + fun checkLoginButtonIsEnabled() { + closeKeyboard() + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_LOGIN_BUTTON_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_LOGIN_BUTTON_TAG) + .assertIsEnabled() } - fun checkURL(url: String) { - composeTestRule.onNodeWithTag("INPUT_QR_CODE_FIELD").assert(hasText(url)) + @OptIn(ExperimentalTestApi::class) + fun checkLoginButtonIsDisabled() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_LOGIN_BUTTON_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_LOGIN_BUTTON_TAG) + .assertIsNotEnabled() } - fun checkWebviewWithRecoveryAccountIsOpened() { - Intents.intended( - CoreMatchers.allOf( - hasExtra( - WebViewActivity.WEB_VIEW_URL, - "${MOCK_SERVER_URL}/dhis-web-commons/security/recovery.action" - ), - hasComponent(WebViewActivity::class.java.name) - ) + @OptIn(ExperimentalTestApi::class) + fun clickLoginButton() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_LOGIN_BUTTON_TAG), + TIMEOUT, ) + composeTestRule.onNodeWithTag(CREDENTIALS_LOGIN_BUTTON_TAG).performClick() } - fun checkQRScanIsOpened() { - Intents.intended(CoreMatchers.allOf(hasComponent(ScanActivity::class.java.name))) + @OptIn(ExperimentalTestApi::class) + fun checkServerValidationErrorIsDisplayed() { + // Wait for error to appear in the supporting text of the server input + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag("INPUT_QR_CODE_FIELD"), + TIMEOUT, + ) + // The error is displayed as supporting text, we just need to verify the field is in error state } - fun checkShareDataDialogIsDisplayed() { - val title = InstrumentationRegistry.getInstrumentation() - .targetContext.getString(R.string.improve_app_msg_title) - composeTestRule.onNodeWithText(title) + @OptIn(ExperimentalTestApi::class) + fun checkCredentialsErrorIsDisplayed() { + // Wait for the InfoBar with error message to appear + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(CREDENTIALS_ERROR_INFO_BAR_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_ERROR_INFO_BAR_TAG).assertIsDisplayed() } - fun clickOnPrivacyPolicy() { - composeTestRule.onNodeWithTag(CLICKABLE_TEXT_TAG).performClick() + @OptIn(ExperimentalTestApi::class) + fun clickOnManageAccountsButton() { + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG).performClick() } - fun acceptTrackerDialog() { - val title = InstrumentationRegistry - .getInstrumentation() - .targetContext.getString(R.string.improve_app_msg_title) - composeTestRule.onNodeWithText(title).assertIsDisplayed() + @OptIn(ExperimentalTestApi::class) + fun checkAccountIsListed(serverUrl: String, username: String) { + val accountTag = "${ACCOUNT_ITEM_TAG}_${serverUrl}_${username}" + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(accountTag), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(accountTag).assertIsDisplayed() } - fun clickYesOnAcceptTrackerDialog() { - composeTestRule.onNodeWithText(context.getString(R.string.yes)) - .performClick() + @OptIn(ExperimentalTestApi::class) + fun clickOnAccount(serverUrl: String, username: String) { + val accountTag = "${ACCOUNT_ITEM_TAG}_${serverUrl}_${username}" + composeTestRule.waitUntilExactlyOneExists( + hasTestTag(accountTag), + TIMEOUT, + ) + composeTestRule.onNodeWithTag(accountTag).performClick() } - fun checkPrivacyViewIsOpened() { - Intents.intended(CoreMatchers.allOf(hasComponent(PolicyView::class.java.name))) + @OptIn(ExperimentalTestApi::class) + fun checkTrackingPermissionDialogIsDisplayed() { + composeTestRule.waitUntilExactlyOneExists( + hasText("Do you want to help us improve this app?"), + TIMEOUT, + ) } - companion object { - const val LOGIN_ERROR_TITLE = "Login error" - const val OK = "OK" + @OptIn(ExperimentalTestApi::class) + fun acceptTrackingPermission() { + checkTrackingPermissionDialogIsDisplayed() + composeTestRule.waitUntilExactlyOneExists( + hasText("Yes"), + TIMEOUT, + ) + composeTestRule.onNode(hasText("Yes")).performClick() } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt b/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt index a2cf81e3e12..708a2590a71 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/login/LoginTest.kt @@ -1,31 +1,20 @@ package org.dhis2.usescases.login -import android.app.Activity -import android.app.Instrumentation import android.content.Intent import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider -import androidx.test.espresso.intent.Intents.intending -import androidx.test.espresso.intent.matcher.IntentMatchers import org.dhis2.common.keystore.KeyStoreRobot -import org.dhis2.commons.Constants.EXTRA_DATA -import org.dhis2.commons.prefs.Preference.Companion.PIN -import org.dhis2.commons.prefs.Preference.Companion.SESSION_LOCKED import org.dhis2.lazyActivityScenarioRule import org.dhis2.usescases.BaseTest -import org.dhis2.usescases.qrScanner.ScanActivity -import org.hamcrest.CoreMatchers.allOf import org.hisp.dhis.android.core.D2Manager import org.hisp.dhis.android.core.mockwebserver.ResponseController.Companion.API_ME_PATH import org.hisp.dhis.android.core.mockwebserver.ResponseController.Companion.API_SYSTEM_INFO_PATH import org.hisp.dhis.android.core.mockwebserver.ResponseController.Companion.GET import org.junit.FixMethodOrder -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runners.MethodSorters -@Ignore("implement in login module") @FixMethodOrder(MethodSorters.NAME_ASCENDING) class LoginTest : BaseTest() { @@ -39,6 +28,7 @@ class LoginTest : BaseTest() { restoreDataBaseOnBeforeAction = false super.setUp() setupMockServer() + clearAnalyticsPermission() D2Manager.removeCredentials() } @@ -46,110 +36,60 @@ class LoginTest : BaseTest() { override fun teardown() { restoreDataBaseOnBeforeAction = true super.teardown() + // Restore original credentials D2Manager.setCredentials(KeyStoreRobot.KEYSTORE_USERNAME, KeyStoreRobot.PASSWORD) } - @Ignore("implement in login module") @Test - fun loginFlow() { - mockWebServerRobot.addResponse(GET, API_LOGIN_CONFIG, API_LOGIN_CONFIG_RESPONSE, 200) - mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_RESPONSE_OK) - mockWebServerRobot.addResponse(GET, API_SYSTEM_INFO_PATH, API_SYSTEM_INFO_RESPONSE_OK) - mockWebServerRobot.addResponse( - GET, - PATH_WEBAPP_GENERAL_SETTINGS, - API_METADATA_SETTINGS_RESPONSE_ERROR, - 404 - ) - mockWebServerRobot.addResponse(GET, PATH_WEBAPP_INFO, API_METADATA_SETTINGS_INFO_ERROR, 404) + fun shouldLoginWithoutOauth() { + enableIntents() startLoginActivity() loginRobot(composeTestRule) { - typeServerToValidate(MOCK_SERVER_URL) + // Step: Enter incorrect server URL and validate - should show error + typeServerToValidate("https://invalid-server.com") clickOnValidateServerButton() - // Test case - [ANDROAPP-4122](https://dhis2.atlassian.net/browse/ANDROAPP-4122) -// typeUsername(USERNAME) -// typePassword(PASSWORD) -// clearUsernameField() -// clearPasswordField() -// checkUsernameFieldIsClear() -// checkPasswordFieldIsClear() - - //Test case - [ANDROAPP-4123](https://dhis2.atlassian.net/browse/ANDROAPP-4123) -// checkLoginButtonIsHidden() - - // Test case - [ANDROAPP-4126](https://dhis2.atlassian.net/browse/ANDROAPP-4126) - enableIntents() -// clickAccountRecovery() - checkWebviewWithRecoveryAccountIsOpened() - pressBack() - - // Test case - [ANDROAPP-4121](https://dhis2.atlassian.net/browse/ANDROAPP-4121) - mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_UNAUTHORIZE, HTTP_UNAUTHORIZE) -// selectUsernameField() -// typeUsername(USERNAME) -// typePassword(PASSWORD) - waitToDebounce(5000) -// clickLoginButton() - checkAuthErrorAlertIsVisible() - clickOKAuthErrorAlert() - - // Test case - [ANDROAPP-4121](https://dhis2.atlassian.net/browse/ANDROAPP-4121) - mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_RESPONSE_OK) -// clearPasswordField() -// typePassword(PASSWORD) - waitToDebounce(5000) -// clickLoginButton() - - //Test case - [ANDROAPP-5184](https://dhis2.atlassian.net/browse/ANDROAPP-5184) - checkShareDataDialogIsDisplayed() - clickOnPrivacyPolicy() - checkPrivacyViewIsOpened() - pressBack() - acceptTrackerDialog() - clickYesOnAcceptTrackerDialog() - } - cleanDatabase() - } + checkServerValidationErrorIsDisplayed() - @Ignore("implement in login module") - @Test - fun goToPinScreenWhenPinWasSet() { - preferencesRobot.saveValue(SESSION_LOCKED, true) - preferencesRobot.saveValue(PIN, PIN_PASSWORD) - - startLoginActivity() - - loginRobot(composeTestRule) { + // Step: Enter correct server URL and validate - should proceed to credentials + mockWebServerRobot.addResponse(GET, API_LOGIN_CONFIG, API_LOGIN_CONFIG_RESPONSE, 200) + typeServerToValidate(MOCK_SERVER_URL) clickOnValidateServerButton() - checkUnblockSessionViewIsVisible() - } - } - @Ignore("implement in login module") - @Test - fun generateLoginThroughQR() { - preferencesRobot.cleanPreferences() - enableIntents() - startLoginActivity() + // Step: Credentials screen should be displayed + // Enter incorrect credentials - should show error + mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_UNAUTHORIZE, HTTP_UNAUTHORIZE) + checkLoginButtonIsDisabled() + typeUsername("wronguser") + typePassword("wrongpassword") + checkLoginButtonIsEnabled() + clickLoginButton() + checkCredentialsErrorIsDisplayed() + + // Step: Enter correct credentials - should proceed to tracker dialog + mockWebServerRobot.addResponse(GET, API_ME_PATH, API_ME_RESPONSE_OK) + mockWebServerRobot.addResponse(GET, API_SYSTEM_INFO_PATH, API_SYSTEM_INFO_RESPONSE_OK) + mockWebServerRobot.addResponse( + GET, + PATH_WEBAPP_GENERAL_SETTINGS, + API_METADATA_SETTINGS_RESPONSE_OK, + 200 + ) + mockWebServerRobot.addResponse( + GET, + PATH_WEBAPP_INFO, + API_METADATA_SETTINGS_INFO_ERROR, + 404 + ) - loginRobot(composeTestRule) { - mockOnActivityForResult() - clickQRButton() - checkQRScanIsOpened() - checkURL(MOCK_SERVER_URL) - } - } + typeUsername(USERNAME) + typePassword(PASSWORD) + checkLoginButtonIsEnabled() + clickLoginButton() - private fun mockOnActivityForResult() { - val intent = Intent().apply { - putExtra(EXTRA_DATA, MOCK_SERVER_URL - ) + // Step: Handle tracking permission dialog + acceptTrackingPermission() } - val result = Instrumentation.ActivityResult(Activity.RESULT_OK, intent) - intending(allOf(IntentMatchers.hasComponent(ScanActivity::class.java.name))).respondWith( - result - ) } private fun startLoginActivity() { @@ -161,27 +101,32 @@ class LoginTest : BaseTest() { } - private fun cleanDatabase() { - context.deleteDatabase(DB_GENERATED_BY_LOGIN) + private fun clearAnalyticsPermission() { + D2Manager.getD2() + .dataStoreModule() + .localDataStore() + .value(ANALYTICS_PERMISSION_KEY) + .blockingDeleteIfExist() } companion object { const val HTTP_UNAUTHORIZE = 401 const val API_LOGIN_CONFIG = "/api/loginConfig" - const val API_LOGIN_CONFIG_RESPONSE ="mocks/loginconfig/legacy_flow_config.json" + const val API_LOGIN_CONFIG_RESPONSE = "mocks/loginconfig/legacy_flow_config.json" const val API_ME_RESPONSE_OK = "mocks/user/user.json" const val API_ME_UNAUTHORIZE = "mocks/user/unauthorize.json" const val API_SYSTEM_INFO_RESPONSE_OK = "mocks/systeminfo/systeminfo.json" - const val API_METADATA_SETTINGS_RESPONSE_ERROR = - "mocks/settingswebapp/generalsettings_404.json" + const val API_METADATA_SETTINGS_RESPONSE_OK = + "mocks/settingswebapp/generalsettings.json" const val API_METADATA_SETTINGS_INFO_ERROR = "mocks/settingswebapp/infosettings_404.json" const val PATH_WEBAPP_GENERAL_SETTINGS = "/api/dataStore/ANDROID_SETTING_APP/general_settings?.*" const val PATH_WEBAPP_INFO = "/api/dataStore/ANDROID_SETTINGS_APP/info?.*" - const val DB_GENERATED_BY_LOGIN = "127-0-0-1-8080_test_unencrypted.db" - const val PIN_PASSWORD = 1234 - const val USERNAME = "test" - const val PASSWORD = "Android123" + const val USERNAME = "android" // Existing test database username + const val PASSWORD = "Android123" // Existing test database password + + private const val ANALYTICS_PERMISSION_KEY = "analytics_permission" + } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt index 07ad8529ebf..5c37733e3a5 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/main/MainRobot.kt @@ -1,6 +1,5 @@ package org.dhis2.usescases.main -import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert @@ -8,50 +7,29 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithTag -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.contrib.NavigationViewActions -import androidx.test.espresso.matcher.ViewMatchers.withId -import org.dhis2.R import org.dhis2.common.BaseRobot import org.dhis2.usescases.main.program.HOME_ITEMS import org.dhis2.usescases.main.program.hasPrograms -fun homeRobot(robotBody: MainRobot.() -> Unit) { - MainRobot().apply { +private const val HOME_NAVIGATION_TIMEOUT = 15000L + +fun homeRobot( + composeTestRule: ComposeTestRule, + robotBody: MainRobot.() -> Unit +) { + MainRobot(composeTestRule).apply { robotBody() } } -class MainRobot : BaseRobot() { - - fun clickOnNavigationDrawerMenu() = apply { - waitForView(withId(R.id.menu)).perform(click()) - } - - fun clickOnSettings() = apply { - waitForView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.sync_manager)) - waitToDebounce(FRAGMENT_TRANSITION) - } - - fun clickOnPin() = apply { - onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.block_button)) - } - - fun clickAbout() = apply { - onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.menu_about)) - waitToDebounce(FRAGMENT_TRANSITION) - } - - fun clickDeleteAccount() = apply { - onView(withId(R.id.nav_view)).perform(NavigationViewActions.navigateTo(R.id.delete_account)) - } +class MainRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { + @OptIn(ExperimentalTestApi::class) fun checkViewIsNotEmpty(composeTestRule: ComposeTestRule) { - composeTestRule.waitUntil() { - composeTestRule.onNodeWithTag(HOME_ITEMS) - .fetchSemanticsNode().config.getOrNull(hasPrograms) == true - } + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(HOME_ITEMS) and SemanticsMatcher.expectValue(hasPrograms, true), + HOME_NAVIGATION_TIMEOUT, + ) composeTestRule.onNodeWithTag(HOME_ITEMS).assert( SemanticsMatcher.expectValue(hasPrograms, true) ) @@ -59,12 +37,7 @@ class MainRobot : BaseRobot() { @OptIn(ExperimentalTestApi::class) fun checkHomeIsDisplayed(composeTestRule: ComposeTestRule) { - composeTestRule.waitUntilAtLeastOneExists(hasTestTag(HOME_ITEMS), TIMEOUT) + composeTestRule.waitUntilAtLeastOneExists(hasTestTag(HOME_ITEMS), HOME_NAVIGATION_TIMEOUT) composeTestRule.onNodeWithTag(HOME_ITEMS).assertIsDisplayed() } - - companion object { - const val FRAGMENT_TRANSITION = 1500L - const val LOGOUT_TRANSITION = 2000L - } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt b/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt index 1df078c6a6a..902fee96ce7 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/main/MainTest.kt @@ -3,14 +3,9 @@ package org.dhis2.usescases.main import android.content.Intent import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.ActivityTestRule import org.dhis2.lazyActivityScenarioRule import org.dhis2.usescases.BaseTest -import org.dhis2.usescases.development.DevelopmentActivity -import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventCaptureActivity -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -27,12 +22,17 @@ class MainTest : BaseTest() { @Throws(Exception::class) override fun setUp() { super.setUp() - startActivity() } @Test fun checkHomeScreenRecyclerviewHasElements() { - homeRobot { + startActivity( + MainActivity.intent( + ApplicationProvider.getApplicationContext(), + MainScreenType.Home(HomeScreen.Programs), + ) + ) + homeRobot(composeTestRule) { composeTestRule.waitForIdle() checkViewIsNotEmpty(composeTestRule) } @@ -40,19 +40,19 @@ class MainTest : BaseTest() { @Test fun shouldNavigateToHomeWhenBackPressed() { - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() + startActivity( + MainActivity.intent( + ApplicationProvider.getApplicationContext(), + MainScreenType.Settings, + ) + ) + homeRobot(composeTestRule) { pressBack() checkHomeIsDisplayed(composeTestRule) } } - private fun startActivity() { - val intent = Intent( - ApplicationProvider.getApplicationContext(), - MainActivity::class.java - ).putExtra(AVOID_SYNC, true) + private fun startActivity(intent: Intent) { rule.launch(intent) } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/pin/PinRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/pin/PinRobot.kt deleted file mode 100644 index a77476e6560..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/pin/PinRobot.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.dhis2.usescases.pin - -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText -import org.dhis2.R -import org.dhis2.common.BaseRobot -import org.dhis2.common.matchers.isToast -import org.dhis2.usescases.main.MainActivity -import org.hamcrest.CoreMatchers.allOf - -fun pinRobot(pinBody: PinRobot.() -> Unit) { - PinRobot().apply { - pinBody() - } -} - -class PinRobot : BaseRobot() { - - fun clickForgotCode() { - onView(withId(R.id.forgotCode)).perform(click()) - } - - fun clickPinButton(button: String) { - onView(withText(button)).perform(click()) - } - - fun checkRedirectToHome() { - Intents.intended(allOf(hasComponent(MainActivity::class.java.name))) - } - - fun checkToastDisplayed(toastText: String) { - onView(withText(toastText)).inRoot(isToast()).check(matches(isDisplayed())) - } -} diff --git a/app/src/androidTest/java/org/dhis2/usescases/pin/PinTest.kt b/app/src/androidTest/java/org/dhis2/usescases/pin/PinTest.kt deleted file mode 100644 index 75664686787..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/pin/PinTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.dhis2.usescases.pin - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.ActivityTestRule -import org.dhis2.usescases.BaseTest -import org.dhis2.usescases.main.MainActivity -import org.dhis2.usescases.main.homeRobot -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PinTest : BaseTest() { - - @get:Rule - val rule = ActivityTestRule(MainActivity::class.java, false, false) - - @Test - fun openPin() { - startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnPin() - } - } - - private fun startActivity() { - rule.launchActivity(null) - } -} diff --git a/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt b/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt index 99bde9ffc87..094cbace362 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/programevent/ProgramEventTest.kt @@ -80,13 +80,11 @@ class ProgramEventTest : BaseTest() { programEventsRobot(composeTestRule) { clickOnEvent(eventDate) - } - eventRobot(composeTestRule) { - openMenuMoreOptions() - clickOnDelete() - clickOnDeleteDialog() - } - programEventsRobot(composeTestRule) { + eventRobot(composeTestRule) { + openMenuMoreOptions() + clickOnDelete() + clickOnDeleteDialog() + } checkEventWasDeleted(eventDate) } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt index e8d749e430e..ad20e27a443 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/programevent/robot/ProgramEventsRobot.kt @@ -9,12 +9,6 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import org.dhis2.R import org.dhis2.common.BaseRobot fun programEventsRobot( @@ -68,6 +62,7 @@ class ProgramEventsRobot(val composeTestRule: ComposeContentTestRule) : BaseRobo } fun checkEventWasDeleted(eventDate: String) { + composeTestRule.waitForIdle() composeTestRule.onNodeWithText(eventDate).assertDoesNotExist() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt b/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt index 93d3178ed9f..3c8674f9c97 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/searchte/SearchTETest.kt @@ -111,6 +111,77 @@ class SearchTETest : BaseTest() { } } + @Test + fun shouldFollowTBProgramSearchFlow() { + mockWebServerRobot.addResponse( + ResponseController.GET, + API_TRACKED_ENTITY_PATH, + API_TRACKED_ENTITY_EMPTY_RESPONSE, + ) + + prepareTBIntentAndLaunchActivity(rule) + + searchTeiRobot(composeTestRule) { + waitUntilActivityVisible() + + // ANDROAPP-5971: Verify the button is displayed and enabled before opening search + checkAddNewTEIButtonIsDisplayedAndEnabled() + + // Open the search parameters panel + clickOnOpenSearch() + + // ANDROAPP-5861: Unique attribute (TB identifier) is first after sort ordering + checkFirstSearchParamIsBarcodeOrQROrUnique(TB_IDENTIFIER_LABEL) + + // Check that all 9 search fields are displayed + checkSearchParamCount(9) + + // ANDROAPP-5862: Search button is disabled when no values are entered + checkSearchButtonIsDisabled() + + // Enter a value to enable the search button (Part A entry) + typeOnSearchParameter(TB_SEARCH_ATTR_CITY, TB_SEARCH_CITY_SHORT) + + // ANDROAPP-5862: Search button is now enabled + checkSearchButtonIsEnabled() + + // Re-enter a short value (1 char) to enable the button + checkFocusedFieldShowsOperatorSupportingText() + typeOnSearchParameter(TB_SEARCH_ATTR_STATE, TB_SEARCH_CITY_SHORT) + checkFocusedFieldShowsOperatorSupportingText() + typeOnSearchParameter(TB_SEARCH_ATTR_TB_NUMBER, TB_SEARCH_CITY_SHORT) + + // Click Search – triggers per-field min-character validation + clickOnSearch() + + // ANDROAPP-7489/7490 & ANDROAPP-1056/7491: + checkMinCharactersErrorIsDisplayed( + TB_SEARCH_ATTR_CITY, + TB_SEARCH_ATTR_STATE, + TB_SEARCH_ATTR_TB_NUMBER, + ) + clickOnClearSearch() + closeKeyboard() + + // Update to valid search values and search again + openNextSearchParameter(TB_SEARCH_ATTR_CITY) + typeOnSearchParameter(TB_SEARCH_ATTR_CITY, TB_SEARCH_CITY) + closeKeyboard() + openNextSearchParameter(TB_SEARCH_ATTR_STATE) + typeOnSearchParameter(TB_SEARCH_ATTR_STATE, TB_SEARCH_STATE) + closeKeyboard() + typeOnSearchParameter(TB_SEARCH_ATTR_TB_NUMBER, TB_SEARCH_TB_NUMBER) + + // Click Search with valid values + clickOnSearch() + + // Verify results: Lynn Dunn and Inés Bebea are displayed + checkSearchResultDisplayed(TB_RESULT_DUNN) + checkSearchResultDisplayed(TB_RESULT_BEBEA) + + } + } + private fun createRegisterTEI() = RegisterTEIUIModel( "Claire", "Jones", @@ -139,5 +210,20 @@ class SearchTETest : BaseTest() { const val CHILD_TE_TYPE_VALUE = "nEenWmSyUEp" const val CHILD_TE_TYPE = "TRACKED_ENTITY_UID" + + // TB Program search flow test constants + const val TB_IDENTIFIER_LABEL = "TB identifier" + + const val TB_SEARCH_ATTR_CITY = "City" + const val TB_SEARCH_ATTR_STATE = "State" + const val TB_SEARCH_ATTR_TB_NUMBER = "TB number" + + const val TB_SEARCH_CITY_SHORT = "C" + const val TB_SEARCH_CITY = "Cit" + const val TB_SEARCH_STATE = "Sta" + const val TB_SEARCH_TB_NUMBER = "34567" + + const val TB_RESULT_DUNN = "Dunn" + const val TB_RESULT_BEBEA = "Bebea" } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/FilterRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/FilterRobot.kt index c12066a97fd..753ad5101e1 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/FilterRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/FilterRobot.kt @@ -16,7 +16,6 @@ import androidx.test.espresso.contrib.PickerActions import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItem import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withChild import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withParent import androidx.test.espresso.matcher.ViewMatchers.withText @@ -151,7 +150,7 @@ class FilterRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { withParent(withId(R.id.mainToolbar)) ) ) - .check(matches(withChild(withText(filterCount)))) + .check(matches(hasDescendant(withText(filterCount)))) } fun checkCountAtFilter(filter: String, count: String) { @@ -170,8 +169,9 @@ class FilterRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun checkTeiAreCompleted() { val nodes = composeTestRule.onAllNodesWithText("Enrollment completed", true) - assert(nodes.fetchSemanticsNodes().size >= 3) { - "Expected at least 3 nodes, but found ${nodes.fetchSemanticsNodes().size}" + composeTestRule.waitForIdle() + assert(nodes.fetchSemanticsNodes().size >= 2) { + "Expected at least 2 nodes, but found ${nodes.fetchSemanticsNodes().size}" } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/SearchTeiRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/SearchTeiRobot.kt index d2c48a74aa1..a30bfb07bdc 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/SearchTeiRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/searchte/robot/SearchTeiRobot.kt @@ -1,18 +1,22 @@ package org.dhis2.usescases.searchte.robot import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.hasParent +import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onLast -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextInput import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -20,19 +24,14 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.platform.app.InstrumentationRegistry import org.dhis2.R import org.dhis2.common.BaseRobot -import org.dhis2.common.matchers.RecyclerviewMatchers import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.hasItem -import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.hasNoMoreResultsInProgram -import org.dhis2.common.viewactions.openSpinnerPopup -import org.dhis2.usescases.searchTrackEntity.listView.SearchResult -import org.dhis2.usescases.searchte.entity.DisplayListFieldsUIModel +import org.dhis2.tracker.search.ui.screen.SEARCH_PARAMETERS_LIST_TAG import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not -import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem - +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue fun searchTeiRobot( composeTestRule: ComposeTestRule, @@ -66,7 +65,14 @@ class SearchTeiRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { ) } + @OptIn(ExperimentalTestApi::class) fun openNextSearchParameter(parameterValue: String) { + closeKeyboard() + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag(SEARCH_PARAMETERS_LIST_TAG) + .performScrollToNode(hasText(parameterValue)) + composeTestRule.waitUntilAtLeastOneExists(hasText(parameterValue), TIMEOUT) composeTestRule.onNodeWithText(parameterValue).performClick() } @@ -82,80 +88,17 @@ class SearchTeiRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { } } + @OptIn(ExperimentalTestApi::class) fun clickOnSearch() { closeKeyboard() - composeTestRule.onNodeWithTag("SEARCH_BUTTON").performClick() - composeTestRule.waitForIdle() - } - - @OptIn(ExperimentalTestApi::class) - fun checkListOfSearchTEI(title: String, attributes: Map) { - //Checks title and all attributes are displayed - composeTestRule.waitUntilAtLeastOneExists(hasText(title)) - composeTestRule.onNodeWithText(title).assertIsDisplayed() - attributes.forEach { item -> - item.key?.let { composeTestRule.onNodeWithText("$it:", true).assertIsDisplayed() } - composeTestRule.onNode( - hasParent(hasTestTag("LIST_CARD_ADDITIONAL_INFO_COLUMN")) - and hasText(item.value, true), useUnmergedTree = true - ).assertIsDisplayed() - } - } - - fun checkNoSearchResult() { - onView(withId(R.id.scrollView)) - .check( - matches( - RecyclerviewMatchers.allElementsWithHolderTypeHave( - SearchResult::class.java, - allOf( - hasNoMoreResultsInProgram() - ) - ) - ) - ) - } - - fun clickOnProgramSpinner() { - onView(withId(R.id.program_spinner)).perform(openSpinnerPopup()) - } - - fun selectAProgram(program: String) { - onView(allOf(withId(R.id.spinner_text), withText(program))) - .perform(click()) - } - - fun checkProgramHasChanged(program: String) { - onView(withId(R.id.spinner_text)).check(matches(withText(program))) - } - - @OptIn(ExperimentalTestApi::class) - fun checkFieldsFromDisplayList( - displayListFieldsUIModel: DisplayListFieldsUIModel - ) { - //Given the title is the first attribute - val title = "First name: ${displayListFieldsUIModel.name}" - val displayedAttributes = createAttributesList(displayListFieldsUIModel) - val showMoreText = InstrumentationRegistry.getInstrumentation() - .targetContext.getString(R.string.show_more) composeTestRule.waitForIdle() - composeTestRule.waitUntilAtLeastOneExists(hasText(showMoreText)) - //When we expand all attribute list - composeTestRule.onNodeWithText(showMoreText, true, useUnmergedTree = true).performClick() - //Then The title and all attributes are displayed - composeTestRule.onNodeWithText(title).assertIsDisplayed() - displayedAttributes.forEach { item -> - item.key?.let { composeTestRule.onNodeWithText("$it:", true).assertIsDisplayed() } - composeTestRule.onNode( - hasParent(hasTestTag("LIST_CARD_ADDITIONAL_INFO_COLUMN")) - and hasText(item.value, true), useUnmergedTree = true - ).assertIsDisplayed() - } + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("SEARCH_BUTTON"), TIMEOUT) + composeTestRule.onNodeWithTag("SEARCH_BUTTON").performClick() } @OptIn(ExperimentalTestApi::class) fun clickOnShowMap() { - composeTestRule.waitUntilAtLeastOneExists(hasTestTag("NAVIGATION_BAR_ITEM_Map"),TIMEOUT) + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("NAVIGATION_BAR_ITEM_Map"), TIMEOUT) composeTestRule.onNodeWithTag("NAVIGATION_BAR_ITEM_Map").performClick() } @@ -172,7 +115,7 @@ class SearchTeiRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { // Finally assert that the text is displayed composeTestRule.onNode( hasAnyAncestor(hasTestTag("LIST_CARD_ADDITIONAL_INFO_COLUMN")) - and hasText(firstName, ignoreCase = true, substring = true), + and hasText(firstName, ignoreCase = true, substring = true), useUnmergedTree = true ).assertIsDisplayed() } @@ -185,27 +128,136 @@ class SearchTeiRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { onView(withId(R.id.createButton)).perform(click()) } - fun checkListOfSearchTEIWithAdditionalInfo(title: String, additionalText: String) { - composeTestRule.onNodeWithText(title).assertIsDisplayed() - composeTestRule.onNodeWithContentDescription(additionalText).assertIsDisplayed() - } - - private fun createAttributesList(displayListFieldsUIModel: DisplayListFieldsUIModel) = listOf( - AdditionalInfoItem( - key = "Last name", - value = displayListFieldsUIModel.lastName, - ), - AdditionalInfoItem( - key = "Email", - value = displayListFieldsUIModel.email, - ), - AdditionalInfoItem( - key = "Date of birth", - value = displayListFieldsUIModel.birthday, - ), - AdditionalInfoItem( - key = "Address", - value = displayListFieldsUIModel.address, - ), - ) + // --- Methods for the TB Program search flow test --- + + @OptIn(ExperimentalTestApi::class) + fun checkAddNewTEIButtonIsDisplayedAndEnabled() { + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("ADD_NEW_BUTTON"), TIMEOUT) + composeTestRule.onNodeWithTag("ADD_NEW_BUTTON") + .assertIsDisplayed() + .assertIsEnabled() + } + + @OptIn(ExperimentalTestApi::class) + fun checkFirstSearchParamIsBarcodeOrQROrUnique(expectedLabel: String) { + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("SEARCH_PARAM_ITEM"), TIMEOUT) + composeTestRule + .onAllNodesWithTag("SEARCH_PARAM_ITEM")[0] + .assert(hasText(expectedLabel)) + } + + @OptIn(ExperimentalTestApi::class) + fun checkSearchParamCount(expectedCount: Int) { + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("SEARCH_PARAM_ITEM"), TIMEOUT) + val count = composeTestRule + .onAllNodesWithTag("SEARCH_PARAM_ITEM") + .fetchSemanticsNodes() + .size + assertEquals( + "Expected $expectedCount search parameters, but found $count", + expectedCount, + count + ) + } + + @OptIn(ExperimentalTestApi::class) + fun checkSearchButtonIsDisabled() { + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("SEARCH_BUTTON"), TIMEOUT) + composeTestRule.onNodeWithTag("SEARCH_BUTTON").assertIsNotEnabled() + } + + @OptIn(ExperimentalTestApi::class) + fun checkSearchButtonIsEnabled() { + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("SEARCH_BUTTON"), TIMEOUT) + composeTestRule.onNodeWithTag("SEARCH_BUTTON").assertIsEnabled() + } + + @OptIn(ExperimentalTestApi::class) + fun typeOnSearchParameter(label: String, value: String) { + // Dismiss the keyboard first so the LazyColumn recomposes and exposes all field labels. + // Without this, items scrolled behind an open keyboard are dropped from composition and + // waitUntilAtLeastOneExists(hasText(label)) times out on the next field. + // We do NOT close the keyboard at the end: keeping focus on the typed field lets + // checkFocusedFieldShowsOperatorSupportingText() read its supporting text. + // Any focus jump caused by the IMM dismiss is immediately overridden by clicking the label. + closeKeyboard() + composeTestRule.waitForIdle() + // Scroll the LazyColumn to ensure the target field is in composition before clicking. + composeTestRule + .onNodeWithTag(SEARCH_PARAMETERS_LIST_TAG) + .performScrollToNode(hasText(label)) + composeTestRule.waitUntilAtLeastOneExists(hasText(label), TIMEOUT) + composeTestRule.onNodeWithText(label).performClick() + composeTestRule.waitUntilAtLeastOneExists(hasTestTag("INPUT_TEXT_FIELD"), TIMEOUT) + composeTestRule.onAllNodesWithTag("INPUT_TEXT_FIELD").onLast().performTextInput(value) + } + + @OptIn(ExperimentalTestApi::class) + fun clickOnClearSearch() { + closeKeyboard() + composeTestRule.waitForIdle() + val count = composeTestRule + .onAllNodesWithTag("INPUT_TEXT_RESET_BUTTON", useUnmergedTree = true) + .fetchSemanticsNodes().size + repeat(count) { + composeTestRule.waitForIdle() + composeTestRule + .onAllNodesWithTag("INPUT_TEXT_RESET_BUTTON", useUnmergedTree = true)[0] + .performScrollTo() + .performClick() + // Use hardware back press to dismiss the keyboard that appears after clicking X. + // Unlike closeKeyboard() (IMM-based), the back key is consumed by the keyboard + // itself before reaching Compose, so focus does not jump to the first focusable + // item (Unique ID) in the LazyColumn. + pressBack() + composeTestRule.waitForIdle() + } + } + + + @OptIn(ExperimentalTestApi::class) + fun checkFocusedFieldShowsOperatorSupportingText() { + composeTestRule.waitUntilAtLeastOneExists( + hasText("Exact matches only") or + hasText("Must match the start of the value") or + hasText("Must match the end of the value"), + TIMEOUT, + ) + } + + @OptIn(ExperimentalTestApi::class) + fun checkMinCharactersErrorIsDisplayed(vararg fieldLabels: String) { + composeTestRule.waitForIdle() + + fieldLabels.forEach { label -> + // Scroll the LazyColumn to compose the target field before asserting. + // Off-screen items are dropped from composition on slow CI devices, causing + // waitUntilAtLeastOneExists to time out even though the error is present. + composeTestRule + .onNodeWithTag(SEARCH_PARAMETERS_LIST_TAG) + .performScrollToNode(hasText(label)) + composeTestRule.waitForIdle() + + val containerMatcher = + hasAnyDescendant(hasText(label)) and + hasAnyDescendant(hasText("Enter at least", substring = true)) + + composeTestRule.waitUntilAtLeastOneExists(containerMatcher, TIMEOUT) + + val matchingNodes = composeTestRule + .onAllNodes(containerMatcher) + .fetchSemanticsNodes() + + assertTrue( + "Expected a min-characters error associated with field label \"$label\", but none was found.", + matchingNodes.isNotEmpty(), + ) + } + } + + @OptIn(ExperimentalTestApi::class) + fun checkSearchResultDisplayed(teiName: String) { + composeTestRule.waitUntilAtLeastOneExists(hasText(teiName, substring = true), TIMEOUT) + composeTestRule.onNodeWithText(teiName, substring = true).assertIsDisplayed() + } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsRobot.kt index 185332def79..224760e655c 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsRobot.kt @@ -1,8 +1,8 @@ package org.dhis2.usescases.settings +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule @@ -77,13 +77,13 @@ class SettingsRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun clickOnReservedValues() { composeTestRule.onNodeWithTag(SettingItem.RESERVED_VALUES.name).performClick() + composeTestRule.waitForIdle() } fun clickOnManageReservedValues() { composeTestRule.onNode( hasText(getString(R.string.manage_reserved_values_button), ignoreCase = true) ).performClick() - Intents.intended(IntentMatchers.hasComponent(ReservedValueActivity::class.java.name)) } @@ -95,7 +95,12 @@ class SettingsRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { waitForView(withId(R.id.errorRecycler)).check(matches(isDisplayed())) } + @OptIn(ExperimentalTestApi::class) fun checkTwoFAOptionIsDisplayed() { + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(SettingItem.TWO_FACTOR_AUTH.name), + TIMEOUT + ) composeTestRule.onNodeWithTag(SettingItem.TWO_FACTOR_AUTH.name).assertIsDisplayed() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt b/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt index ce1b9bcb812..df2a7e4227e 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/settings/SettingsTest.kt @@ -1,15 +1,13 @@ package org.dhis2.usescases.settings -import android.content.Intent import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.dhis2.commons.featureconfig.model.Feature import org.dhis2.lazyActivityScenarioRule import org.dhis2.usescases.BaseTest -import org.dhis2.usescases.main.AVOID_SYNC import org.dhis2.usescases.main.MainActivity -import org.dhis2.usescases.main.homeRobot +import org.dhis2.usescases.main.MainScreenType import org.junit.Before import org.junit.Rule import org.junit.Test @@ -33,12 +31,6 @@ class SettingsTest : BaseTest() { @Test fun shouldFindEditPeriodDisabledWhenClickOnSyncData() { startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() - } - settingsRobot(composeTestRule) { clickOnSyncData() checkEditPeriodIsDisableForData() @@ -48,12 +40,6 @@ class SettingsTest : BaseTest() { @Test fun shouldFindEditDisabledWhenClickOnSyncConfiguration() { startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() - } - settingsRobot(composeTestRule) { clickOnSyncConfiguration() checkEditPeriodIsDisableForConfiguration() @@ -63,12 +49,6 @@ class SettingsTest : BaseTest() { @Test fun shouldFindEditDisableWhenClickOnSyncParameters() { startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() - } - settingsRobot(composeTestRule) { clickOnSyncParameters() checkEditPeriodIsDisableForParameters() @@ -78,12 +58,6 @@ class SettingsTest : BaseTest() { @Test fun shouldRefillValuesWhenClickOnReservedValues() { startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() - } - settingsRobot(composeTestRule) { clickOnReservedValues() clickOnManageReservedValues() @@ -93,12 +67,6 @@ class SettingsTest : BaseTest() { @Test fun shouldSuccessfullyOpenLogs() { startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() - } - settingsRobot(composeTestRule) { clickOnOpenSyncErrorLog() checkLogViewIsDisplayed() @@ -109,14 +77,7 @@ class SettingsTest : BaseTest() { @Test fun shouldNotShowTwoFAOption() { disableFeatureConfigValue(Feature.TWO_FACTOR_AUTHENTICATION) - startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() - } - settingsRobot(composeTestRule) { checkTwoFAOptionIsNotDisplayed() } @@ -128,12 +89,6 @@ class SettingsTest : BaseTest() { enableFeatureConfigValue(Feature.TWO_FACTOR_AUTHENTICATION) startActivity() - - homeRobot { - clickOnNavigationDrawerMenu() - clickOnSettings() - } - settingsRobot(composeTestRule) { checkTwoFAOptionIsDisplayed() clickOnTwoFASettings() @@ -142,10 +97,10 @@ class SettingsTest : BaseTest() { } private fun startActivity() { - val intent = Intent( + val intent = MainActivity.intent( ApplicationProvider.getApplicationContext(), - MainActivity::class.java - ).putExtra(AVOID_SYNC, true) + MainScreenType.Settings + ) rule.launch(intent) } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerController.kt b/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerController.kt deleted file mode 100644 index aa258d6b737..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerController.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.dhis2.usescases.sync - -import androidx.lifecycle.LiveData -import androidx.work.WorkInfo -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem - -class MockedWorkManagerController(private val workInfoStatuses: LiveData>) : - WorkManagerController { - - override fun syncDataForWorker(workerItem: WorkerItem) { - } - - override fun syncDataForWorker(dataWorkerTag: String, workName: String) { - - } - - override fun syncMetaDataForWorker(metadataWorkerTag: String, workName: String) { - } - - override fun beginUniqueWork(workerItem: WorkerItem) { - } - - override fun enqueuePeriodicWork(workerItem: WorkerItem) { - } - - override fun getWorkInfosForUniqueWorkLiveData(workerName: String): LiveData> { - return workInfoStatuses - } - - override fun getWorkInfosByTagLiveData(tag: String): LiveData> { - return workInfoStatuses - } - - override fun getWorkInfosForTags(vararg tags: String): LiveData> { - return workInfoStatuses - } - - override fun cancelAllWork() { - } - - override suspend fun cancelAllWorkAndWait() { - } - - override fun cancelAllWorkByTag(tag: String) { - } - - override fun cancelUniqueWork(workName: String) { - } - - override fun pruneWork() { - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerModule.kt b/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerModule.kt deleted file mode 100644 index 6d312feb985..00000000000 --- a/app/src/androidTest/java/org/dhis2/usescases/sync/MockedWorkManagerModule.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.usescases.sync - -import androidx.work.WorkManager -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkManagerModule - -class MockedWorkManagerModule(private val mockedController: WorkManagerController) : - WorkManagerModule() { - - override fun providesWorkManagerController(workManager: WorkManager): WorkManagerController { - return mockedController - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt b/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt index 006b9ad8be9..8b9b969d5e3 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/sync/SyncActivityTest.kt @@ -1,52 +1,60 @@ package org.dhis2.usescases.sync -import androidx.lifecycle.MutableLiveData -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.work.Data -import androidx.work.WorkInfo -import org.dhis2.AppTest -import org.dhis2.commons.Constants +import kotlinx.coroutines.flow.MutableStateFlow +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.dhis2.usescases.BaseTest -import org.junit.Before +import org.junit.After import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import java.util.UUID -import org.junit.After +import org.koin.test.KoinTest +import org.koin.test.mock.MockProviderRule +import org.koin.test.mock.declareMock +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever + @RunWith(AndroidJUnit4::class) -class SyncActivityTest : BaseTest() { - private lateinit var workInfoStatusLiveData: MutableLiveData> +class SyncActivityTest : BaseTest(), KoinTest { + private val metadataSyncJobStatuses = MutableStateFlow>(emptyList()) @get:Rule - val rule = activityScenarioRule() - - @Before - override fun setUp() { - super.setUp() - workInfoStatusLiveData = - ApplicationProvider.getApplicationContext().mutableWorkInfoStatuses - } + val mockProvider = + MockProviderRule.create { clazz -> + Mockito.mock(clazz.java) + } @After override fun teardown() { super.teardown() - workInfoStatusLiveData.postValue(emptyList()) + metadataSyncJobStatuses.tryEmit(emptyList()) } @Test fun shouldShowMetadataErrorDialog() { - syncRobot { - waitUntilActivityVisible() - waitToDebounce(3000) - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.RUNNING))) - waitToDebounce(3000) - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.FAILED))) - waitToDebounce(3000) - checkMetadataErrorDialogIsDisplayed() + declareMock { + whenever(launchMetadataSync(any())) doAnswer {} + whenever(observeMetadataJob()) doReturn metadataSyncJobStatuses + } + + ActivityScenario.launch(SyncActivity::class.java).use { + syncRobot { + waitUntilActivityVisible() + waitToDebounce(3000) + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Running))) + waitToDebounce(3000) + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Failed))) + waitToDebounce(3000) + checkMetadataErrorDialogIsDisplayed() + } } } @@ -54,28 +62,28 @@ class SyncActivityTest : BaseTest() { fun shouldCompleteSyncProcess() { enableIntents() - syncRobot { - waitUntilActivityVisible() - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.RUNNING))) - waitToDebounce(3000) - checkMetadataIsSyncing() - checkDataIsWaiting() - workInfoStatusLiveData.postValue(arrayListOf(mockedMetaWorkInfo(WorkInfo.State.SUCCEEDED))) - waitToDebounce(3000) - checkMainActivityIsLaunched() + declareMock { + whenever(launchMetadataSync(any())) doAnswer {} + whenever(observeMetadataJob()) doReturn metadataSyncJobStatuses } - } - private fun mockedMetaWorkInfo(state: WorkInfo.State): WorkInfo { - return WorkInfo( - id = UUID.randomUUID(), - state = state, - tags = setOf(Constants.META_NOW), - outputData = Data.EMPTY, - progress = Data.EMPTY, - runAttemptCount = 0, - generation = 0 - ) + ActivityScenario.launch(SyncActivity::class.java).use { + syncRobot { + waitUntilActivityVisible() + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Running))) + waitToDebounce(3000) + checkMetadataIsSyncing() + checkDataIsWaiting() + metadataSyncJobStatuses.tryEmit(listOf(mockedMetadataJobStatus(SyncStatus.Succeed))) + waitToDebounce(3000) + checkMainActivityIsLaunched() + } + } } -} + private fun mockedMetadataJobStatus(status: SyncStatus) = SyncJobStatus( + tags = listOf("METADATA_SYNC", "METADATA_SYNC_NOW"), + status = status, + message = null + ) +} diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt index 93c3c49e54f..1e67d65ca19 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/TeiDashboardTest.kt @@ -339,23 +339,21 @@ class TeiDashboardTest : BaseTest() { clickOnTimelineEvents() clickOnMenuMoreOptions() clickOnMenuProgramEnrollments() - } - enrollmentRobot(composeTestRule) { - clickOnAProgramForEnrollment(composeTestRule, womanProgram) - clickOnAcceptInDatePicker() + enrollmentRobot(composeTestRule) { + clickOnAProgramForEnrollment(composeTestRule, womanProgram) + clickOnAcceptInDatePicker() - orgUnitSelectorRobot(composeTestRule) { - selectTreeOrgUnit(orgUnit) - } + orgUnitSelectorRobot(composeTestRule) { + selectTreeOrgUnit(orgUnit) + } - waitUntilActivityVisible() - openFormSection(personAttribute) - typeOnInputDateField("01012000", "Date of birth") - clickOnSaveEnrollment() - } + waitUntilActivityVisible() + openFormSection(personAttribute) + typeOnInputDateField("01012000", "Date of birth") + clickOnSaveEnrollment() + } - teiDashboardRobot(composeTestRule) { waitToDebounce(1000) clickOnMenuMoreOptions() clickOnTimelineEvents() diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt index 439cd182a0d..7406d43880b 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/dialogs/scheduling/SchedulingDialogUiTest.kt @@ -1,10 +1,13 @@ package org.dhis2.usescases.teidashboard.dialogs.scheduling import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.isSelectable import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -34,7 +37,11 @@ class SchedulingDialogUiTest : BaseTest() { val composeTestRule = createAndroidComposeRule() private val viewModel: SchedulingViewModel = mock() - private val enrollment = Enrollment.builder().uid("enrollmentUid").build() + private val enrollment = Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() private val overdueSubtitle = "Overdue subtitle" @@ -145,7 +152,14 @@ class SchedulingDialogUiTest : BaseTest() { ) { } } - composeTestRule.onNodeWithText("No").performClick() + composeTestRule.waitForIdle() + composeTestRule + .onAllNodes( + matcher = hasAnyAncestor(hasTestTag("YES_NO_OPTIONS")) and isSelectable(), + useUnmergedTree = true, + ) + .onLast() + .performClick() composeTestRule.onNodeWithText("Program stage").assertDoesNotExist() composeTestRule.onNodeWithText("Date").assertDoesNotExist() composeTestRule.onNodeWithText("CatCombo *").assertDoesNotExist() diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt index 1941f23e3db..1a95d437aa5 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EnrollmentRobot.kt @@ -40,9 +40,15 @@ fun enrollmentRobot( class EnrollmentRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { + @OptIn(ExperimentalTestApi::class) fun clickOnAProgramForEnrollment(composeTestRule: ComposeTestRule, program: String) { + val testTag = PROGRAM_TO_ENROLL.format(program) composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag(PROGRAM_TO_ENROLL.format(program), useUnmergedTree = true) + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag(testTag), + TIMEOUT + ) + composeTestRule.onNodeWithTag(testTag, useUnmergedTree = true) .performClick() composeTestRule.waitForIdle() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt index b42e272c0ad..cb6475cdba3 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/EventRobot.kt @@ -3,17 +3,11 @@ package org.dhis2.usescases.teidashboard.robot import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.hasAnyAncestor -import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextReplacement import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches @@ -52,63 +46,26 @@ class EventRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithTag(MAIN_BUTTON_TAG).performClick() } - fun checkSecondaryButtonNotVisible() { - composeTestRule.onNodeWithTag(SECONDARY_BUTTON_TAG).assertDoesNotExist() - } - @OptIn(ExperimentalTestApi::class) fun clickOnReopen() { composeTestRule.waitUntilAtLeastOneExists(hasTestTag("REOPEN_BUTTON")) composeTestRule.onNodeWithTag("REOPEN_BUTTON", useUnmergedTree = true).performClick() } - fun acceptUpdateEventDate() { - composeTestRule.onNodeWithText("OK", true).performClick() - } - fun openMenuMoreOptions() { - onView(withId(R.id.moreOptions)).perform(click()) + waitForView(withId(R.id.moreOptions)).perform(click()) } fun clickOnDelete() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val deleteLabel = getString(R.string.delete) composeTestRule.onNodeWithText(deleteLabel).performClick() + composeTestRule.waitForIdle() } } fun clickOnDeleteDialog() { - onView(withId(R.id.possitive)).perform(click()) - } - - fun clickOnEventDueDate() { - composeTestRule.onNode( - hasTestTag("INPUT_DATE_TIME_ACTION_BUTTON") and hasAnySibling( - hasText("Due date") - ) - ).assertIsDisplayed().performClick() - - } - - fun selectSpecificDate(currentDate: String, date: String) { - composeTestRule.onNodeWithTag("DATE_PICKER").assertIsDisplayed() - composeTestRule.onNodeWithContentDescription( - label = "text", - substring = true, - useUnmergedTree = true, - ).performClick() - composeTestRule.onNode( - hasText(currentDate) and hasAnyAncestor(isDialog()) - ).performTextReplacement(date) - } - - @OptIn(ExperimentalTestApi::class) - fun typeOnDateParameter(dateValue: String) { - composeTestRule.waitUntilAtLeastOneExists(hasTestTag("INPUT_DATE_TIME_TEXT_FIELD"),2000) - composeTestRule.apply { - onNodeWithTag("INPUT_DATE_TIME_TEXT_FIELD").performClick() - onNodeWithTag("INPUT_DATE_TIME_TEXT_FIELD").performTextReplacement(dateValue) - } + waitForView(withId(R.id.possitive)).perform(click()) } fun checkEventDetails(eventDate: String, eventOrgUnit: String) { @@ -123,10 +80,6 @@ class EventRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { Intents.intended(allOf(IntentMatchers.hasComponent(EventCaptureActivity::class.java.name))) } - fun openEventDetailsSection() { - composeTestRule.onNodeWithText("Event details").performClick() - } - fun checkEventIsOpen() { composeTestRule.onNodeWithTag("REOPEN_BUTTON").assertIsNotDisplayed() } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt index 53774a20689..0d951ca96ec 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/NoteRobot.kt @@ -1,8 +1,8 @@ package org.dhis2.usescases.teidashboard.robot -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.TypeTextAction import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent @@ -30,7 +30,7 @@ fun noteRobot(noteRobot: NoteRobot.() -> Unit) { class NoteRobot : BaseRobot() { fun clickOnFabAddNewNote() { - onView(withId(R.id.addNoteButton)).check(matches(isDisplayed())).perform(click()) + waitForView(withId(R.id.addNoteButton)).check(matches(isDisplayed())).perform(click()) } fun verifyNoteDetailActivityIsLaunched() { @@ -38,24 +38,24 @@ class NoteRobot : BaseRobot() { } fun typeNote(text: String) { - onView(withId(R.id.noteText)).perform(TypeTextAction(text)) + waitForView(withId(R.id.noteText)).perform(TypeTextAction(text)) closeKeyboard() } fun clickOnSaveButton() { - waitForView(withText(R.string.save)) + waitForView(allOf(withId(R.id.saveButton), withText(R.string.save)),5000) .check(matches(allOf(isDisplayed(), isEnabled()))) .perform(click()) } fun clickYesOnAlertDialog() { - waitForView(withText(R.string.yes)) + waitForView(withId(android.R.id.button1), waitMillis = DIALOG_WAIT_TIMEOUT_MS) .check(matches(isDisplayed())) .perform(click()) } fun checkNoteWasNotCreated(text: String) { - onView(withId(R.id.notes_recycler)).check( + waitForView(withId(R.id.notes_recycler), waitMillis = NOTES_WAIT_TIMEOUT_MS).check( matches( not( atPosition( @@ -68,28 +68,52 @@ class NoteRobot : BaseRobot() { } fun checkNewNoteWasCreated(text: String) { - waitForView(withId(R.id.notes_recycler)).check( - matches( - allOf( - isDisplayed(), - isNotEmpty(), - atPosition(0, hasDescendant(withText(text))) - ) - ) + // First wait for the RecyclerView to be visible + waitForView(withId(R.id.notes_recycler), waitMillis = NOTES_WAIT_TIMEOUT_MS) + .check(matches(isDisplayed())) + // Now check for the note content + waitForView( + allOf( + withId(R.id.notes_recycler), + isDisplayed(), + isNotEmpty(), + atPosition(0, hasDescendant(withText(text))) + ), + waitMillis = NOTES_WAIT_TIMEOUT_MS ) } fun clickOnClearButton() { waitForView(withText(R.string.clear)) .check(matches(allOf(isDisplayed(), isEnabled()))) - .perform(click()) + .perform(closeSoftKeyboard(), click()) } fun checkNoteDetails(user: String, noteText: String) { - waitForView(withId(R.id.notes_recycler)).check(matches(isDisplayed())) - waitForView(allOf(withId(R.id.storeBy), withEffectiveVisibility(Visibility.VISIBLE), withText(user))) + waitForView(withId(R.id.notes_recycler), waitMillis = NOTES_WAIT_TIMEOUT_MS) + .check(matches(isDisplayed())) + waitForView( + allOf( + withId(R.id.storeBy), + withEffectiveVisibility(Visibility.VISIBLE), + withText(user) + ), + waitMillis = NOTES_WAIT_TIMEOUT_MS, + ) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) - waitForView(allOf(withId(R.id.note_text), withEffectiveVisibility(Visibility.VISIBLE), withText(noteText))) + waitForView( + allOf( + withId(R.id.note_text), + withEffectiveVisibility(Visibility.VISIBLE), + withText(noteText) + ), + waitMillis = NOTES_WAIT_TIMEOUT_MS, + ) .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) } + + companion object { + private const val DIALOG_WAIT_TIMEOUT_MS = 10000 + private const val NOTES_WAIT_TIMEOUT_MS = 15000 + } } diff --git a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt index 5c1ae787770..7ffebe013fb 100644 --- a/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt +++ b/app/src/androidTest/java/org/dhis2/usescases/teidashboard/robot/TeiDashboardRobot.kt @@ -7,8 +7,6 @@ import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -17,7 +15,6 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItem import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers @@ -31,20 +28,14 @@ import androidx.test.platform.app.InstrumentationRegistry import org.dhis2.R import org.dhis2.common.BaseRobot import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.atPosition -import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.hasItem import org.dhis2.common.matchers.RecyclerviewMatchers.Companion.isNotEmpty -import org.dhis2.usescases.event.entity.EventStatusUIModel -import org.dhis2.usescases.event.entity.TEIProgramStagesUIModel -import org.dhis2.usescases.flow.teiFlow.entity.DateRegistrationUIModel import org.dhis2.usescases.programStageSelection.ProgramStageSelectionActivity import org.dhis2.usescases.programStageSelection.ProgramStageSelectionViewHolder -import org.dhis2.usescases.teiDashboard.dashboardfragments.teidata.teievents.EventViewHolder import org.dhis2.usescases.teiDashboard.ui.INFO_BAR_TEST_TAG import org.dhis2.usescases.teiDashboard.ui.TEST_ADD_EVENT_BUTTON import org.dhis2.usescases.teiDashboard.ui.TEST_ADD_EVENT_BUTTON_IN_TIMELINE import org.dhis2.usescases.teiDashboard.ui.model.InfoBarType import org.dhis2.usescases.teidashboard.entity.EnrollmentUIModel -import org.dhis2.usescases.teidashboard.entity.UpperEnrollmentUIModel import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.anyOf import org.hamcrest.CoreMatchers.equalTo @@ -58,6 +49,7 @@ fun teiDashboardRobot( } } +@OptIn(ExperimentalTestApi::class) class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun goToNotes() { @@ -66,7 +58,10 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { R.string.navigation_notes ) ).performClick() - waitToDebounce(500) + waitForView(withId(R.id.addNoteButton)) + // Wait for notes RecyclerView to be ready after navigation + waitForView(withId(R.id.notes_recycler), waitMillis = 10000) + composeTestRule.waitForIdle() } fun goToRelationships() { @@ -84,7 +79,7 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { R.string.navigation_analytics ) composeTestRule.waitUntilExactlyOneExists( - hasText(analyticsText,true), + hasText(analyticsText, true), TIMEOUT ) composeTestRule.onNodeWithText(analyticsText, useUnmergedTree = true).performClick() @@ -113,11 +108,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithText(title).performClick() } - fun clickOnEventWith(searchParam: String) { - composeTestRule.onAllNodesWithText(searchParam, useUnmergedTree = true).onFirst() - .performClick() - } - fun clickOnFab() { composeTestRule.onNodeWithTag(TEST_ADD_EVENT_BUTTON_IN_TIMELINE, useUnmergedTree = true) .performClick() @@ -144,11 +134,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { Intents.intended(allOf(IntentMatchers.hasComponent(ProgramStageSelectionActivity::class.java.name))) } - - fun clickOnReferralOption(oneTime: String) { - composeTestRule.onNodeWithText(oneTime).performClick() - } - fun clickOnReferralNextButton() { waitForView(withId(R.id.action_button)).perform(click()) } @@ -184,6 +169,10 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun clickOnMenuComplete() { with(InstrumentationRegistry.getInstrumentation().targetContext) { + composeTestRule.waitUntilExactlyOneExists( + hasText(getString(R.string.complete)), + TIMEOUT + ) composeTestRule.onNodeWithText(getString(R.string.complete)).performClick() } } @@ -199,22 +188,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { .assertDoesNotExist() } - fun clickOnShareButton() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - composeTestRule.onNodeWithText(getString(R.string.share)).performClick() - } - } - - fun clickOnNextQR() { - var qrLenght = 1 - - while (qrLenght < 8) { - waitForView(withId(R.id.next)) - onView(withId(R.id.next)).perform(click()) - qrLenght++ - } - } - fun clickOnMenuDeleteTEI() { with(InstrumentationRegistry.getInstrumentation().targetContext) { composeTestRule.onNodeWithText(getString(R.string.dashboard_menu_delete_person)) @@ -222,11 +195,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { } } - fun checkUpperInfo(upperInformation: UpperEnrollmentUIModel) { - onView(withId(R.id.org_unit)) - .check(matches(withText(upperInformation.orgUnit))) - } - fun clickOnSeeDetails() { waitForView(withId(R.id.editButton)) .perform(click()) @@ -238,7 +206,7 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { hasText( enrollmentUIModel.enrollmentDate, ) and hasAnySibling( - hasText("Enrollment date *"), + hasText("Date of enrollment *"), ), useUnmergedTree = true, ).assertIsDisplayed() @@ -270,17 +238,14 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { fun clickOnMenuProgramEnrollments() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val programSelectorLabel = getString(R.string.more_enrollments) + composeTestRule.waitUntilExactlyOneExists( + hasText(programSelectorLabel), + TIMEOUT + ) composeTestRule.onNodeWithText(programSelectorLabel).performClick() } } - fun checkEventWasCreatedAndClosed(eventName: String) { - composeTestRule.onNodeWithText(eventName).assertIsDisplayed() - val targetContext: Context = InstrumentationRegistry.getInstrumentation().targetContext - val viewOnlyText = targetContext.resources.getString(R.string.view_only) - composeTestRule.onNodeWithText(viewOnlyText).assertDoesNotExist() - } - fun clickOnMenuDeleteEnrollment() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val deleteEnrollmentLabel = getString(R.string.remove_from) @@ -292,6 +257,10 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val timelineLabel = getString(R.string.view_timeline) try { + composeTestRule.waitUntilExactlyOneExists( + hasText(timelineLabel), + TIMEOUT + ) composeTestRule.onNodeWithText(timelineLabel).performClick() } catch (e: NoMatchingViewException) { checkIfGroupedEventsIsVisible() @@ -299,19 +268,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { } } - fun clickOnReopen() { - with(InstrumentationRegistry.getInstrumentation().targetContext) { - val timelineLabel = getString(R.string.enrollment_reopen) - val eventLabel = resources.getQuantityString(R.plurals.event_label, 2) - val itemLabel = timelineLabel.format(eventLabel) - try { - onView(withText(itemLabel)).perform(click()) - } catch (e: NoMatchingViewException) { - checkIfGroupedEventsIsVisible() - } - } - } - private fun checkIfGroupedEventsIsVisible() { with(InstrumentationRegistry.getInstrumentation().targetContext) { val groupLabel = getString(R.string.group_by_stage) @@ -383,85 +339,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onAllNodes(hasText(viewOnlyText), useUnmergedTree = false) } - fun clickOnStageGroup(programStageName: String) { - composeTestRule.onNodeWithText(programStageName).performClick() - } - - fun clickOnEventGroupByStage(eventDate: String) { - onView(withId(R.id.tei_recycler)) - .perform( - actionOnItem( - hasDescendant( - allOf( - withText(eventDate), - withId(R.id.event_date), - ), - ), - click(), - ), - ) - } - - fun checkEventWasDeletedStageGroup(teiProgramStages: TEIProgramStagesUIModel) { - val firstProgramStage = teiProgramStages.programStage_first - val secondProgramStage = teiProgramStages.programStage_second - val thirdProgramStage = teiProgramStages.programStage_third - - onView(withId(R.id.tei_recycler)) - .check( - matches( - allOf( - hasItem( - allOf( - hasDescendant(withText(firstProgramStage.name)), - hasDescendant(withText(firstProgramStage.events)), - ), - ), - hasItem( - allOf( - hasDescendant(withText(secondProgramStage.name)), - hasDescendant(withText(secondProgramStage.events)), - ), - ), - hasItem( - allOf( - hasDescendant(withText(thirdProgramStage.name)), - hasDescendant(withText(thirdProgramStage.events)), - ), - ), - ), - ), - ) - } - - fun checkEventStateStageGroup(eventDetails: EventStatusUIModel) { - var status = R.drawable.ic_event_status_open - when (eventDetails.status) { - "Open" -> status = R.drawable.ic_event_status_open - "Overdue" -> status = R.drawable.ic_event_status_overdue - "Event Completed" -> status = R.drawable.ic_event_status_complete - "Skip" -> status = R.drawable.ic_event_status_skipped - "Schedule" -> status = R.drawable.ic_event_status_schedule - } - - onView(withId(R.id.tei_recycler)) - .check( - matches( - hasItem( - allOf( - hasDescendant(withText(eventDetails.date)), - hasDescendant(withText(eventDetails.orgUnit)), - hasDescendant(withTagValue(equalTo(status))), - ), - ), - ), - ) - } - - fun clickOnEventGroupByStageUsingDate(dueDate: String) { - composeTestRule.onNodeWithText(dueDate).performClick() - } - fun clickOnConfirmDeleteTEI() { composeTestRule.onNodeWithText("Delete").performClick() } @@ -470,16 +347,6 @@ class TeiDashboardRobot(val composeTestRule: ComposeTestRule) : BaseRobot() { composeTestRule.onNodeWithText("Remove").performClick() } - fun checkEnrollmentDate(enrollmentDate: DateRegistrationUIModel) { - composeTestRule.onNode( - hasText( - "Date of enrollment: 0${enrollmentDate.day}/0${enrollmentDate.month}/${enrollmentDate.year}", - true - ), - useUnmergedTree = true - ).assertIsDisplayed() - } - fun typeOnInputDateField(dateValue: String, title: String) { composeTestRule.apply { onNode( diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index caeb2a6c54c..2ee086aad09 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -19,7 +19,6 @@ android:icon="@mipmap/ic_launcher"> - \ No newline at end of file diff --git a/app/src/dhis2/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt b/app/src/dhis2/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt new file mode 100644 index 00000000000..746bee80950 --- /dev/null +++ b/app/src/dhis2/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt @@ -0,0 +1,33 @@ +package org.dhis2.usescases.main.domain + +import android.content.Context +import kotlinx.coroutines.suspendCancellableCoroutine +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.domain.model.DownloadMethod +import kotlin.coroutines.resume + +class DownloadNewVersion( + private val versionRepository: VersionRepository, +) : UseCase { + override suspend fun invoke(input: Context): Result = + try { + suspendCancellableCoroutine { continuation -> + versionRepository.download( + context = input, + onDownloadCompleted = { + continuation.resume(Result.success(DownloadMethod.File(it))) + }, + onDownloading = { + // no-op + }, + ) + continuation.invokeOnCancellation { + // If needed perform action on cancellation + } + } + } catch (e: DomainError) { + Result.failure(e) + } +} diff --git a/app/src/dhis2/java/org/dhis2/utils/granularsync/GranularSyncModule.kt b/app/src/dhis2/java/org/dhis2/utils/granularsync/GranularSyncModule.kt index 104cdc50a17..12ed81557f2 100644 --- a/app/src/dhis2/java/org/dhis2/utils/granularsync/GranularSyncModule.kt +++ b/app/src/dhis2/java/org/dhis2/utils/granularsync/GranularSyncModule.kt @@ -13,6 +13,8 @@ import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisProgramUtils import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.utils.granularsync.data.GranularSyncRepository +import org.dhis2.utils.granularsync.ui.SyncUiStateMapper import org.hisp.dhis.android.core.D2 @Module @@ -21,6 +23,10 @@ class GranularSyncModule( private val view: GranularSyncContracts.View, private val syncContext: SyncContext, ) { + @Provides + fun provideSyncUiStateMapper(resourceManager: ResourceManager): SyncUiStateMapper = + SyncUiStateMapper(syncContext, resourceManager) + @Provides fun providesViewModelFactory( d2: D2, @@ -28,6 +34,7 @@ class GranularSyncModule( workManagerController: WorkManagerController, smsSyncProvider: SMSSyncProvider, repository: GranularSyncRepository, + mapper: SyncUiStateMapper, ): GranularSyncViewModelFactory = GranularSyncViewModelFactory( d2, @@ -38,6 +45,7 @@ class GranularSyncModule( syncContext, workManagerController, smsSyncProvider, + mapper, ) @Provides diff --git a/app/src/dhis2PlayServices/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt b/app/src/dhis2PlayServices/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt new file mode 100644 index 00000000000..df2258b4d61 --- /dev/null +++ b/app/src/dhis2PlayServices/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt @@ -0,0 +1,21 @@ +package org.dhis2.usescases.main.domain + +import android.content.Context +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.domain.model.DownloadMethod + +class DownloadNewVersion( + private val versionRepository: VersionRepository, +) : UseCase { + override suspend fun invoke(input: Context): Result = + try { + val url = versionRepository.getUrl() + url?.let { + Result.success(DownloadMethod.Url(it)) + } ?: Result.failure(DomainError.UnexpectedError("No url provided")) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/dhis2PlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt b/app/src/dhis2PlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt index ab5d88c7950..f19be870fc6 100644 --- a/app/src/dhis2PlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt +++ b/app/src/dhis2PlayServices/java/org/dhis2/utils/granularsync/GranularSyncModule.kt @@ -38,6 +38,8 @@ import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisProgramUtils import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.utils.granularsync.data.GranularSyncRepository +import org.dhis2.utils.granularsync.ui.SyncUiStateMapper import org.hisp.dhis.android.core.D2 @Module @@ -46,6 +48,10 @@ class GranularSyncModule( private val view: GranularSyncContracts.View, private val syncContext: SyncContext, ) { + @Provides + fun provideSyncUiStateMapper(resourceManager: ResourceManager): SyncUiStateMapper = + SyncUiStateMapper(syncContext, resourceManager) + @Provides fun providesViewModelFactory( d2: D2, @@ -53,6 +59,7 @@ class GranularSyncModule( workManagerController: WorkManagerController, smsSyncProvider: SMSSyncProvider, repository: GranularSyncRepository, + mapper: SyncUiStateMapper, ): GranularSyncViewModelFactory = GranularSyncViewModelFactory( d2, @@ -69,6 +76,7 @@ class GranularSyncModule( syncContext, workManagerController, smsSyncProvider, + mapper, ) @Provides diff --git a/app/src/dhis2Training/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt b/app/src/dhis2Training/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt new file mode 100644 index 00000000000..746bee80950 --- /dev/null +++ b/app/src/dhis2Training/java/org/dhis2/usescases/main/domain/DownloadNewVersion.kt @@ -0,0 +1,33 @@ +package org.dhis2.usescases.main.domain + +import android.content.Context +import kotlinx.coroutines.suspendCancellableCoroutine +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.domain.model.DownloadMethod +import kotlin.coroutines.resume + +class DownloadNewVersion( + private val versionRepository: VersionRepository, +) : UseCase { + override suspend fun invoke(input: Context): Result = + try { + suspendCancellableCoroutine { continuation -> + versionRepository.download( + context = input, + onDownloadCompleted = { + continuation.resume(Result.success(DownloadMethod.File(it))) + }, + onDownloading = { + // no-op + }, + ) + continuation.invokeOnCancellation { + // If needed perform action on cancellation + } + } + } catch (e: DomainError) { + Result.failure(e) + } +} diff --git a/app/src/dhis2Training/java/org/dhis2/utils/granularsync/GranularSyncModule.kt b/app/src/dhis2Training/java/org/dhis2/utils/granularsync/GranularSyncModule.kt index 104cdc50a17..12ed81557f2 100644 --- a/app/src/dhis2Training/java/org/dhis2/utils/granularsync/GranularSyncModule.kt +++ b/app/src/dhis2Training/java/org/dhis2/utils/granularsync/GranularSyncModule.kt @@ -13,6 +13,8 @@ import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisProgramUtils import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.utils.granularsync.data.GranularSyncRepository +import org.dhis2.utils.granularsync.ui.SyncUiStateMapper import org.hisp.dhis.android.core.D2 @Module @@ -21,6 +23,10 @@ class GranularSyncModule( private val view: GranularSyncContracts.View, private val syncContext: SyncContext, ) { + @Provides + fun provideSyncUiStateMapper(resourceManager: ResourceManager): SyncUiStateMapper = + SyncUiStateMapper(syncContext, resourceManager) + @Provides fun providesViewModelFactory( d2: D2, @@ -28,6 +34,7 @@ class GranularSyncModule( workManagerController: WorkManagerController, smsSyncProvider: SMSSyncProvider, repository: GranularSyncRepository, + mapper: SyncUiStateMapper, ): GranularSyncViewModelFactory = GranularSyncViewModelFactory( d2, @@ -38,6 +45,7 @@ class GranularSyncModule( syncContext, workManagerController, smsSyncProvider, + mapper, ) @Provides diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd346b3e6c1..ab678935476 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + + + + + + + @@ -85,7 +94,6 @@ - @@ -145,8 +153,7 @@ + android:foregroundServiceType="dataSync" /> + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/App.java b/app/src/main/java/org/dhis2/App.java index ddf0b0cac60..d1fc1043f4e 100644 --- a/app/src/main/java/org/dhis2/App.java +++ b/app/src/main/java/org/dhis2/App.java @@ -11,8 +11,6 @@ import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.OnLifecycleEvent; import androidx.lifecycle.ProcessLifecycleOwner; -import androidx.multidex.MultiDex; -import androidx.multidex.MultiDexApplication; import org.dhis2.commons.di.dagger.PerActivity; import org.dhis2.commons.di.dagger.PerServer; @@ -47,8 +45,6 @@ import org.dhis2.usescases.teiDashboard.TeiDashboardModule; import org.dhis2.utils.analytics.AnalyticsModule; import org.dhis2.utils.granularsync.SyncStatusDialogProvider; -import org.dhis2.utils.session.PinModule; -import org.dhis2.utils.session.SessionComponent; import org.dhis2.utils.timber.DebugTree; import org.hisp.dhis.android.core.D2Manager; import org.hisp.dhis.android.core.datastore.KeyValuePair; @@ -71,7 +67,7 @@ import io.sentry.android.core.SentryAndroid; import timber.log.Timber; -public class App extends MultiDexApplication implements Components, LifecycleObserver { +public class App extends android.app.Application implements Components, LifecycleObserver { @NonNull @Singleton @@ -89,9 +85,6 @@ public class App extends MultiDexApplication implements Components, LifecycleObs @PerActivity private TeiDashboardComponent dashboardComponent; - @Nullable - private SessionComponent sessionComponent; - private boolean fromBackGround = false; private boolean recreated; @@ -120,6 +113,10 @@ public void onCreate() { public void initCrashController() { if (areTrackingPermissionGranted()) { + if (BuildConfig.SENTRY_DSN.isEmpty()) { + Timber.w("Sentry DSN is empty. Skipping Sentry initialization."); + return; + } SentryAndroid.init(this, options -> { options.setDsn(BuildConfig.SENTRY_DSN); options.setAnrReportInDebug(true); @@ -154,12 +151,6 @@ private void initCustomCrashActivity() { .apply(); } - @Override - protected void attachBaseContext(Context base) { - super.attachBaseContext(base); - MultiDex.install(this); - } - private void setUpAppComponent() { appComponent = prepareAppComponent().build(); appComponent.inject(this); @@ -278,15 +269,6 @@ public void releaseDashboardComponent() { } } - @NotNull - public SessionComponent createSessionComponent(PinModule pinModule) { - return (sessionComponent = userComponent.plus(pinModule)); - } - - public void releaseSessionComponent() { - sessionComponent = null; - } - @OnLifecycleEvent(Lifecycle.Event.ON_STOP) public void onAppBackgrounded() { Timber.tag("BG").d("App in background"); diff --git a/app/src/main/java/org/dhis2/AppModule.kt b/app/src/main/java/org/dhis2/AppModule.kt index 0373cade8fa..77311e07784 100644 --- a/app/src/main/java/org/dhis2/AppModule.kt +++ b/app/src/main/java/org/dhis2/AppModule.kt @@ -4,6 +4,11 @@ import android.content.Context import dagger.Module import dagger.Provides import org.dhis2.commons.resources.ColorUtils +import org.dhis2.data.service.CheckVersionWorker +import org.dhis2.data.service.SyncGranularWorker +import org.dhis2.usescases.main.mainModule +import org.koin.androidx.workmanager.dsl.workerOf +import org.koin.dsl.module import javax.inject.Singleton @Module @@ -18,3 +23,10 @@ class AppModule( @Singleton fun colorUtils(): ColorUtils = ColorUtils() } + +val appModule = + module { + includes(mainModule) + workerOf(::SyncGranularWorker) + workerOf(::CheckVersionWorker) + } diff --git a/app/src/main/java/org/dhis2/bindings/Bindings.java b/app/src/main/java/org/dhis2/bindings/Bindings.java index 0354b034f25..60d0a67c159 100644 --- a/app/src/main/java/org/dhis2/bindings/Bindings.java +++ b/app/src/main/java/org/dhis2/bindings/Bindings.java @@ -5,11 +5,9 @@ import android.graphics.drawable.AnimatedVectorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; -import android.os.Build; import android.util.TypedValue; import android.view.View; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.TextView; import androidx.appcompat.content.res.AppCompatResources; @@ -168,13 +166,6 @@ public static void setImageBackground(ImageView imageView, Drawable drawable) { } - @BindingAdapter("versionVisibility") - public static void setVisibility(LinearLayout linearLayout, boolean check) { - if (check && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - linearLayout.setVisibility(View.GONE); - } - } - @BindingAdapter("settingIcon") public static void setSettingIcon(ImageView view, int drawableReference) { Drawable drawable = AppCompatResources.getDrawable(view.getContext(), drawableReference); diff --git a/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt b/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt index bafbb9a0aba..66102244615 100644 --- a/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt +++ b/app/src/main/java/org/dhis2/bindings/SettingExtensions.kt @@ -15,6 +15,7 @@ const val MANUAL = 0 fun MetadataSyncPeriod.toSeconds(): Int = when (this) { MetadataSyncPeriod.EVERY_HOUR -> EVERY_HOUR + MetadataSyncPeriod.EVERY_6_HOURS -> EVERY_6_HOUR MetadataSyncPeriod.EVERY_12_HOURS -> EVERY_12_HOUR MetadataSyncPeriod.EVERY_24_HOURS -> EVERY_24_HOUR MetadataSyncPeriod.EVERY_7_DAYS -> EVERY_7_DAYS diff --git a/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt b/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt index 47627d21950..781e1485952 100644 --- a/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt +++ b/app/src/main/java/org/dhis2/data/biometric/BiometricUtils.kt @@ -4,7 +4,6 @@ import android.content.Context import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import androidx.annotation.RequiresApi import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat @@ -93,27 +92,19 @@ class CryptographyManager : CryptographicActions { load(null) } - override fun getInitializedCipherForEncryption(): Cipher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val cipher = getCipher() - val secretKey = - getOrCreateSecretKey() - - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - cipher - } else { - null - } + override fun getInitializedCipherForEncryption(): Cipher { + val cipher = getCipher() + val secretKey = getOrCreateSecretKey() + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + return cipher + } - override fun getInitializedCipherForDecryption(initializationVector: ByteArray): Cipher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val cipher = getCipher() - val secretKey = getOrCreateSecretKey() - cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector)) - cipher - } else { - null - } + override fun getInitializedCipherForDecryption(initializationVector: ByteArray): Cipher { + val cipher = getCipher() + val secretKey = getOrCreateSecretKey() + cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector)) + return cipher + } override fun encryptData( plaintext: String, @@ -136,7 +127,6 @@ class CryptographyManager : CryptographicActions { return Cipher.getInstance(transformation) } - @RequiresApi(Build.VERSION_CODES.M) private fun getOrCreateSecretKey(): SecretKey { // If Secretkey was previously created for that keyName, then grab and return it. keyStore.getKey(KEY_NAME, null)?.let { return it as SecretKey } diff --git a/app/src/main/java/org/dhis2/data/dhislogic/Authorities.kt b/app/src/main/java/org/dhis2/data/dhislogic/Authorities.kt index ed6318c7bec..352f3e33686 100644 --- a/app/src/main/java/org/dhis2/data/dhislogic/Authorities.kt +++ b/app/src/main/java/org/dhis2/data/dhislogic/Authorities.kt @@ -3,3 +3,5 @@ package org.dhis2.data.dhislogic const val AUTH_DATAVALUE_ADD = "F_DATAVALUE_ADD" const val AUTH_UNCOMPLETE_EVENT = "F_UNCOMPLETE_EVENT" const val AUTH_ALL = "ALL" +const val AUTH_ENROLLMENT_CASCADE_DELETE = "F_ENROLLMENT_CASCADE_DELETE" +const val AUTH_TEI_CASCADE_DELETE = "F_TEI_CASCADE_DELETE" diff --git a/app/src/main/java/org/dhis2/data/enrollment/EnrollmentUiDataHelper.kt b/app/src/main/java/org/dhis2/data/enrollment/EnrollmentUiDataHelper.kt index b50840c9e0d..1902c4ec288 100644 --- a/app/src/main/java/org/dhis2/data/enrollment/EnrollmentUiDataHelper.kt +++ b/app/src/main/java/org/dhis2/data/enrollment/EnrollmentUiDataHelper.kt @@ -2,7 +2,7 @@ package org.dhis2.data.enrollment import android.content.Context import org.dhis2.R -import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.dhis2.tracker.search.model.EnrollmentStatus class EnrollmentUiDataHelper( val context: Context, diff --git a/app/src/main/java/org/dhis2/data/forms/dataentry/ProgramAdapter.java b/app/src/main/java/org/dhis2/data/forms/dataentry/ProgramAdapter.java index 3f9acf0e0ff..259da33a460 100644 --- a/app/src/main/java/org/dhis2/data/forms/dataentry/ProgramAdapter.java +++ b/app/src/main/java/org/dhis2/data/forms/dataentry/ProgramAdapter.java @@ -1,19 +1,18 @@ package org.dhis2.data.forms.dataentry; import android.content.Context; -import androidx.databinding.DataBindingUtil; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.databinding.DataBindingUtil; + import org.dhis2.R; +import org.dhis2.databinding.SpinnerProgramItemLayoutBinding; import org.dhis2.databinding.SpinnerProgramLayoutBinding; - import org.dhis2.usescases.searchTrackEntity.ProgramSpinnerModel; import java.util.List; @@ -38,8 +37,6 @@ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup SpinnerProgramLayoutBinding binding = DataBindingUtil.inflate(inflater, R.layout.spinner_program_layout, parent, false); convertView = binding.getRoot(); setProgram(binding, position); - binding.spinnerText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20); - binding.spinnerText.setTextColor(ContextCompat.getColor(binding.spinnerText.getContext(), R.color.white_faf)); } return convertView; @@ -48,7 +45,7 @@ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup @Override public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - SpinnerProgramLayoutBinding binding = DataBindingUtil.inflate(inflater, R.layout.spinner_program_layout, parent, false); + SpinnerProgramItemLayoutBinding binding = DataBindingUtil.inflate(inflater, R.layout.spinner_program_item_layout, parent, false); setProgram(binding, position); return binding.getRoot(); } @@ -65,6 +62,18 @@ private void setProgram(SpinnerProgramLayoutBinding binding, int position) { } } + private void setProgram(SpinnerProgramItemLayoutBinding binding, int position) { + if (position != 0) { + binding.setProgram(programs.get(position - 1)); + } else { + binding.setProgram(new ProgramSpinnerModel( + trackedEntityName, + String.format(getContext().getString(R.string.all_tei_type), trackedEntityName), + false + )); + } + } + @Override public int getCount() { return super.getCount() + 1; diff --git a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt index 337621dc51a..60faa114c38 100644 --- a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt +++ b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStore.kt @@ -15,15 +15,6 @@ interface ValueStore { value: String?, ): Flowable - suspend fun save( - orgUnitUid: String, - periodId: String, - attributeOptionComboUid: String, - dataElementUid: String, - categoryOptionComboUid: String, - value: String?, - ): Flowable - fun deleteOptionValues(optionCodeValuesToDelete: List) fun deleteOptionValueIfSelected( diff --git a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt index 523c7b362cc..a6203eadaf0 100644 --- a/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt +++ b/app/src/main/java/org/dhis2/data/forms/dataentry/ValueStoreImpl.kt @@ -1,39 +1,52 @@ package org.dhis2.data.forms.dataentry import io.reactivex.Flowable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import org.dhis2.bindings.blockingSetCheck import org.dhis2.bindings.withValueTypeCheck import org.dhis2.commons.data.EntryMode -import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.dhis2.form.R import org.dhis2.form.model.StoreResult import org.dhis2.form.model.ValueStoreResult -import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.utils.DhisTextUtils import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.FileResizerHelper import org.hisp.dhis.android.core.arch.helpers.Result import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.datavalue.LegacyDataValueApi import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository import java.io.File +@OptIn(LegacyDataValueApi::class) class ValueStoreImpl( private val d2: D2, private val recordUid: String, private val entryMode: EntryMode, private val dhisEnrollmentUtils: DhisEnrollmentUtils, private val crashReportController: CrashReportController, - private val networkUtils: NetworkUtils, private val searchTEIRepository: SearchTEIRepository, - private val fieldErrorMessageProvider: FieldErrorMessageProvider, private val resourceManager: ResourceManager, + networkStatusProvider: NetworkStatusProvider, + dispatcherProvider: DispatcherProvider, ) : ValueStore { var enrollmentRepository: EnrollmentObjectRepository? = null var overrideProgramUid: String? = null + private val isNetworkOnline = + networkStatusProvider.connectionStatus + .stateIn( + CoroutineScope(dispatcherProvider.io()), + SharingStarted.Eagerly, + false, + ) + override fun overrideProgram(programUid: String?) { overrideProgramUid = programUid } @@ -65,76 +78,6 @@ class ValueStoreImpl( ) } - override suspend fun save( - orgUnitUid: String, - periodId: String, - attributeOptionComboUid: String, - dataElementUid: String, - categoryOptionComboUid: String, - value: String?, - ): Flowable { - val dataValueObject = - d2.dataValueModule().dataValues().value( - periodId, - orgUnitUid, - dataElementUid, - categoryOptionComboUid, - attributeOptionComboUid, - ) - - val validator = - d2 - .dataElementModule() - .dataElements() - .uid(dataElementUid) - .blockingGet() - ?.valueType() - ?.validator - - return if (!value.isNullOrEmpty()) { - if (dataValueObject.blockingExists() && - dataValueObject.blockingGet()?.value() == value - ) { - Flowable.just(StoreResult("", ValueStoreResult.VALUE_HAS_NOT_CHANGED)) - } else { - when (val validation = validator?.validate(value)) { - is Result.Failure -> - Flowable.just( - StoreResult( - uid = "", - valueStoreResult = ValueStoreResult.ERROR_UPDATING_VALUE, - valueStoreResultMessage = - fieldErrorMessageProvider - .getFriendlyErrorMessage(validation.failure), - ), - ) - is Result.Success -> - dataValueObject - .set(value) - .andThen(Flowable.just(StoreResult("", ValueStoreResult.VALUE_CHANGED))) - else -> - Flowable.just( - StoreResult( - uid = "", - valueStoreResult = ValueStoreResult.ERROR_UPDATING_VALUE, - valueStoreResultMessage = - fieldErrorMessageProvider - .defaultValidationErrorMessage(), - ), - ) - } - } - } else { - if (dataValueObject.blockingExists()) { - dataValueObject - .deleteIfExist() - .andThen(Flowable.just(StoreResult("", ValueStoreResult.VALUE_CHANGED))) - } else { - Flowable.just(StoreResult("", ValueStoreResult.VALUE_HAS_NOT_CHANGED)) - } - } - } - override fun saveWithTypeCheck( uid: String, value: String?, @@ -146,12 +89,14 @@ class ValueStoreImpl( .uid(uid) .blockingExists() -> saveDataElement(uid, value) + d2 .trackedEntityModule() .trackedEntityAttributes() .uid(uid) .blockingExists() -> saveAttribute(uid, value) + else -> Flowable.just(StoreResult(uid, ValueStoreResult.UID_IS_NOT_DE_OR_ATTR)) } @@ -176,6 +121,7 @@ class ValueStoreImpl( .blockingGet() enrollment?.trackedEntityInstance() } + EntryMode.ATTR -> recordUid EntryMode.DV -> null } @@ -297,7 +243,7 @@ class ValueStoreImpl( value: String?, teiUid: String, ): Boolean = - if (!networkUtils.isOnline()) { + if (!isNetworkOnline.value) { dhisEnrollmentUtils.isTrackedEntityAttributeValueUnique(uid, value, teiUid) } else { val programUid = overrideProgramUid ?: enrollmentRepository?.blockingGet()?.program() @@ -381,6 +327,7 @@ class ValueStoreImpl( deleteOptionValuesForEnrollment( optionCodeValuesToDelete, ) + EntryMode.DV, -> throw IllegalArgumentException( "DataValues can't be saved using these arguments. Use the other one.", diff --git a/app/src/main/java/org/dhis2/data/server/SSLContextInitializer.kt b/app/src/main/java/org/dhis2/data/server/SSLContextInitializer.kt index 648fc44d02e..00aa4ede100 100644 --- a/app/src/main/java/org/dhis2/data/server/SSLContextInitializer.kt +++ b/app/src/main/java/org/dhis2/data/server/SSLContextInitializer.kt @@ -17,7 +17,7 @@ object SSLContextInitializer { } try { Security.insertProviderAt(Conscrypt.newProvider(), 1) - } catch (e: Exception) { + } catch (e: Throwable) { Timber.e(SSLContextInitializer::class.java.simpleName, e.toString()) } } diff --git a/app/src/main/java/org/dhis2/data/server/ServerModule.kt b/app/src/main/java/org/dhis2/data/server/ServerModule.kt index 1021d00743e..0b2d8395d9b 100644 --- a/app/src/main/java/org/dhis2/data/server/ServerModule.kt +++ b/app/src/main/java/org/dhis2/data/server/ServerModule.kt @@ -5,7 +5,9 @@ import android.content.ContextWrapper import dagger.Module import dagger.Provides import dhis2.org.analytics.charts.Charts -import dhis2.org.analytics.charts.DhisAnalyticCharts +import dhis2.org.analytics.charts.di.ChartsComponent +import dhis2.org.analytics.charts.di.DaggerChartsComponent +import dhis2.org.analytics.charts.domain.GetEnrollmentAnalyticsUseCase import okhttp3.Interceptor import org.dhis2.BuildConfig import org.dhis2.R @@ -21,8 +23,6 @@ import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider -import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.service.SyncStatusController import org.dhis2.data.service.VersionRepository import org.dhis2.form.data.OptionsRepository import org.dhis2.form.data.RulesUtilsProvider @@ -83,7 +83,20 @@ class ServerModule { @Provides @PerServer - fun provideCharts(serverComponent: ServerComponent): Charts = DhisAnalyticCharts.Provider.get(serverComponent) + fun provideChartsComponent(serverComponent: ServerComponent): ChartsComponent = + DaggerChartsComponent + .builder() + .dependencies(serverComponent) + .build() + + @Provides + @PerServer + fun provideCharts(chartsComponent: ChartsComponent): Charts = chartsComponent.charts() + + @Provides + @PerServer + fun provideGetEnrollmentAnalyticsUseCase(chartsComponent: ChartsComponent): GetEnrollmentAnalyticsUseCase = + chartsComponent.getEnrollmentAnalyticsUseCase() @Provides @PerServer @@ -104,7 +117,10 @@ class ServerModule { @Provides @PerServer - fun providesRepository(d2: D2): ServerSettingsRepository = ServerSettingsRepository(d2) + fun providesRepository( + d2: D2, + colorUtils: ColorUtils, + ): ServerSettingsRepository = ServerSettingsRepository(d2, colorUtils) @Provides @PerServer @@ -123,11 +139,6 @@ class ServerModule { colorUtils, ) - @Provides - @PerServer - fun providesSyncStatusController(dispatcherProvider: DispatcherProvider): SyncStatusController = - SyncStatusController(dispatcherProvider) - @Provides @PerServer fun providesVersionStatusController(d2: D2): VersionRepository = VersionRepository(d2) diff --git a/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt b/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt index 735da2d5033..92bfcd7b3b5 100644 --- a/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt +++ b/app/src/main/java/org/dhis2/data/server/ServerSettingsRepository.kt @@ -2,11 +2,18 @@ package org.dhis2.data.server import io.reactivex.Single import org.dhis2.BuildConfig +import org.dhis2.R +import org.dhis2.commons.resources.ColorUtils +import org.dhis2.commons.resources.paletteThemes +import org.dhis2.mobile.commons.color.ColorMatcher +import org.dhis2.mobile.commons.color.PaletteColor import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.settings.SystemSetting +import timber.log.Timber class ServerSettingsRepository( private val d2: D2, + private val colorUtils: ColorUtils, ) { fun getTheme(): Single> = d2 @@ -14,19 +21,49 @@ class ServerSettingsRepository( .systemSetting() .get() .map { systemSettings -> - val style = + val customColor = systemSettings .firstOrNull { - it.key() == SystemSetting.SystemSettingKey.STYLE + it.key() == SystemSetting.SystemSettingKey.CUSTOM_COLOR }?.value() val flag = systemSettings .firstOrNull { it.key() == SystemSetting.SystemSettingKey.FLAG }?.value() - Pair(flag, SystemStyleMapper(style)) + if (customColor.isNullOrEmpty()) { + Pair(flag, R.style.AppTheme) + } else { + try { + val customColorPalette = PaletteColor.fromHex(customColor) + val closestColor = + ColorMatcher.findClosest( + selectedR = customColorPalette.r, + selectedG = customColorPalette.g, + selectedB = customColorPalette.b, + palette = + paletteThemes.map { (color, _) -> + PaletteColor.fromHex(color) + }, + ) + Pair(flag, getThemeFromClosestColor(closestColor)) + } catch (e: Exception) { + Timber.e(e, "Error parsing custom color, using default theme") + Pair(flag, R.style.AppTheme) + } + } } + private fun getThemeFromClosestColor(color: PaletteColor?): Int = + color?.let { + val serverTheme = colorUtils.getThemeFromColor(it.hex) + if (serverTheme != -1) { + serverTheme + } else { + R.style.AppTheme + } + } ?: R.style.AppTheme + fun allowScreenShare(): Boolean = BuildConfig.DEBUG || d2 diff --git a/app/src/main/java/org/dhis2/data/server/SystemStyleMapper.kt b/app/src/main/java/org/dhis2/data/server/SystemStyleMapper.kt deleted file mode 100644 index a00ceaede9b..00000000000 --- a/app/src/main/java/org/dhis2/data/server/SystemStyleMapper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.dhis2.data.server - -import org.dhis2.R - -object SystemStyleMapper { - private const val SERVER_GREEN_THEME = "green" - private const val SERVER_INDIA_THEME = "india" - private const val SERVER_MYANMAR_THEME = "myanmar" - - operator fun invoke(serverStyle: String?): Int = - when { - serverStyle?.contains(SERVER_GREEN_THEME) == true -> R.style.GreenTheme - serverStyle?.contains(SERVER_INDIA_THEME) == true -> R.style.OrangeTheme - serverStyle?.contains(SERVER_MYANMAR_THEME) == true -> R.style.RedTheme - else -> R.style.AppTheme - } -} diff --git a/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt b/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt index 62ba41d5c77..531d10c6f03 100644 --- a/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt +++ b/app/src/main/java/org/dhis2/data/service/CheckVersionWorker.kt @@ -1,20 +1,17 @@ package org.dhis2.data.service import android.content.Context -import androidx.work.Worker +import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import org.dhis2.usescases.main.MainPresenter -import javax.inject.Inject class CheckVersionWorker( context: Context, workerParams: WorkerParameters, -) : Worker(context, workerParams) { - @Inject - internal lateinit var presenter: MainPresenter + private val versionRepository: VersionRepository, +) : CoroutineWorker(context, workerParams) { - override fun doWork(): Result { - presenter.checkVersionUpdate() + override suspend fun doWork(): Result { + versionRepository.checkVersionUpdates() return Result.success() } } diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java b/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java deleted file mode 100644 index ebb30c9b4e5..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorker.java +++ /dev/null @@ -1,206 +0,0 @@ -package org.dhis2.data.service; - -import static org.dhis2.utils.analytics.AnalyticsConstants.DATA_TIME; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; -import android.content.pm.ServiceInfo; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.work.Data; -import androidx.work.ForegroundInfo; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import org.dhis2.App; -import org.dhis2.R; -import org.dhis2.commons.Constants; -import org.dhis2.commons.date.DateUtils; -import org.dhis2.commons.network.NetworkUtils; -import org.dhis2.commons.prefs.PreferenceProvider; - -import java.util.Calendar; -import java.util.Objects; - -import javax.inject.Inject; - -import timber.log.Timber; - -public class SyncDataWorker extends Worker { - - private static final String DATA_CHANNEL = "sync_data_notification"; - private static final int SYNC_DATA_ID = 8071986; - - @Inject - SyncPresenter presenter; - - @Inject - PreferenceProvider prefs; - - public SyncDataWorker( - @NonNull Context context, - @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - Objects.requireNonNull(((App) getApplicationContext()).userComponent()) - .plus(new SyncDataWorkerModule()) - .inject(this); - - presenter.initSyncControllerMap(); - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_data), - 0); - - boolean isEventOk = true; - boolean isTeiOk = true; - boolean isDataValue = true; - - long init = System.currentTimeMillis(); - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_events), - 20); - - try { - presenter.syncAndDownloadEvents(); - } catch (Exception e) { - if (!new NetworkUtils(getApplicationContext()).isOnline()) { - presenter.setNetworkUnavailable(); - } - Timber.e(e); - isEventOk = false; - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_teis), - 40); - - try { - presenter.syncAndDownloadTeis(); - } catch (Exception e) { - if (!new NetworkUtils(getApplicationContext()).isOnline()) { - presenter.setNetworkUnavailable(); - } - Timber.e(e); - isTeiOk = false; - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_data_sets), - 60); - - try { - presenter.syncAndDownloadDataValues(); - } catch (Exception e) { - if (!new NetworkUtils(getApplicationContext()).isOnline()) { - presenter.setNetworkUnavailable(); - } - Timber.e(e); - isDataValue = false; - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_resources), - 80); - - try { - presenter.downloadResources(); - } catch (Exception e) { - Timber.e(e); - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - "syncing reserved values", - 95 - - ); - - try { - presenter.syncReservedValues(); - } catch (Exception e) { - Timber.e(e); - } - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_done), - 100); - - presenter.logTimeToFinish(System.currentTimeMillis() - init, DATA_TIME); - - String lastDataSyncDate = DateUtils.dateTimeFormat().format(Calendar.getInstance().getTime()); - SyncResult syncResult = presenter.checkSyncStatus(); - - prefs.setValue(Constants.LAST_DATA_SYNC, lastDataSyncDate); - prefs.setValue(Constants.LAST_DATA_SYNC_STATUS, isEventOk && isTeiOk && isDataValue && syncResult == SyncResult.SYNC); - prefs.setValue(Constants.SYNC_RESULT, syncResult.name()); - - cancelNotification(); - - presenter.startPeriodicDataWork(); - - presenter.finishSync(); - - return Result.success(createOutputData(true)); - } - - @Override - public void onStopped() { - cancelNotification(); - super.onStopped(); - } - - private Data createOutputData(boolean state) { - return new Data.Builder() - .putBoolean("DATA_STATE", state) - .build(); - } - - private void triggerNotification(String title, String content, int progress) { - NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel mChannel = new NotificationChannel(DATA_CHANNEL, "DataSync", NotificationManager.IMPORTANCE_HIGH); - notificationManager.createNotificationChannel(mChannel); - } - - NotificationCompat.Builder notificationBuilder = - new NotificationCompat.Builder(getApplicationContext(), DATA_CHANNEL) - .setSmallIcon(R.drawable.ic_sync) - .setContentTitle(title) - .setContentText(content) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setAutoCancel(false) - .setProgress(100, progress, false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT); - - setForegroundAsync(new ForegroundInfo( - SyncDataWorker.SYNC_DATA_ID, - notificationBuilder.build(), - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0 - )); - - } - - private void cancelNotification() { - NotificationManagerCompat notificationManager = - NotificationManagerCompat.from(getApplicationContext()); - notificationManager.cancel(SYNC_DATA_ID); - } -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerComponent.java b/app/src/main/java/org/dhis2/data/service/SyncDataWorkerComponent.java deleted file mode 100644 index 3938520a51d..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerComponent.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.data.service; - -import androidx.annotation.NonNull; - -import org.dhis2.commons.di.dagger.PerService; - -import dagger.Subcomponent; - -@PerService -@Subcomponent(modules = SyncDataWorkerModule.class) -public interface SyncDataWorkerComponent { - void inject(@NonNull SyncDataWorker syncDataWorker); -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt deleted file mode 100644 index 66758840961..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dhis2.data.service - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.di.dagger.PerService -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper -import org.hisp.dhis.android.core.D2 - -@Module -class SyncDataWorkerModule { - @Provides - @PerService - fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) - - @Provides - @PerService - internal fun syncPresenter( - d2: D2, - preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, - syncRepository: SyncRepository, - ): SyncPresenter = - SyncPresenterImpl( - d2, - preferences, - workManagerController, - analyticsHelper, - syncStatusController, - syncRepository, - ) -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt b/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt index 64dc7834207..3875821b4c7 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt @@ -4,12 +4,13 @@ import dagger.Module import dagger.Provides import org.dhis2.commons.di.dagger.PerService import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 @Module -class SyncGranularRxModule { +class SyncGranularRxModule( + private val syncStatusController: SyncStatusController, +) { @Provides @PerService fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) @@ -19,17 +20,12 @@ class SyncGranularRxModule { internal fun syncPresenter( d2: D2, preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, syncRepository: SyncRepository, ): SyncPresenter = SyncPresenterImpl( d2, preferences, - workManagerController, - analyticsHelper, - syncStatusController, syncRepository, + syncStatusController, ) } diff --git a/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt b/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt index e15ffa40ae0..6ce7392a572 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncGranularWorker.kt @@ -44,6 +44,7 @@ import org.dhis2.commons.Constants.ORG_UNIT import org.dhis2.commons.Constants.PERIOD_ID import org.dhis2.commons.Constants.UID import org.dhis2.commons.sync.ConflictType +import org.dhis2.mobile.sync.domain.SyncStatusController import javax.inject.Inject private const val GRANULAR_CHANNEL = "sync_granular_notification" @@ -52,6 +53,7 @@ private const val SYNC_GRANULAR_ID = 8071988 class SyncGranularWorker( context: Context, workerParams: WorkerParameters, + private val syncStatusController: SyncStatusController, ) : Worker(context, workerParams) { @Inject internal lateinit var presenter: SyncPresenter @@ -59,7 +61,7 @@ class SyncGranularWorker( override fun doWork(): Result { (applicationContext as App) .userComponent() - ?.plus(SyncGranularRxModule()) + ?.plus(SyncGranularRxModule(syncStatusController)) ?.inject(this) val uid = inputData.getString(UID) ?: return Result.failure() diff --git a/app/src/main/java/org/dhis2/data/service/SyncInitWorker.java b/app/src/main/java/org/dhis2/data/service/SyncInitWorker.java deleted file mode 100644 index f8ac513724b..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncInitWorker.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.dhis2.data.service; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import org.dhis2.App; - -import javax.inject.Inject; - -public class SyncInitWorker extends Worker { - - public static final String INIT_META = "INIT_META"; - public static final String INIT_DATA = "INIT_DATA"; - - @Inject - SyncPresenter presenter; - - public SyncInitWorker( - @NonNull Context context, - @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - - if (((App) getApplicationContext()).userComponent() != null) { - - ((App) getApplicationContext()).userComponent().plus(new SyncInitWorkerModule()).inject(this); - - if (getInputData().getBoolean(INIT_META, false)) - presenter.startPeriodicMetaWork(); - if (getInputData().getBoolean(INIT_DATA, false)) - presenter.startPeriodicDataWork(); - - return Result.success(); - } else { - return Result.failure(); - } - } - -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerComponent.java b/app/src/main/java/org/dhis2/data/service/SyncInitWorkerComponent.java deleted file mode 100644 index ea8097b8f1f..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerComponent.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.data.service; - -import androidx.annotation.NonNull; - -import org.dhis2.commons.di.dagger.PerService; - -import dagger.Subcomponent; - -@PerService -@Subcomponent(modules = SyncInitWorkerModule.class) -public interface SyncInitWorkerComponent { - void inject(@NonNull SyncInitWorker syncInitWorker); -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt deleted file mode 100644 index 68fbd402275..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dhis2.data.service - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.di.dagger.PerService -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper -import org.hisp.dhis.android.core.D2 - -@Module -class SyncInitWorkerModule { - @Provides - @PerService - fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) - - @Provides - @PerService - internal fun syncPresenter( - d2: D2, - preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, - syncRepository: SyncRepository, - ): SyncPresenter = - SyncPresenterImpl( - d2, - preferences, - workManagerController, - analyticsHelper, - syncStatusController, - syncRepository, - ) -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java deleted file mode 100644 index 2757c5bea89..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorker.java +++ /dev/null @@ -1,212 +0,0 @@ -package org.dhis2.data.service; - -import static org.dhis2.data.service.SyncOutputKt.METADATA_MESSAGE; -import static org.dhis2.data.service.SyncOutputKt.METADATA_STATE; -import static org.dhis2.utils.analytics.AnalyticsConstants.METADATA_TIME; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; -import android.content.pm.ServiceInfo; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.work.Data; -import androidx.work.ForegroundInfo; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import org.dhis2.App; -import org.dhis2.R; -import org.dhis2.commons.date.DateUtils; -import org.dhis2.commons.prefs.PreferenceProvider; -import org.dhis2.commons.resources.ResourceManager; -import org.dhis2.commons.Constants; -import org.dhis2.utils.NetworkUtils; -import org.hisp.dhis.android.core.maintenance.D2Error; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.io.Writer; -import java.util.Calendar; - -import javax.inject.Inject; - -import timber.log.Timber; - -public class SyncMetadataWorker extends Worker { - - private static final String METADATA_CHANNEL = "sync_metadata_notification"; - private static final int SYNC_METADATA_ID = 26061987; - - @Inject - SyncPresenter presenter; - - @Inject - PreferenceProvider prefs; - - @Inject - ResourceManager resourceManager; - - public SyncMetadataWorker( - @NonNull Context context, - @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - if (((App) getApplicationContext()).userComponent() != null) { - - ((App) getApplicationContext()).userComponent().plus(new SyncMetadataWorkerModule()).inject(this); - - triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_configuration), - 0); - - boolean isMetaOk = true; - boolean noNetwork = false; - StringBuilder message = new StringBuilder(""); - - long init = System.currentTimeMillis(); - try { - presenter.syncMetadata(progress -> triggerNotification( - getApplicationContext().getString(R.string.app_name), - getApplicationContext().getString(R.string.syncing_configuration), - progress)); - } catch (Exception e) { - Timber.e(e); - isMetaOk = false; - if (!NetworkUtils.isOnline(getApplicationContext())) - noNetwork = true; - if (e instanceof D2Error) { - D2Error error = (D2Error) e; - message.append(composeErrorMessageInfo(error)); - } else if (e.getCause() instanceof D2Error) { - D2Error error = (D2Error) e.getCause(); - message.append(composeErrorMessageInfo(error)); - } else { - message.append(e.toString().split("\n\t")[0]); - } - } finally { - presenter.logTimeToFinish(System.currentTimeMillis() - init, METADATA_TIME); - } - - String lastDataSyncDate = DateUtils.dateTimeFormat().format(Calendar.getInstance().getTime()); - - prefs.setValue(Constants.LAST_META_SYNC, lastDataSyncDate); - prefs.setValue(Constants.LAST_META_SYNC_STATUS, isMetaOk); - prefs.setValue(Constants.LAST_META_SYNC_NO_NETWORK, noNetwork); - - cancelNotification(); - - if (!isMetaOk) - return Result.failure(createOutputData(false, message.toString())); - - presenter.startPeriodicMetaWork(); - - return Result.success(createOutputData(true, message.toString())); - } else { - return Result.failure(createOutputData(false, getApplicationContext().getString(R.string.error_init_session))); - } - } - - @Override - public void onStopped() { - cancelNotification(); - super.onStopped(); - } - - private Data createOutputData(boolean state, String message) { - return new Data.Builder() - .putBoolean(METADATA_STATE, state) - .putString(METADATA_MESSAGE, message) - .build(); - } - - private String errorStackTrace(@Nullable Exception exception) { - if (exception == null) - return ""; - Writer writer = new StringWriter(); - exception.printStackTrace(new PrintWriter(writer)); - return writer.toString(); - } - - private StringBuilder composeErrorMessageInfo(D2Error error) { - StringBuilder builder = new StringBuilder("Cause: ") - .append(resourceManager.parseD2Error(error)) - .append("\n\n") - .append("Exception: ") - .append(errorStackTrace((error).originalException()).split("\n\t")[0]) - .append("\n\n"); - - if (error.created() != null) { - builder.append("Created: ") - .append(error.created().toString()) - .append("\n\n"); - } - - if (error.httpErrorCode() != null) { - builder.append("Http Error Code: ") - .append(error.httpErrorCode()) - .append("\n\n"); - } - - if (error.errorComponent() != null) { - builder.append("Error component: ") - .append(error.errorComponent()) - .append("\n\n"); - } - - if (error.url() != null) { - builder.append("Url: ") - .append(error.url()) - .append("\n\n"); - } - - builder.append("StackTrace: ") - .append(errorStackTrace(error).split("\n\t")[0]) - .append("\n\n"); - - return builder; - } - - private void triggerNotification(String title, String content, int progress) { - NotificationManager notificationManager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel mChannel = new NotificationChannel(METADATA_CHANNEL, "MetadataSync", NotificationManager.IMPORTANCE_HIGH); - notificationManager.createNotificationChannel(mChannel); - } - NotificationCompat.Builder notificationBuilder = - new NotificationCompat.Builder(getApplicationContext(), METADATA_CHANNEL) - .setSmallIcon(R.drawable.ic_sync) - .setContentTitle(title) - .setContentText(content) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setAutoCancel(false) - .setProgress(100, progress, false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT); - - setForegroundAsync(new ForegroundInfo( - SyncMetadataWorker.SYNC_METADATA_ID, - notificationBuilder.build(), - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0 - )); - } - - private void cancelNotification() { - NotificationManagerCompat notificationManager = - NotificationManagerCompat.from(getApplicationContext()); - notificationManager.cancel(SYNC_METADATA_ID); - } - - public interface OnProgressUpdate { - void onProgressUpdate(int progress); - } -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerComponent.java b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerComponent.java deleted file mode 100644 index 20d6b70c2da..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerComponent.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.dhis2.data.service; - -import androidx.annotation.NonNull; - -import org.dhis2.commons.di.dagger.PerService; - -import dagger.Subcomponent; - -@PerService -@Subcomponent(modules = SyncMetadataWorkerModule.class) -public interface SyncMetadataWorkerComponent { - void inject(@NonNull SyncMetadataWorker syncMetadataWorker); -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt deleted file mode 100644 index 33fcbff9d17..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dhis2.data.service - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.di.dagger.PerService -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper -import org.hisp.dhis.android.core.D2 - -@Module -class SyncMetadataWorkerModule { - @Provides - @PerService - fun syncRepository(d2: D2): SyncRepository = SyncRepositoryImpl(d2) - - @Provides - @PerService - internal fun syncPresenter( - d2: D2, - preferences: PreferenceProvider, - workManagerController: WorkManagerController, - analyticsHelper: AnalyticsHelper, - syncStatusController: SyncStatusController, - syncRepository: SyncRepository, - ): SyncPresenter = - SyncPresenterImpl( - d2, - preferences, - workManagerController, - analyticsHelper, - syncStatusController, - syncRepository, - ) -} diff --git a/app/src/main/java/org/dhis2/data/service/SyncPresenter.java b/app/src/main/java/org/dhis2/data/service/SyncPresenter.java index 507a0fabd4a..297ca06c001 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncPresenter.java +++ b/app/src/main/java/org/dhis2/data/service/SyncPresenter.java @@ -1,28 +1,15 @@ package org.dhis2.data.service; +import androidx.work.ListenableWorker; + import org.hisp.dhis.android.core.arch.call.D2Progress; import org.hisp.dhis.android.core.imports.TrackerImportConflict; -import org.hisp.dhis.android.core.tracker.exporter.TrackerD2Progress; import java.util.List; -import androidx.work.ListenableWorker; - import io.reactivex.Observable; interface SyncPresenter { - void syncAndDownloadEvents() throws Exception; - - void syncAndDownloadTeis() throws Exception; - - void syncMetadata(SyncMetadataWorker.OnProgressUpdate progressUpdate) throws Exception; - - void syncAndDownloadDataValues() throws Exception; - - void syncReservedValues(); - - SyncResult checkSyncStatus(); - Observable syncGranularEvent(String eventUid); ListenableWorker.Result blockSyncGranularProgram(String programUid); @@ -57,21 +44,5 @@ interface SyncPresenter { List messageTrackerImportConflict(String uid); - void startPeriodicDataWork(); - - void startPeriodicMetaWork(); - - void downloadResources(); - ListenableWorker.Result blockSyncGranularDataValues(String dataSetUid, String orgUnitUid, String attrOptionCombo, String periodId, String[] catOptionCombo); - - void logTimeToFinish(long millisToFinish, String eventName); - - void updateProyectAnalytics(); - - void initSyncControllerMap(); - - void finishSync(); - - void setNetworkUnavailable(); } diff --git a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt index 5f235e6521e..28f480d09f8 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt @@ -2,127 +2,35 @@ package org.dhis2.data.service import androidx.annotation.VisibleForTesting import androidx.work.Data -import androidx.work.ExistingWorkPolicy import androidx.work.ListenableWorker import io.reactivex.Completable import io.reactivex.Observable -import org.dhis2.bindings.toSeconds +import kotlinx.coroutines.runBlocking import org.dhis2.commons.bindings.enrollment +import org.dhis2.commons.bindings.event import org.dhis2.commons.bindings.program import org.dhis2.commons.date.DateUtils -import org.dhis2.commons.prefs.Preference.Companion.DATA import org.dhis2.commons.prefs.Preference.Companion.EVENT_MAX import org.dhis2.commons.prefs.Preference.Companion.EVENT_MAX_DEFAULT import org.dhis2.commons.prefs.Preference.Companion.LIMIT_BY_ORG_UNIT import org.dhis2.commons.prefs.Preference.Companion.LIMIT_BY_PROGRAM -import org.dhis2.commons.prefs.Preference.Companion.META -import org.dhis2.commons.prefs.Preference.Companion.TEI_MAX -import org.dhis2.commons.prefs.Preference.Companion.TEI_MAX_DEFAULT -import org.dhis2.commons.prefs.Preference.Companion.TIME_DAILY -import org.dhis2.commons.prefs.Preference.Companion.TIME_DATA -import org.dhis2.commons.prefs.Preference.Companion.TIME_META import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem -import org.dhis2.data.service.workManager.WorkerType -import org.dhis2.utils.analytics.AnalyticsHelper -import org.dhis2.utils.analytics.matomo.DEFAULT_EXTERNAL_TRACKER_NAME +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.call.D2Progress -import org.hisp.dhis.android.core.arch.call.D2ProgressStatus import org.hisp.dhis.android.core.common.State -import org.hisp.dhis.android.core.fileresource.FileResourceDomainType import org.hisp.dhis.android.core.imports.TrackerImportConflict import org.hisp.dhis.android.core.program.ProgramType -import org.hisp.dhis.android.core.settings.GeneralSettings import org.hisp.dhis.android.core.settings.LimitScope import org.hisp.dhis.android.core.settings.ProgramSettings -import org.hisp.dhis.android.core.systeminfo.DHISVersion -import timber.log.Timber import java.util.Calendar -import kotlin.math.ceil class SyncPresenterImpl( private val d2: D2, private val preferences: PreferenceProvider, - private val workManagerController: WorkManagerController, - private val analyticsHelper: AnalyticsHelper, - private val syncStatusController: SyncStatusController, private val syncRepository: SyncRepository, + private val syncStatusController: SyncStatusController, ) : SyncPresenter { - override fun initSyncControllerMap() { - Completable - .fromCallable { - val programMap: Map = - d2 - .programModule() - .programs() - .blockingGetUids() - .map { programUid -> - programUid to D2ProgressStatus(false, null) - }.toMap() - val aggregateMap: Map = - d2.dataSetModule().dataSets().blockingGetUids().associateWith { - D2ProgressStatus(false, null) - } - val allMap = - programMap - .toMutableMap() - .apply { - putAll(aggregateMap) - }.toMap() - syncStatusController.initDownloadProcess(allMap) - }.blockingAwait() - } - - override fun finishSync() { - syncStatusController.finishSync() - } - - override fun setNetworkUnavailable() { - syncStatusController.onNetworkUnavailable() - } - - override fun syncAndDownloadEvents() { - val (eventLimit, limitByOU, limitByProgram) = getDownloadLimits() - val programEventUids = - d2 - .programModule() - .programs() - .byProgramType() - .eq(ProgramType.WITHOUT_REGISTRATION) - .blockingGetUids() - syncStatusController.startDownloadingEvents() - Completable - .fromObservable(d2.eventModule().events().upload()) - .andThen( - Completable - .fromObservable( - d2 - .eventModule() - .eventDownloader() - .limit(eventLimit) - .limitByOrgunit(limitByOU) - .limitByProgram(limitByProgram) - .download() - .doOnNext { d2Progress -> - syncStatusController.updateDownloadProcess( - d2Progress.programs().filter { entry -> - programEventUids.contains(entry.key) - }, - ) - }, - ).doOnError { - Timber.d("error while downloading Events") - }.onErrorComplete() - .doOnComplete { - syncStatusController.finishDownloadingEvents( - programEventUids, - ) - }, - ).blockingAwait() - } - @VisibleForTesting fun getDownloadLimits(): Triple { val programSettings = getProgramSetting() @@ -148,237 +56,15 @@ class SyncPresenterImpl( return Triple(eventLimit, limitByOU, limitByProgram) } - override fun syncAndDownloadTeis() { - val programSettings = getProgramSetting() - val globalProgramSettings = programSettings?.globalSettings() - - val teiLimit = - globalProgramSettings?.teiDownload() ?: preferences.getInt(TEI_MAX, TEI_MAX_DEFAULT) - val limitByOU = - globalProgramSettings?.settingDownload()?.let { - it == LimitScope.PER_ORG_UNIT || it == LimitScope.PER_OU_AND_PROGRAM - } ?: preferences.getBoolean(LIMIT_BY_ORG_UNIT, false) - val limitByProgram = - globalProgramSettings?.settingDownload()?.let { - it == LimitScope.PER_PROGRAM || it == LimitScope.PER_OU_AND_PROGRAM - } ?: preferences.getBoolean(LIMIT_BY_PROGRAM, false) - - val trackerProgramUids = - d2 - .programModule() - .programs() - .byProgramType() - .eq(ProgramType.WITH_REGISTRATION) - .blockingGetUids() - - syncStatusController.startDownloadingTracker() - - Completable - .fromObservable(d2.trackedEntityModule().trackedEntityInstances().upload()) - .andThen( - Completable - .fromObservable( - d2 - .trackedEntityModule() - .trackedEntityInstanceDownloader() - .limit(teiLimit) - .limitByOrgunit(limitByOU) - .limitByProgram(limitByProgram) - .download() - .doOnNext { data -> - val percentage = data.percentage() - val callsDone = data.doneCalls().size - val totalCalls = data.totalCalls() - Timber.d("$percentage% $callsDone/$totalCalls") - syncStatusController.updateDownloadProcess( - data.programs().filter { entry -> - trackerProgramUids.contains(entry.key) - }, - ) - }, - ).doOnError { Timber.d("error while downloading TEIs") } - .onErrorComplete() - .doOnComplete { - syncStatusController.finishDownloadingTracker( - trackerProgramUids, - ) - }, - ).blockingAwait() - } - - override fun syncAndDownloadDataValues() { - val dataSetUids = d2.dataSetModule().dataSets().blockingGetUids() - if (dataSetUids.isNotEmpty()) { - syncStatusController.startDownloadingDataSets() - Completable - .fromObservable(d2.dataValueModule().dataValues().upload()) - .doOnError { Timber.d("error while downloading Datasets") } - .andThen( - Completable.fromObservable( - d2.dataSetModule().dataSetCompleteRegistrations().upload(), - ), - ).doOnError { Timber.d("error while downloading Datasets") } - .andThen( - Completable - .fromObservable( - d2 - .aggregatedModule() - .data() - .download() - .doOnNext { - syncStatusController.updateDownloadProcess(it.dataSets()) - }, - ).doOnError { Timber.d("error while downloading Datasets") } - .onErrorComplete() - .doOnComplete { - syncStatusController.finishDownloadingTracker( - dataSetUids, - ) - }, - ).blockingAwait() - } - } - - override fun syncMetadata(progressUpdate: SyncMetadataWorker.OnProgressUpdate) { - Completable - .fromObservable( - d2 - .metadataModule() - .download() - .doOnNext { data -> - Timber.log(1, data.toString()) - progressUpdate.onProgressUpdate(ceil(data.percentage() ?: 0.0).toInt()) - }.doOnComplete { - updateProyectAnalytics() - setUpSMS() - }, - ).doOnError { - Timber.d("error while downloading Metadata") - }.onErrorComplete() - .andThen( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ).andThen( - Completable.fromObservable( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ), - ).blockingAwait() - } - - private fun setUpSMS() { - val globalSettings = getSettings() - - globalSettings?.let { - if (!globalSettings.smsGateway().isNullOrEmpty()) { - d2 - .smsModule() - .configCase() - .setGatewayNumber(globalSettings.smsGateway()) - .andThen( - if (!globalSettings.smsResultSender().isNullOrEmpty()) { - d2 - .smsModule() - .configCase() - .setConfirmationSenderNumber(globalSettings.smsResultSender()) - } else { - Completable.complete() - }, - ).andThen( - d2.smsModule().configCase().setModuleEnabled(true), - ).andThen( - d2.smsModule().configCase().refreshMetadataIds(), - ).blockingAwait() - } - } - } - - override fun downloadResources() { - if (d2.systemInfoModule().versionManager().isGreaterThan(DHISVersion.V2_32)) { - syncStatusController.initDownloadMedia() - Completable - .fromObservable( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.DATA_VALUE) - .download(), - ).blockingAwait() - } - } - - override fun syncReservedValues() { - val maxNumberOfValuesToReserve = - getSettings()?.let { - it.reservedValues() ?: 100 - } ?: 100 - Completable - .fromObservable( - d2 - .trackedEntityModule() - .reservedValueManager() - .downloadAllReservedValues(maxNumberOfValuesToReserve), - ).doOnError { - Timber.d("error while downloading reserved values") - }.blockingAwait() - } - - override fun checkSyncStatus(): SyncResult { - val eventsOk = - d2 - .eventModule() - .events() - .byAggregatedSyncState() - .notIn(State.SYNCED) - .blockingGet() - .isEmpty() - val teiOk = - d2 - .trackedEntityModule() - .trackedEntityInstances() - .byAggregatedSyncState() - .notIn(State.SYNCED, State.RELATIONSHIP) - .blockingGet() - .isEmpty() - - if (eventsOk && teiOk) { - return SyncResult.SYNC - } - - val anyEventsToPostOrToUpdate = - d2 - .eventModule() - .events() - .byAggregatedSyncState() - .`in`(State.TO_POST, State.TO_UPDATE) - .blockingGet() - .isNotEmpty() - val anyTeiToPostOrToUpdate = - d2 - .trackedEntityModule() - .trackedEntityInstances() - .byAggregatedSyncState() - .`in`(State.TO_POST, State.TO_UPDATE) - .blockingGet() - .isNotEmpty() - - if (anyEventsToPostOrToUpdate || anyTeiToPostOrToUpdate) { - return SyncResult.INCOMPLETE - } - - return SyncResult.ERROR - } - override fun syncGranularEvent(eventUid: String): Observable { Completable.fromObservable(syncRepository.uploadEvent(eventUid)).blockingAwait() - return syncRepository - .downLoadEvent(eventUid) - .map { it as D2Progress } - .mergeWith(syncRepository.downloadEventFiles(eventUid)) + val programUid = d2.event(eventUid)?.program() + return programUid?.let { + syncRepository + .downLoadEvent(eventUid, programUid) + .map { it as D2Progress } + .mergeWith(syncRepository.downloadEventFiles(eventUid)) + } ?: Observable.empty() } override fun blockSyncGranularProgram(programUid: String): ListenableWorker.Result { @@ -388,7 +74,9 @@ class SyncPresenterImpl( return if (!checkSyncProgramStatus(programUid)) { ListenableWorker.Result.failure() } else { - syncStatusController.updateSingleProgramToSuccess(programUid) + runBlocking { + syncStatusController.updateSingleProgramToSuccess(programUid) + } ListenableWorker.Result.success() } } @@ -719,7 +407,7 @@ class SyncPresenterImpl( .byTrackedEntityInstanceUid() .eq(uid) .blockingGet() - if (trackerImportConflicts != null && trackerImportConflicts.isNotEmpty()) { + if (!trackerImportConflicts.isNullOrEmpty()) { return trackerImportConflicts } @@ -730,7 +418,7 @@ class SyncPresenterImpl( .byEventUid() .eq(uid) .blockingGet() - if (trackerImportConflicts != null && trackerImportConflicts.isNotEmpty()) { + if (trackerImportConflicts.isNotEmpty()) { return trackerImportConflicts } @@ -741,73 +429,15 @@ class SyncPresenterImpl( .byEnrollmentUid() .eq(uid) .blockingGet() - return if (trackerImportConflicts != null && trackerImportConflicts.isNotEmpty()) { - trackerImportConflicts - } else { + return trackerImportConflicts.ifEmpty { null } } - override fun startPeriodicDataWork() { - val seconds = - getSettings()?.dataSync()?.toSeconds() ?: preferences.getInt(TIME_DATA, TIME_DAILY) - workManagerController.cancelUniqueWork(DATA) - - if (seconds != 0) { - val workerItem = - WorkerItem( - DATA, - WorkerType.DATA, - seconds.toLong(), - policy = ExistingWorkPolicy.REPLACE, - ) - - workManagerController.syncDataForWorker(workerItem) - } - } - - override fun startPeriodicMetaWork() { - val seconds = - getSettings()?.metadataSync()?.toSeconds() ?: preferences.getInt(TIME_META, TIME_DAILY) - workManagerController.cancelUniqueWork(META) - - if (seconds != 0) { - val workerItem = - WorkerItem( - META, - WorkerType.METADATA, - seconds.toLong(), - policy = ExistingWorkPolicy.REPLACE, - ) - - workManagerController.syncDataForWorker(workerItem) - } - } - - private fun getSettings(): GeneralSettings? = d2.settingModule().generalSetting().blockingGet() - - private fun getProgramSetting(): ProgramSettings? = d2.settingModule().programSetting().blockingGet() - - override fun logTimeToFinish( - millisToFinish: Long, - eventName: String, - ) { - analyticsHelper.setEvent( - eventName, - (millisToFinish / 60000.0).toString(), - eventName, - ) - } - - override fun updateProyectAnalytics() { - getSettings()?.let { - if (it.matomoID() != null && it.matomoURL() != null) { - analyticsHelper.updateMatomoSecondaryTracker( - it.matomoURL()!!, - it.matomoID()!!, - DEFAULT_EXTERNAL_TRACKER_NAME, - ) - } - } ?: analyticsHelper.clearMatomoSecondaryTracker() - } + private fun getProgramSetting(): ProgramSettings? = + d2 + .settingModule() + .synchronizationSettings() + .blockingGet() + ?.programSettings() } diff --git a/app/src/main/java/org/dhis2/data/service/SyncRepository.kt b/app/src/main/java/org/dhis2/data/service/SyncRepository.kt index 1d317d969af..8eaf15b5ac6 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncRepository.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncRepository.kt @@ -25,7 +25,10 @@ interface SyncRepository { fun uploadEvent(eventUid: String): Observable - fun downLoadEvent(eventUid: String): Observable + fun downLoadEvent( + eventUid: String, + programUid: String, + ): Observable fun downloadEventFiles(eventUid: String): Observable diff --git a/app/src/main/java/org/dhis2/data/service/SyncRepositoryImpl.kt b/app/src/main/java/org/dhis2/data/service/SyncRepositoryImpl.kt index 3fdcf93e39b..5bcde1cf077 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncRepositoryImpl.kt @@ -1,8 +1,6 @@ package org.dhis2.data.service -import io.reactivex.Observable import org.hisp.dhis.android.core.D2 -import org.hisp.dhis.android.core.arch.call.D2Progress import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance @@ -57,13 +55,16 @@ class SyncRepositoryImpl( .eq(eventUid) .upload() - override fun downLoadEvent(eventUid: String): Observable = - d2 - .eventModule() - .eventDownloader() - .byUid() - .eq(eventUid) - .download() + override fun downLoadEvent( + eventUid: String, + programUid: String, + ) = d2 + .eventModule() + .eventDownloader() + .byProgramUid(programUid) + .byUid() + .eq(eventUid) + .download() override fun downloadEventFiles(eventUid: String) = d2 diff --git a/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt b/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt deleted file mode 100644 index 19373e1a7b9..00000000000 --- a/app/src/main/java/org/dhis2/data/service/SyncStatusController.kt +++ /dev/null @@ -1,190 +0,0 @@ -package org.dhis2.data.service - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import org.dhis2.commons.viewmodel.DispatcherProvider -import org.hisp.dhis.android.core.arch.call.D2ProgressStatus -import org.hisp.dhis.android.core.arch.call.D2ProgressSyncStatus -import timber.log.Timber - -class SyncStatusController( - private val dispatcher: DispatcherProvider, -) { - private var progressStatusMap: Map = emptyMap() - private val downloadStatus = MutableStateFlow(SyncStatusData(isInitialSync = true)) - - fun observeDownloadProcess(): StateFlow = downloadStatus - - fun initDownloadProcess(programDownload: Map) { - Timber.tag("SYNC").d("INIT DATA SYNC") - progressStatusMap = programDownload - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - SyncStatusData( - running = true, - downloadingEvents = false, - downloadingTracker = false, - downloadingDataSetValues = false, - false, - progressStatusMap, - ), - ) - } - } - - fun updateDownloadProcess(programDownload: Map) { - Timber.tag("SYNC").d("Updating PROGRAM") - progressStatusMap = - progressStatusMap.toMutableMap().also { - it.putAll(programDownload) - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(programSyncStatusMap = progressStatusMap), - ) - } - } - - fun finishSync() { - Timber.tag("SYNC").d("FINISH DATA SYNC") - progressStatusMap = progressStatusMap.toMutableMap() - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy( - running = false, - programSyncStatusMap = progressStatusMap, - ), - ) - } - } - - fun onNetworkUnavailable() { - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - SyncStatusData(true, programSyncStatusMap = progressStatusMap), - ) - } - } - - fun startDownloadingEvents() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(running = true, downloadingEvents = true), - ) - } - } - - fun finishDownloadingEvents(eventProgramUids: List) { - Timber.tag("SYNC").d("FINISHED EVENTS") - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (!eventProgramUids.contains(entry.key) || entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy( - downloadingEvents = false, - programSyncStatusMap = progressStatusMap, - ), - ) - } - } - - fun startDownloadingTracker() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingTracker = true), - ) - } - } - - fun finishDownloadingTracker(trackerProgramUids: List) { - Timber.tag("SYNC").d("FINISHED TRACKER") - - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (!trackerProgramUids.contains(entry.key) || entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy( - downloadingTracker = false, - programSyncStatusMap = progressStatusMap, - ), - ) - } - } - - fun updateSingleProgramToSuccess(programUid: String) { - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (programUid != entry.key) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.SUCCESS) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - SyncStatusData(false, programSyncStatusMap = progressStatusMap), - ) - } - } - - fun initDownloadMedia() { - Timber.tag("SYNC").d("INIT FILES") - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingMedia = true), - ) - } - } - - fun restore() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit(SyncStatusData()) - } - } - - fun startDownloadingDataSets() { - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingDataSetValues = true), - ) - } - } - - fun finishDownloadingDataSets(dataSetUids: List) { - progressStatusMap = - progressStatusMap.toMutableMap().mapValues { entry -> - if (!dataSetUids.contains(entry.key) || entry.value.isComplete) { - entry.value - } else { - entry.value.copy(isComplete = true, D2ProgressSyncStatus.ERROR) - } - } - CoroutineScope(dispatcher.io()).launch { - downloadStatus.emit( - downloadStatus.value.copy(downloadingDataSetValues = false, programSyncStatusMap = progressStatusMap), - ) - } - } -} diff --git a/app/src/main/java/org/dhis2/data/service/VersionRepository.kt b/app/src/main/java/org/dhis2/data/service/VersionRepository.kt index 23771279cc4..49c65337ce8 100644 --- a/app/src/main/java/org/dhis2/data/service/VersionRepository.kt +++ b/app/src/main/java/org/dhis2/data/service/VersionRepository.kt @@ -5,11 +5,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.net.Uri -import android.os.Build import android.os.Environment import androidx.core.content.ContextCompat -import androidx.core.content.FileProvider import androidx.core.net.toUri import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -44,7 +41,7 @@ class VersionRepository( fun download( context: Context, - onDownloadCompleted: (Uri) -> Unit, + onDownloadCompleted: (String) -> Unit, onDownloading: () -> Unit, ) { val url = @@ -60,9 +57,8 @@ class VersionRepository( )}/$fileName" val apkFile = File(destination) - val apkUri = uriFromFile(context, apkFile) if (apkFile.exists()) { - onDownloadCompleted(apkUri) + onDownloadCompleted(destination) } else if (fileName?.endsWith("apk") == true) { val request = DownloadManager @@ -86,7 +82,7 @@ class VersionRepository( ctxt: Context, intent: Intent?, ) { - onDownloadCompleted(apkUri) + onDownloadCompleted(destination) } } ContextCompat.registerReceiver( @@ -96,20 +92,10 @@ class VersionRepository( ContextCompat.RECEIVER_EXPORTED, ) } else { - url?.let { onDownloadCompleted(it.toUri()) } + url?.let { onDownloadCompleted(url) } } } - private fun uriFromFile( - context: Context, - file: File, - ): Uri = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file) - } else { - Uri.fromFile(file) - } - fun getUrl(): String? = d2 .settingModule() diff --git a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt index 9723f3177bd..f583c04b714 100644 --- a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt +++ b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerController.kt @@ -34,16 +34,6 @@ import androidx.work.WorkInfo interface WorkManagerController { fun syncDataForWorker(workerItem: WorkerItem) - fun syncMetaDataForWorker( - metadataWorkerTag: String, - workName: String, - ) - - fun syncDataForWorker( - dataWorkerTag: String, - workName: String, - ) - fun beginUniqueWork(workerItem: WorkerItem) fun enqueuePeriodicWork(workerItem: WorkerItem) diff --git a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt index 26158e84383..ceef5322de2 100644 --- a/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/workManager/WorkManagerControllerImpl.kt @@ -30,16 +30,13 @@ package org.dhis2.data.service.workManager import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData -import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.await import org.dhis2.data.service.CheckVersionWorker -import org.dhis2.data.service.SyncDataWorker import org.dhis2.data.service.SyncGranularWorker -import org.dhis2.data.service.SyncMetadataWorker import java.util.concurrent.TimeUnit class WorkManagerControllerImpl( @@ -55,32 +52,6 @@ class WorkManagerControllerImpl( } } - override fun syncMetaDataForWorker( - metadataWorkerTag: String, - workName: String, - ) { - val workerOneBuilder = OneTimeWorkRequest.Builder(SyncMetadataWorker::class.java) - workerOneBuilder - .addTag(metadataWorkerTag) - - workManager - .beginUniqueWork(workName, ExistingWorkPolicy.KEEP, workerOneBuilder.build()) - .enqueue() - } - - override fun syncDataForWorker( - dataWorkerTag: String, - workName: String, - ) { - val workerTwoBuilder = OneTimeWorkRequest.Builder(SyncDataWorker::class.java) - workerTwoBuilder - .addTag(dataWorkerTag) - - workManager - .beginUniqueWork(workName, ExistingWorkPolicy.KEEP, workerTwoBuilder.build()) - .enqueue() - } - override fun beginUniqueWork(workerItem: WorkerItem) { val request = createOneTimeBuilder(workerItem).build() workerItem.policy?.let { @@ -132,8 +103,6 @@ class WorkManagerControllerImpl( private fun createOneTimeBuilder(workerItem: WorkerItem): OneTimeWorkRequest.Builder { val syncBuilder = when (workerItem.workerType) { - WorkerType.METADATA -> OneTimeWorkRequest.Builder(SyncMetadataWorker::class.java) - WorkerType.DATA -> OneTimeWorkRequest.Builder(SyncDataWorker::class.java) WorkerType.GRANULAR -> OneTimeWorkRequest.Builder(SyncGranularWorker::class.java) WorkerType.NEW_VERSION -> OneTimeWorkRequest.Builder(CheckVersionWorker::class.java) } @@ -155,20 +124,6 @@ class WorkManagerControllerImpl( val syncBuilder = when (workerItem.workerType) { - WorkerType.METADATA -> { - PeriodicWorkRequest.Builder( - SyncMetadataWorker::class.java, - seconds, - TimeUnit.SECONDS, - ) - } - WorkerType.DATA -> { - PeriodicWorkRequest.Builder( - SyncDataWorker::class.java, - seconds, - TimeUnit.SECONDS, - ) - } WorkerType.GRANULAR -> { PeriodicWorkRequest.Builder( SyncGranularWorker::class.java, @@ -176,6 +131,7 @@ class WorkManagerControllerImpl( TimeUnit.SECONDS, ) } + WorkerType.NEW_VERSION -> PeriodicWorkRequest.Builder( CheckVersionWorker::class.java, diff --git a/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt b/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt index 6c77fc50fb5..050519a6e2c 100644 --- a/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt +++ b/app/src/main/java/org/dhis2/data/service/workManager/WorkerType.kt @@ -29,8 +29,6 @@ package org.dhis2.data.service.workManager enum class WorkerType { - METADATA, - DATA, GRANULAR, NEW_VERSION, } diff --git a/app/src/main/java/org/dhis2/data/sorting/SearchSortingValueSetter.kt b/app/src/main/java/org/dhis2/data/sorting/SearchSortingValueSetter.kt index be850c0fb82..f56b1327de2 100644 --- a/app/src/main/java/org/dhis2/data/sorting/SearchSortingValueSetter.kt +++ b/app/src/main/java/org/dhis2/data/sorting/SearchSortingValueSetter.kt @@ -4,6 +4,7 @@ import org.dhis2.commons.filters.Filters import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.commons.filters.sorting.SortingStatus import org.dhis2.data.enrollment.EnrollmentUiDataHelper +import org.dhis2.mobile.commons.extensions.toJavaDate import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope @@ -52,7 +53,7 @@ class SearchSortingValueSetter( .eventModule() .events() .byEnrollmentUid() - .eq(teiModel.selectedEnrollment?.uid() ?: "") + .eq(teiModel.selectedEnrollment?.uid ?: "") .byDeleted() .isFalse .orderByTimeline( @@ -62,7 +63,7 @@ class SearchSortingValueSetter( RepositoryScope.OrderByDirection.DESC }, ).blockingGet() - if (sortedEvents != null && sortedEvents.isNotEmpty()) { + if (sortedEvents.isNotEmpty()) { val sortedEvent = sortedEvents.first() val sortedDate: Date? = if (sortedEvent.status() == EventStatus.SCHEDULE || @@ -85,7 +86,7 @@ class SearchSortingValueSetter( if (teiModel.selectedEnrollment != null) { enrollmentStatusValue = enrollmentUiDataHelper.getEnrollmentStatusClientName( - teiModel.selectedEnrollment.status()!!, + teiModel.selectedEnrollment.status, ) } return Pair(enrollmentStatusLabel, enrollmentStatusValue) @@ -97,12 +98,12 @@ class SearchSortingValueSetter( d2 .programModule() .programs() - .uid(it.program()) + .uid(it.program) .blockingGet() - ?.enrollmentDateLabel() ?: enrollmentDateDefaultLabel + ?.displayEnrollmentDateLabel() ?: enrollmentDateDefaultLabel val enrollmentDateValue = SimpleDateFormat(uiDateFormat, Locale.getDefault()) - .format(teiModel.selectedEnrollment.enrollmentDate()) + .format(teiModel.selectedEnrollment.enrollmentDate?.toJavaDate()) Pair(enrollmentDateLabel, enrollmentDateValue) } ?: Pair(enrollmentDateDefaultLabel, unknownLabel) } diff --git a/app/src/main/java/org/dhis2/data/user/UserComponent.java b/app/src/main/java/org/dhis2/data/user/UserComponent.java index eebf6102a13..c2f47d587c3 100644 --- a/app/src/main/java/org/dhis2/data/user/UserComponent.java +++ b/app/src/main/java/org/dhis2/data/user/UserComponent.java @@ -8,14 +8,8 @@ import org.dhis2.commons.featureconfig.di.FeatureConfigActivityComponent; import org.dhis2.commons.featureconfig.di.FeatureConfigActivityModule; import org.dhis2.commons.filters.data.FilterPresenter; -import org.dhis2.data.service.SyncDataWorkerComponent; -import org.dhis2.data.service.SyncDataWorkerModule; import org.dhis2.data.service.SyncGranularRxComponent; import org.dhis2.data.service.SyncGranularRxModule; -import org.dhis2.data.service.SyncInitWorkerComponent; -import org.dhis2.data.service.SyncInitWorkerModule; -import org.dhis2.data.service.SyncMetadataWorkerComponent; -import org.dhis2.data.service.SyncMetadataWorkerModule; import org.dhis2.usescases.about.AboutComponent; import org.dhis2.usescases.about.AboutModule; import org.dhis2.usescases.datasets.datasetDetail.DataSetDetailComponent; @@ -32,8 +26,6 @@ import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.injection.EventDetailsModule; import org.dhis2.usescases.eventsWithoutRegistration.eventInitial.EventInitialComponent; import org.dhis2.usescases.eventsWithoutRegistration.eventInitial.EventInitialModule; -import org.dhis2.usescases.main.MainComponent; -import org.dhis2.usescases.main.MainModule; import org.dhis2.usescases.main.program.ProgramComponent; import org.dhis2.usescases.main.program.ProgramModule; import org.dhis2.usescases.notes.NotesComponent; @@ -70,8 +62,6 @@ import org.dhis2.usescases.teiDashboard.dialogs.scheduling.SchedulingModule; import org.dhis2.usescases.teiDashboard.teiProgramList.TeiProgramListComponent; import org.dhis2.usescases.teiDashboard.teiProgramList.TeiProgramListModule; -import org.dhis2.utils.session.PinModule; -import org.dhis2.utils.session.SessionComponent; import dagger.Subcomponent; import dhis2.org.analytics.charts.ui.di.AnalyticsFragmentComponent; @@ -83,10 +73,6 @@ public interface UserComponent { FilterPresenter filterPresenter(); - @NonNull - MainComponent plus(@NonNull MainModule mainModule); - - @NonNull ProgramEventDetailComponent plus(@NonNull ProgramEventDetailModule programEventDetailModule); @@ -133,12 +119,6 @@ public interface UserComponent { @NonNull ReservedValueComponent plus(ReservedValueModule reservedValueModule); - @NonNull - SyncDataWorkerComponent plus(SyncDataWorkerModule syncDataWorkerModule); - - @NonNull - SyncMetadataWorkerComponent plus(SyncMetadataWorkerModule syncDataWorkerModule); - @NonNull EventCaptureComponent plus(EventCaptureModule eventCaptureModule); @@ -151,9 +131,6 @@ public interface UserComponent { @NonNull SyncComponent plus(SyncModule syncModule); - @NonNull - SyncInitWorkerComponent plus(SyncInitWorkerModule syncInitWorkerModule); - @NonNull EnrollmentComponent plus(EnrollmentModule enrollmentModule); @@ -185,9 +162,6 @@ public interface UserComponent { @NonNull EventDetailsComponent plus(EventDetailsModule eventDetailsModule); - @NonNull - SessionComponent plus(PinModule pinModule); - @NonNull SchedulingComponent plus(SchedulingModule schedulingModule); } diff --git a/app/src/main/java/org/dhis2/di/KoinInitialization.kt b/app/src/main/java/org/dhis2/di/KoinInitialization.kt index 61bbf879f81..a523c08a618 100644 --- a/app/src/main/java/org/dhis2/di/KoinInitialization.kt +++ b/app/src/main/java/org/dhis2/di/KoinInitialization.kt @@ -1,19 +1,27 @@ package org.dhis2.di import android.app.Application +import dhis2.org.analytics.charts.di.chartsModule +import org.dhis2.BuildConfig import org.dhis2.android.rtsm.di.stockModule +import org.dhis2.appModule +import org.dhis2.commons.di.filterModule import org.dhis2.commons.di.resourceManagerModule import org.dhis2.commons.filters.periods.di.filterPeriodsModule import org.dhis2.data.biometric.biometricModule import org.dhis2.mobile.aggregates.di.aggregatesModule import org.dhis2.mobile.commons.di.commonsModule import org.dhis2.mobile.login.main.di.loginModule +import org.dhis2.mobile.sync.di.syncModule +import org.dhis2.tracker.search.di.trackerSearchModule import org.dhis2.usescases.datasets.di.dataSetModules +import org.dhis2.usescases.searchTrackEntity.di.searchTEKoinModule import org.dhis2.usescases.settingsprogram.di.settingsProgramModule import org.dhis2.utils.analytics.matomo.matomoModule import org.hisp.dhis.android.core.D2Configuration import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger +import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.core.context.startKoin object KoinInitialization { @@ -21,18 +29,40 @@ object KoinInitialization { startKoin { androidLogger() androidContext(this@invoke) + properties( + mapOf( + "sentryDsn" to BuildConfig.SENTRY_DSN, + "isTrainingFlavor" to (BuildConfig.FLAVOR == "dhis2Training"), + "openIdType" to BuildConfig.OPEN_ID_TYPE, + "openIdServer" to BuildConfig.OPEN_ID_SERVER, + "openIdClient" to BuildConfig.OPEN_ID_CLIENT, + "openIdRedirectUri" to BuildConfig.OPEN_ID_REDIRECT_URI, + "openIdDiscoveryUri" to BuildConfig.OPEN_ID_DISCOVERY_URI, + "openIdAuthorizationUrl" to BuildConfig.OPEN_ID_AUTHORIZATION_URL, + "openIdTokenUrl" to BuildConfig.OPEN_ID_TOKEN_URL, + "openIdButtonText" to BuildConfig.OPEN_ID_BUTTON_TEXT, + "openIdPrompt" to BuildConfig.OPEN_ID_PROMPT, + ), + ) + workManagerFactory() modules( + appModule, serverModule(d2Configuration), commonsModule, aggregatesModule, + filterModule, filterPeriodsModule, resourceManagerModule, dataSetModules, stockModule, loginModule, + trackerSearchModule, + searchTEKoinModule, settingsProgramModule, biometricModule, matomoModule, + syncModule, + chartsModule, ) } } diff --git a/app/src/main/java/org/dhis2/di/ServerModule.kt b/app/src/main/java/org/dhis2/di/ServerModule.kt index dd41ceb12e9..e7c4ea4805f 100644 --- a/app/src/main/java/org/dhis2/di/ServerModule.kt +++ b/app/src/main/java/org/dhis2/di/ServerModule.kt @@ -1,7 +1,9 @@ package org.dhis2.di +import org.dhis2.data.service.VersionRepository import org.hisp.dhis.android.core.D2Configuration import org.hisp.dhis.android.core.D2Manager +import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module fun serverModule(d2Configuration: D2Configuration) = @@ -17,4 +19,5 @@ fun serverModule(d2Configuration: D2Configuration) = D2Manager.getD2() } } + factoryOf(::VersionRepository) } diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt index c3ef20c4ecb..8ca900b26c6 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCase.kt @@ -6,43 +6,46 @@ import kotlinx.coroutines.flow.flowOf import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase import org.dhis2.commons.simprints.utils.SimprintsSearchUtils -import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModel -import org.dhis2.usescases.searchTrackEntity.SearchRepository import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt import org.dhis2.usescases.searchTrackEntity.SearchTeiModel +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput class SimprintsLoadBiometricSearchResultsUseCase( - private val searchRepository: SearchRepository, private val searchRepositoryKt: SearchRepositoryKt, + private val searchTrackedEntities: SearchTrackedEntities, private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase, ) { suspend operator fun invoke( searchItems: List, - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - offlineOnly: Boolean, + searchInput: SearchTrackedEntitiesInput, sortingItem: SortingItem?, ): Flow>? { val trackedEntities = orderSearchResultsByIdentifyResponse( searchFields = searchItems.toSearchFields(), - queryData = searchParametersModel.queryData.orEmpty(), + queryData = searchInput.queryDataList.toQueryData(), searchTrackedEntities = { - searchRepositoryKt.searchTrackedEntitiesImmediate( - searchParametersModel = searchParametersModel, - isOnline = isOnline, - ) + searchTrackedEntities + .invokeImmediate(searchInput) + .getOrThrow() + }, + getUid = { trackedEntity -> + trackedEntity.uid + }, + getAttributeValue = { trackedEntity, fieldUid -> + trackedEntity.attributeValues + .firstOrNull { it.attribute == fieldUid } + ?.value }, ) ?: return null return flowOf( PagingData.from( - trackedEntities.map { searchItem -> - searchRepository.transform( - searchItem, - searchParametersModel.selectedProgram, - offlineOnly, + trackedEntities.map { trackedEntity -> + searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel( + trackedEntity, sortingItem, ) }, @@ -58,4 +61,10 @@ class SimprintsLoadBiometricSearchResultsUseCase( customIntent = field.customIntent, ) } + + private fun List?.toQueryData(): Map?> = + this + ?.associate { queryData -> + queryData.attributeId to queryData.values + }.orEmpty() } diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt index 21cb57382e6..3e89f4a8260 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt @@ -1,32 +1,33 @@ package org.dhis2.simprints import org.dhis2.commons.filters.sorting.SortingItem -import org.dhis2.data.search.SearchParametersModel -import org.dhis2.usescases.searchTrackEntity.SearchRepository import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt import org.dhis2.usescases.searchTrackEntity.SearchTeiModel +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput class SimprintsLoadPossibleDuplicatesSearchResultsUseCase( - private val searchRepository: SearchRepository, private val searchRepositoryKt: SearchRepositoryKt, + private val searchTrackedEntities: SearchTrackedEntities, ) { suspend operator fun invoke( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - offlineOnly: Boolean, + queryDataList: List, + searchInput: SearchTrackedEntitiesInput, sortingItem: SortingItem?, ): List? { - val simprintsQueryData = searchParametersModel.queryData.orEmpty() + val simprintsQueryData = queryDataList.associateBy(QueryData::attributeId) val simprintsQueryEntry = - simprintsQueryData.entries.firstOrNull { (_, values) -> - (values?.size ?: 0) > 1 - } ?: simprintsQueryData.entries.firstOrNull { (_, values) -> - !values.isNullOrEmpty() + simprintsQueryData.entries.firstOrNull { (_, queryData) -> + (queryData.values?.size ?: 0) > 1 + } ?: simprintsQueryData.entries.firstOrNull { (_, queryData) -> + !queryData.values.isNullOrEmpty() } ?: return null val simprintsQueryValues = simprintsQueryEntry .value + .values .orEmpty() .filter(String::isNotBlank) .distinct() @@ -37,28 +38,28 @@ class SimprintsLoadPossibleDuplicatesSearchResultsUseCase( buildList { simprintsQueryValues.forEach { guidValue -> addAll( - searchRepositoryKt.searchTrackedEntitiesImmediate( - searchParametersModel = - searchParametersModel.copy( - queryData = - simprintsQueryData.toMutableMap().apply { - put(simprintsQueryEntry.key, listOf(guidValue)) + searchTrackedEntities + .invokeImmediate( + searchInput.copy( + queryDataList = + queryDataList.map { queryData -> + if (queryData.attributeId == simprintsQueryEntry.key) { + queryData.copy(values = listOf(guidValue)) + } else { + queryData + } }, ), - isOnline = isOnline, - ), + ).getOrThrow(), ) } - }.distinctBy { it.uid() } + }.distinctBy { it.uid } - return searchItems.map { searchItem -> - searchRepository.transform( - searchItem, - searchParametersModel.selectedProgram, - offlineOnly, + return searchItems.map { trackedEntity -> + searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel( + trackedEntity, sortingItem, ) } } } - diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt index 841c29e797e..5aff95aa1ec 100644 --- a/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt +++ b/app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt @@ -4,13 +4,16 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils -import org.dhis2.data.search.SearchParametersModel import org.dhis2.usescases.searchTrackEntity.SearchRepository import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput class SimprintsResolveSingleBiometricSearchNavigationUseCase( private val searchRepository: SearchRepository, private val searchRepositoryKt: SearchRepositoryKt, + private val searchTrackedEntities: SearchTrackedEntities, private val networkUtils: NetworkUtils, private val filterManager: FilterManager, private val ioDispatcher: CoroutineDispatcher, @@ -39,32 +42,40 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCase( return@withContext null } - val searchParametersModel = - SearchParametersModel( - selectedProgram = searchRepository.getProgram(initialProgramUid), - queryData = queryData.toMutableMap(), - ) val isOnline = queryData.isNotEmpty() && networkUtils.isOnline() + val selectedProgram = searchRepository.getProgram(initialProgramUid) val trackedEntity = - searchRepositoryKt - .searchTrackedEntitiesImmediate( - searchParametersModel = searchParametersModel, - isOnline = isOnline, - ).singleOrNull() ?: return@withContext null + searchTrackedEntities + .invokeImmediate( + SearchTrackedEntitiesInput( + selectedProgram = selectedProgram?.uid(), + allowCache = false, + excludeValues = searchRepositoryKt.getExcludeValues(), + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = isOnline, + queryDataList = + queryData.map { (attributeId, values) -> + QueryData( + attributeId = attributeId, + values = values, + searchOperator = null, + ) + }, + ), + ).getOrThrow() + .singleOrNull() ?: return@withContext null val searchTeiModel = - searchRepository.transform( + searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel( trackedEntity, - searchParametersModel.selectedProgram, - !(isOnline && filterManager.stateFilters.isEmpty()), filterManager.sortingItem, ) NavigationTarget( - teiUid = searchTeiModel.uid(), - programUid = searchTeiModel.selectedEnrollment?.program() ?: initialProgramUid, - enrollmentUid = searchTeiModel.selectedEnrollment?.uid(), - isOnline = searchTeiModel.isOnline, + teiUid = searchTeiModel.tei.uid, + programUid = searchTeiModel.selectedEnrollment?.program ?: initialProgramUid, + enrollmentUid = searchTeiModel.selectedEnrollment?.uid, + isOnline = searchTeiModel.tei.isOnline, ) } } diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java index 5bc7f3c3c27..054df4253f6 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailActivity.java @@ -105,14 +105,12 @@ private void configureBottomNavigation() { binding.navigationBar.setOnItemSelectedListener(item -> { Fragment newFragment = null; - switch (item.getItemId()) { - case R.id.navigation_list_view: - newFragment = DataSetListFragment.newInstance(dataSetUid, accessWriteData); - break; - case R.id.navigation_analytics: - presenter.trackDataSetAnalytics(); - newFragment = GroupAnalyticsFragment.Companion.forDataSet(dataSetUid); - break; + int itemId = item.getItemId(); + if (itemId == R.id.navigation_list_view) { + newFragment = DataSetListFragment.newInstance(dataSetUid, accessWriteData); + } else if (itemId == R.id.navigation_analytics) { + presenter.trackDataSetAnalytics(); + newFragment = GroupAnalyticsFragment.Companion.forDataSet(dataSetUid); } FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); if (fragment == null ||(newFragment != null && !fragment.getClass().toString().equals(newFragment.getClass().toString()))) { diff --git a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java index cbb88bb39bc..e91e260d705 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryImpl.java @@ -1,5 +1,6 @@ package org.dhis2.usescases.datasets.datasetDetail; +import static org.dhis2.data.dhislogic.AuthoritiesKt.AUTH_ALL; import static org.dhis2.data.dhislogic.AuthoritiesKt.AUTH_DATAVALUE_ADD; import org.dhis2.commons.resources.DhisPeriodUtils; @@ -147,7 +148,11 @@ public Flowable canWriteAny() { } private boolean hasDataValueAuthority() { - return !d2.userModule().authorities().byName().eq(AUTH_DATAVALUE_ADD).blockingIsEmpty(); + return !d2.userModule() + .authorities() + .byName() + .in(AUTH_DATAVALUE_ADD, AUTH_ALL) + .blockingIsEmpty(); } @Override diff --git a/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt b/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt index 601483fe600..99c7dced63c 100644 --- a/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt +++ b/app/src/main/java/org/dhis2/usescases/development/ConflictGenerator.kt @@ -1,3 +1,5 @@ +@file:OptIn(LegacyDataValueApi::class) + package org.dhis2.usescases.development import kotlinx.coroutines.runBlocking @@ -8,6 +10,7 @@ import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.datavalue.DataValue import org.hisp.dhis.android.core.datavalue.DataValueConflict +import org.hisp.dhis.android.core.datavalue.LegacyDataValueApi import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.imports.ImportStatus @@ -125,24 +128,37 @@ class ConflictGenerator( dataValueConflict.categoryOptionCombo() != null && dataValueConflict.attributeOptionCombo() != null ) { - val dataValue = - d2 - .dataValueModule() - .dataValues() - .value( - dataValueConflict.period()!!, - dataValueConflict.orgUnit()!!, - dataValueConflict.dataElement()!!, - dataValueConflict.categoryOptionCombo()!!, - dataValueConflict.attributeOptionCombo()!!, - ).blockingGet() - val dv = - dataValue?.toBuilder()?.syncState(State.SYNCED)?.build() - dv?.let { - runBlocking { - d2.databaseAdapter().upsertObject(it, DataValue::class) + d2 + .dataSetModule() + .dataSetCompleteRegistrations() + .byPeriod() + .eq(dataValueConflict.period()!!) + .byOrganisationUnitUid() + .eq(dataValueConflict.orgUnit()!!) + .byAttributeOptionComboUid() + .eq(dataValueConflict.attributeOptionCombo()!!) + .blockingGet() + .forEach { + val dataValue = + d2 + .dataValueModule() + .dataValues() + .value( + dataValueConflict.period()!!, + dataValueConflict.orgUnit()!!, + dataValueConflict.dataElement()!!, + dataValueConflict.categoryOptionCombo()!!, + dataValueConflict.attributeOptionCombo()!!, + it.dataSet(), + ).blockingGet() + val dv = + dataValue?.toBuilder()?.syncState(State.SYNCED)?.build() + dv?.let { + runBlocking { + d2.databaseAdapter().upsertObject(it, DataValue::class) + } + } } - } } }.also { runBlocking { @@ -423,7 +439,9 @@ class ConflictGenerator( try { runBlocking { d2.databaseAdapter().upsertObject(conflict, TrackerImportConflict::class) - d2.databaseAdapter().execSQL(updateEvent(event.uid(), importStatus.toSyncState().name)) + d2 + .databaseAdapter() + .execSQL(updateEvent(event.uid(), importStatus.toSyncState().name)) } } catch (e: Exception) { Timber.e(e) @@ -529,7 +547,7 @@ class ConflictGenerator( syncState: String, ): String = "UPDATE Enrollment SET syncState = '$syncState'," + - " aggregatedSyncState = '$syncState' where uid = '$enrollmentUid'" + " aggregatedSyncState = '$syncState' where uid = '$enrollmentUid'" private fun updateTei( teiUid: String, 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 ee2beaba097..89b87399ec2 100644 --- a/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt +++ b/app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt @@ -9,7 +9,6 @@ import io.reactivex.processors.PublishProcessor import org.dhis2.commons.data.EntryMode import org.dhis2.commons.di.dagger.PerActivity import org.dhis2.commons.matomo.MatomoAnalyticsController -import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider @@ -43,7 +42,7 @@ import org.dhis2.form.ui.provider.LegendValueProviderImpl import org.dhis2.form.ui.provider.UiEventTypesProviderImpl import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.dhis2.mobile.commons.customintents.CustomIntentRepositoryImpl -import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.simprints.SimprintsCustomIntentResultMapper import org.dhis2.simprints.SimprintsEnrollmentViewModel @@ -240,23 +239,22 @@ class EnrollmentModule( d2: D2, enrollmentRepository: EnrollmentObjectRepository, crashReportController: CrashReportController, - networkUtils: NetworkUtils, searchTEIRepository: SearchTEIRepository, resourceManager: ResourceManager, - ): ValueStore { - val fieldErrorMessageProvider = FieldErrorMessageProvider() - return ValueStoreImpl( + networkStatusProvider: NetworkStatusProvider, + dispatcherProvider: DispatcherProvider, + ): ValueStore = + ValueStoreImpl( d2, enrollmentRepository.blockingGet()?.trackedEntityInstance()!!, EntryMode.ATTR, DhisEnrollmentUtils(d2), crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatcherProvider, ) - } @Provides @PerActivity diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt index 1341577322f..daa9d93d5db 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt @@ -572,8 +572,20 @@ class EventCaptureActivity : .onDismissListener( object : OnDismissListener { override fun onDismiss(hasChanged: Boolean) { - if (hasChanged && syncType == TEI_SYNC) { - dashboardViewModel?.updateDashboard() + if (hasChanged) { + when (syncType) { + TEI_SYNC -> { + dashboardViewModel?.updateDashboard() + } + + EVENT_SYNC -> { + if (isLandscape()) { + eventUid?.let { uid -> setUpEventCaptureFormLandscape(uid) } + } else { + adapter?.refreshDataEntry() + } + } + } } } }, diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt index ac344e9bf8f..688f6cad1b1 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt @@ -110,9 +110,9 @@ class EventCaptureContract { fun deleteEvent(): Observable - fun updateEventStatus(skipped: EventStatus): Observable + fun updateEventStatus(status: EventStatus): Observable - fun rescheduleEvent(time: Date): Observable + fun rescheduleEvent(newDate: Date): Observable fun programStage(): Observable diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt index 95f54659e26..7d3124bcd97 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureModule.kt @@ -62,7 +62,7 @@ class EventCaptureModule( @Provides @PerActivity - fun provideRepository(d2: D2?): EventCaptureRepository = EventCaptureRepositoryImpl(eventUid, d2) + fun provideRepository(d2: D2): EventCaptureRepository = EventCaptureRepositoryImpl(d2, eventUid) @Provides @PerActivity diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt index 810b1550e3a..c6a9aec9f7e 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt @@ -25,6 +25,7 @@ class EventCapturePagerAdapter( ) : FragmentStateAdapter(fragmentActivity) { private val landscapePages: MutableList = ArrayList() private val portraitPages: MutableList = ArrayList() + private val itemIds: MutableList = ArrayList() fun isFormScreenShown(currentItem: Int?): Boolean = currentItem != null && portraitPages[currentItem] == EventPageType.DATA_ENTRY @@ -50,6 +51,12 @@ class EventCapturePagerAdapter( } portraitPages.add(EventPageType.NOTES) landscapePages.add(EventPageType.NOTES) + + if (isPortrait) { + portraitPages.forEach { itemIds.add(it.ordinal * 1000L) } + } else { + landscapePages.forEach { itemIds.add(it.ordinal * 1000L) } + } } override fun createFragment(position: Int): Fragment = @@ -125,4 +132,14 @@ class EventCapturePagerAdapter( companion object { const val NO_POSITION: Int = -1 } + + fun refreshDataEntry() { + val index = portraitPages.indexOf(EventPageType.DATA_ENTRY) + itemIds[index] = itemIds[index] + 1 + notifyItemChanged(index) + } + + override fun getItemId(position: Int): Long { + return itemIds[position] + } } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java deleted file mode 100644 index 036c40507d4..00000000000 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java +++ /dev/null @@ -1,239 +0,0 @@ -package org.dhis2.usescases.eventsWithoutRegistration.eventCapture; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.dhis2.commons.bindings.SdkExtensionsKt; -import org.dhis2.data.dhislogic.AuthoritiesKt; -import org.hisp.dhis.android.core.D2; -import org.hisp.dhis.android.core.common.BaseIdentifiableObject; -import org.hisp.dhis.android.core.common.ValidationStrategy; -import org.hisp.dhis.android.core.enrollment.Enrollment; -import org.hisp.dhis.android.core.enrollment.EnrollmentStatus; -import org.hisp.dhis.android.core.event.Event; -import org.hisp.dhis.android.core.event.EventEditableStatus; -import org.hisp.dhis.android.core.event.EventNonEditableReason; -import org.hisp.dhis.android.core.event.EventStatus; -import org.hisp.dhis.android.core.organisationunit.OrganisationUnit; -import org.hisp.dhis.android.core.program.ProgramRule; -import org.hisp.dhis.android.core.program.ProgramRuleAction; -import org.hisp.dhis.android.core.program.ProgramRuleActionType; -import org.hisp.dhis.android.core.settings.ProgramConfigurationSetting; - -import java.util.Date; -import java.util.List; -import java.util.Objects; - -import io.reactivex.Flowable; -import io.reactivex.Observable; -import io.reactivex.Single; - -public class EventCaptureRepositoryImpl implements EventCaptureContract.EventCaptureRepository { - - private final String eventUid; - private final D2 d2; - - public EventCaptureRepositoryImpl(String eventUid, D2 d2) { - this.eventUid = eventUid; - this.d2 = d2; - } - - private Event getCurrentEvent() { - return d2.eventModule().events().uid(eventUid).blockingGet(); - } - - @Override - public boolean isEnrollmentOpen() { - Event currentEvent = getCurrentEvent(); - return currentEvent.enrollment() == null || d2.enrollmentModule().enrollmentService().blockingIsOpen(currentEvent.enrollment()); - } - - @Override - public boolean isEnrollmentCancelled() { - Enrollment enrollment = d2.enrollmentModule().enrollments().uid(getCurrentEvent().enrollment()).blockingGet(); - if (enrollment == null) - return false; - else - return enrollment.status() == EnrollmentStatus.CANCELLED; - } - - @Override - public boolean isEventEditable(@NonNull String eventUid) { - return d2.eventModule().eventService().blockingIsEditable(eventUid); - } - - @NonNull - @Override - public Flowable programStageName() { - return d2.programModule().programStages().uid(getCurrentEvent().programStage()).get() - .map(BaseIdentifiableObject::displayName) - .toFlowable(); - } - - @NonNull - @Override - public Flowable orgUnit() { - return Flowable.just( - Objects.requireNonNull( - d2.organisationUnitModule() - .organisationUnits() - .uid(getCurrentEvent().organisationUnit()) - .blockingGet() - ) - ); - } - - @NonNull - @Override - public Observable deleteEvent() { - return d2.eventModule().events().uid(eventUid).delete() - .andThen(Observable.just(true)); - } - - @NonNull - @Override - public Observable updateEventStatus(@NonNull EventStatus status) { - - return Observable.fromCallable(() -> { - d2.eventModule().events().uid(eventUid) - .setStatus(status); - return true; - }); - } - - @NonNull - @Override - public Observable rescheduleEvent(@NonNull Date newDate) { - return Observable.fromCallable(() -> { - d2.eventModule().events().uid(eventUid) - .setDueDate(newDate); - d2.eventModule().events().uid(eventUid) - .setStatus(EventStatus.SCHEDULE); - return true; - }); - } - - @NonNull - @Override - public Observable programStage() { - return Observable.just(Objects.requireNonNull(getCurrentEvent().programStage())); - } - - @Override - public boolean getAccessDataWrite() { - return d2.eventModule().eventService().blockingIsEditable(eventUid); - } - - @NonNull - @Override - public Flowable eventStatus() { - return Flowable.just(Objects.requireNonNull(getCurrentEvent().status())); - } - - @NonNull - @Override - public Single canReOpenEvent() { - return Single.fromCallable(() -> d2.userModule().authorities() - .byName().in(AuthoritiesKt.AUTH_UNCOMPLETE_EVENT, AuthoritiesKt.AUTH_ALL).one().blockingExists() - ); - } - - @NonNull - @Override - public Observable isCompletedEventExpired(@NonNull String eventUid) { - return d2.eventModule().eventService().getEditableStatus(eventUid).map(editionStatus -> { - if (editionStatus instanceof EventEditableStatus.NonEditable nonEditableStatus) { - return nonEditableStatus.getReason() == EventNonEditableReason.EXPIRED; - } else { - return false; - } - }).toObservable(); - } - - @NonNull - @Override - public Flowable eventIntegrityCheck() { - Event currentEvent = getCurrentEvent(); - return Flowable.just(currentEvent).map(event -> - (event.status() == EventStatus.COMPLETED || - event.status() == EventStatus.ACTIVE || - event.status() == EventStatus.SKIPPED) && - (event.eventDate() == null || !event.eventDate().after(new Date())) - ); - } - - @NonNull - @Override - public Single getNoteCount() { - return d2.noteModule().notes().byEventUid().eq(eventUid).count(); - } - - @Override - public boolean showCompletionPercentage() { - if (getCurrentEvent() != null && d2.settingModule().appearanceSettings().blockingExists()) { - ProgramConfigurationSetting programConfigurationSetting = d2.settingModule() - .appearanceSettings() - .getProgramConfigurationByUid(getCurrentEvent().program()); - - if (programConfigurationSetting != null && - programConfigurationSetting.completionSpinner() != null) { - return programConfigurationSetting.completionSpinner(); - } - } - return true; - } - - @Override - public boolean hasAnalytics() { - Event currentEvent = getCurrentEvent(); - boolean hasProgramIndicators = !d2.programModule().programIndicators().byProgramUid().eq(currentEvent.program()).blockingIsEmpty(); - List programRules = d2.programModule().programRules().withProgramRuleActions() - .byProgramUid().eq(currentEvent.program()).blockingGet(); - boolean hasProgramRules = false; - for (ProgramRule rule : programRules) { - for (ProgramRuleAction action : Objects.requireNonNull(rule.programRuleActions())) { - if (action.programRuleActionType() == ProgramRuleActionType.DISPLAYKEYVALUEPAIR || - action.programRuleActionType() == ProgramRuleActionType.DISPLAYTEXT) { - hasProgramRules = true; - } - } - } - return hasProgramIndicators || hasProgramRules; - } - - @Override - public boolean hasRelationships() { - return !d2.relationshipModule().relationshipTypes() - .byAvailableForEvent(eventUid) - .blockingIsEmpty(); - } - - @NonNull - @Override - public ValidationStrategy validationStrategy() { - ValidationStrategy validationStrategy = - SdkExtensionsKt.programStage(d2, programStage().blockingFirst()) - .validationStrategy(); - - return validationStrategy != null ? validationStrategy : ValidationStrategy.ON_COMPLETE; - } - - @Override - @Nullable - public String getEnrollmentUid() { - Event currentEvent = getCurrentEvent(); - if (currentEvent == null) { - return null; - }else{ - return currentEvent.enrollment(); - } - } - - @Override - @Nullable - public String getTeiUid() { - Enrollment enrollment = d2.enrollmentModule().enrollments().uid(getEnrollmentUid()).blockingGet(); - return enrollment != null ? enrollment.trackedEntityInstance() : null; - } -} - diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.kt new file mode 100644 index 00000000000..72fbf8170a0 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.kt @@ -0,0 +1,199 @@ +package org.dhis2.usescases.eventsWithoutRegistration.eventCapture + +import io.reactivex.Flowable +import io.reactivex.Observable +import io.reactivex.Single +import org.dhis2.commons.bindings.programStage +import org.dhis2.data.dhislogic.AUTH_ALL +import org.dhis2.data.dhislogic.AUTH_UNCOMPLETE_EVENT +import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventCaptureContract.EventCaptureRepository +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ValidationStrategy +import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.hisp.dhis.android.core.event.EventEditableStatus +import org.hisp.dhis.android.core.event.EventNonEditableReason +import org.hisp.dhis.android.core.event.EventStatus +import org.hisp.dhis.android.core.organisationunit.OrganisationUnit +import org.hisp.dhis.android.core.program.ProgramRuleActionType +import java.util.Date +import java.util.Objects +import java.util.concurrent.Callable + +class EventCaptureRepositoryImpl( + private val d2: D2, + private val eventUid: String, +) : EventCaptureRepository { + private val currentEvent by lazy { + requireNotNull( + d2.eventModule().events().uid(eventUid).blockingGet() + ) + } + + override val isEnrollmentOpen: Boolean + get() { + val currentEvent = this.currentEvent + return currentEvent.enrollment() == null || d2.enrollmentModule().enrollmentService() + .blockingIsOpen(currentEvent.enrollment()!!) + } + + override val isEnrollmentCancelled: Boolean + get() { + val enrollment = + d2.enrollmentModule().enrollments().uid(this.currentEvent.enrollment()) + .blockingGet() + return if (enrollment == null) { + false + } else { + enrollment.status() == EnrollmentStatus.CANCELLED + } + } + + override fun isEventEditable(eventUid: String): Boolean { + return d2.eventModule().eventService().blockingIsEditable(eventUid) + } + + override fun programStageName(): Flowable { + return d2.programModule().programStages().uid(this.currentEvent.programStage()).get() + .map { programStage -> programStage.displayName() ?: programStage.uid() } + .toFlowable() + } + + override fun orgUnit(): Flowable { + return Flowable.just( + Objects.requireNonNull( + d2.organisationUnitModule() + .organisationUnits() + .uid(this.currentEvent.organisationUnit()) + .blockingGet() + ) + ) + } + + override fun deleteEvent(): Observable { + return d2.eventModule().events().uid(eventUid).delete() + .andThen(Observable.just(true)) + } + + override fun updateEventStatus(status: EventStatus): Observable { + return Observable.fromCallable(Callable { + d2.eventModule().events().uid(eventUid) + .setStatus(status) + true + }) + } + + override fun rescheduleEvent(newDate: Date): Observable { + return Observable.fromCallable(Callable { + d2.eventModule().events().uid(eventUid) + .setDueDate(newDate) + d2.eventModule().events().uid(eventUid) + .setStatus(EventStatus.SCHEDULE) + true + }) + } + + override fun programStage(): Observable { + return Observable.just( + Objects.requireNonNull( + this.currentEvent.programStage() + ) + ) + } + + override val accessDataWrite: Boolean + get() = d2.eventModule().eventService().blockingIsEditable(eventUid) + + override fun eventStatus(): Flowable { + return Flowable.just(Objects.requireNonNull(this.currentEvent.status())) + } + + override fun canReOpenEvent(): Single { + return Single.fromCallable(Callable { + d2.userModule().authorities() + .byName().`in`(AUTH_UNCOMPLETE_EVENT, AUTH_ALL).one().blockingExists() + } + ) + } + + override fun isCompletedEventExpired(eventUid: String): Observable { + return d2.eventModule().eventService().getEditableStatus(eventUid) + .map { editionStatus -> + if (editionStatus is EventEditableStatus.NonEditable) { + editionStatus.reason == EventNonEditableReason.EXPIRED + } else { + false + } + }.toObservable() + } + + override fun eventIntegrityCheck(): Flowable { + val currentEvent = this.currentEvent + return Flowable.just(currentEvent).map { event -> + (event.status() == EventStatus.COMPLETED || event.status() == EventStatus.ACTIVE || event.status() == EventStatus.SKIPPED) && + (event.eventDate() == null || !event.eventDate()!!.after(Date())) + } + } + + override val noteCount: Single + get() = d2.noteModule().notes().byEventUid().eq(eventUid).count() + + override fun showCompletionPercentage(): Boolean { + if (d2.settingModule().appearanceSettings().blockingExists()) { + val programConfigurationSetting = d2.settingModule() + .appearanceSettings() + .getProgramConfigurationByUid(this.currentEvent.program()) + + if (programConfigurationSetting != null && + programConfigurationSetting.completionSpinner() != null + ) { + return programConfigurationSetting.completionSpinner()!! + } + } + return true + } + + override fun hasAnalytics(): Boolean { + val currentEvent = this.currentEvent + val hasProgramIndicators = + !d2.programModule().programIndicators().byProgramUid().eq(currentEvent.program()) + .blockingIsEmpty() + val programRules = + d2.programModule().programRules().withProgramRuleActions() + .byProgramUid().eq(currentEvent.program()).blockingGet() + var hasProgramRules = false + for (rule in programRules) { + for (action in Objects.requireNonNull(rule.programRuleActions())) { + if (action.programRuleActionType() == ProgramRuleActionType.DISPLAYKEYVALUEPAIR || + action.programRuleActionType() == ProgramRuleActionType.DISPLAYTEXT + ) { + hasProgramRules = true + } + } + } + return hasProgramIndicators || hasProgramRules + } + + override fun hasRelationships(): Boolean { + return !d2.relationshipModule().relationshipTypes() + .byAvailableForEvent(eventUid) + .blockingIsEmpty() + } + + override fun validationStrategy(): ValidationStrategy { + val validationStrategy = + d2.programStage(programStage().blockingFirst()!!)!! + .validationStrategy() + + return validationStrategy ?: ValidationStrategy.ON_COMPLETE + } + + override fun getEnrollmentUid(): String? { + return currentEvent.enrollment() + } + + override fun getTeiUid(): String? { + val enrollment = d2.enrollmentModule().enrollments().uid(getEnrollmentUid()).blockingGet() + return enrollment?.trackedEntityInstance() + } +} + diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt index 86ca05bc1c7..4bf38c2a121 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/eventCaptureFragment/EventCaptureFormPresenter.kt @@ -72,8 +72,8 @@ class EventCaptureFormPresenter( 1, ) to false - EventNonEditableReason.ORGUNIT_IS_NOT_IN_CAPTURE_SCOPE -> - resourceManager.getString(R.string.edition_orgunit_capture_scope) to + EventNonEditableReason.ORGUNIT_IS_NOT_IN_USER_SCOPE -> + resourceManager.getString(R.string.edition_orgunit_user_scope) to false } view.showNonEditableMessage(reason, canBeReOpened) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt index 5057dbcf06d..5fe7fc1a62f 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventDetails/providers/EventDetailResourcesProvider.kt @@ -46,8 +46,8 @@ class EventDetailResourcesProvider( R.string.edition_enrollment_is_no_open_V2, 1, ) - EventNonEditableReason.ORGUNIT_IS_NOT_IN_CAPTURE_SCOPE -> - resourceManager.getString(R.string.edition_orgunit_capture_scope) + EventNonEditableReason.ORGUNIT_IS_NOT_IN_USER_SCOPE -> + resourceManager.getString(R.string.edition_orgunit_user_scope) } fun provideButtonUpdate() = resourceManager.getString(R.string.update) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt index 1672ed3d2a0..180a1523caf 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImpl.kt @@ -315,6 +315,7 @@ class EventInitialRepositoryImpl internal constructor( .programStageSections() .byProgramStageUid() .eq(stage!!.uid()) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() stageSections @@ -369,6 +370,7 @@ class EventInitialRepositoryImpl internal constructor( .withDataElements() .byProgramStageUid() .eq(event.programStage()) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() val stageDataElements = d2 diff --git a/app/src/main/java/org/dhis2/usescases/general/ActivityGlobalAbstract.java b/app/src/main/java/org/dhis2/usescases/general/ActivityGlobalAbstract.java index cbb05962c84..d78c791a753 100644 --- a/app/src/main/java/org/dhis2/usescases/general/ActivityGlobalAbstract.java +++ b/app/src/main/java/org/dhis2/usescases/general/ActivityGlobalAbstract.java @@ -74,7 +74,7 @@ public void showMoreOptions(View view) { return Unit.INSTANCE; }) .onMenuItemClicked(item -> { - analyticsHelper.setEvent(SHOW_HELP, CLICK, SHOW_HELP); + getAnalyticsHelper().setEvent(SHOW_HELP, CLICK, SHOW_HELP); showTutorial(false); return false; }) @@ -191,6 +191,6 @@ public void showSyncDialog(SyncStatusDialog dialog) { @Override public AnalyticsHelper analyticsHelper() { - return analyticsHelper; + return getAnalyticsHelper(); } } diff --git a/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt b/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt index 9ccd6cd9450..92282fdaa75 100644 --- a/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/general/SessionManagerActivity.kt @@ -7,10 +7,10 @@ import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityOptionsCompat import androidx.lifecycle.lifecycleScope -import io.reactivex.Observable import io.reactivex.subjects.BehaviorSubject -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.dhis2.App +import org.dhis2.BuildConfig import org.dhis2.R import org.dhis2.bindings.app import org.dhis2.commons.ActivityResultObservable @@ -18,10 +18,11 @@ import org.dhis2.commons.ActivityResultObserver import org.dhis2.commons.locationprovider.LocationProvider import org.dhis2.commons.service.SessionManagerServiceImpl import org.dhis2.commons.ui.extensions.handleInsets -import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.server.OpenIdSession.LogOutReason -import org.dhis2.data.service.SyncStatusController import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.login.pin.addPinBottomSheet +import org.dhis2.mobile.login.pin.domain.model.PinMode +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.login.LoginActivity import org.dhis2.usescases.login.LoginActivity.Companion.bundle import org.dhis2.usescases.main.MainActivity @@ -30,8 +31,7 @@ import org.dhis2.usescases.splash.SplashActivity import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.CLICK import org.dhis2.utils.analytics.FORGOT_CODE -import org.dhis2.utils.session.PIN_DIALOG_TAG -import org.dhis2.utils.session.PinDialog +import org.koin.android.ext.android.inject import javax.inject.Inject abstract class SessionManagerActivity : @@ -46,28 +46,16 @@ abstract class SessionManagerActivity : @Inject lateinit var locationProvider: LocationProvider - fun observableLifeCycle(): Observable = lifeCycleObservable - open var handleEdgeToEdge = true - @Inject - lateinit var analyticsHelper: AnalyticsHelper + val analyticsHelper: AnalyticsHelper by inject() - private var pinDialog: PinDialog? = null + private var pinComposeView: androidx.compose.ui.platform.ComposeView? = null private var lifeCycleObservable: BehaviorSubject = BehaviorSubject.create() - var syncStatusController: SyncStatusController = - SyncStatusController( - object : DispatcherProvider { - override fun io() = Dispatchers.IO - - override fun computation() = Dispatchers.Default - - override fun ui() = Dispatchers.Main - }, - ) + val syncStatusController: SyncStatusController by inject() override fun onCreate(savedInstanceState: Bundle?) { val serverComponent = (applicationContext as App).serverComponent @@ -82,11 +70,12 @@ abstract class SessionManagerActivity : true, null, ) - Unit } - if (serverComponent.userManager().isUserLoggedIn().blockingFirst() && - !serverComponent.userManager().allowScreenShare() - ) { + val isTraining = BuildConfig.FLAVOR == "dhis2Training" + val screenShareAllowed = + serverComponent.userManager().isUserLoggedIn().blockingFirst() && + !serverComponent.userManager().allowScreenShare() + if (!isTraining && screenShareAllowed) { window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, @@ -147,23 +136,27 @@ abstract class SessionManagerActivity : this.activityResultObserver = activityResultObserver } - private fun initPinDialog() { - pinDialog = - PinDialog( - PinDialog.Mode.ASK, - (this is LoginActivity), - { - startActivity(MainActivity::class.java, null, true, true, null) - null - }, - { - analyticsHelper.setEvent(FORGOT_CODE, CLICK, FORGOT_CODE) - if (this !is LoginActivity) { - startActivity(LoginActivity::class.java, null, true, true, null) - } - null - }, - ) + private fun showPinBottomSheet() { + if (pinComposeView != null) return + pinComposeView = addPinBottomSheet( + mode = PinMode.ASK, + onSuccess = { + startActivity(MainActivity::class.java, null, true, true, null) + }, + onDismiss = { + analyticsHelper.setEvent(FORGOT_CODE, CLICK, FORGOT_CODE) + if (this !is LoginActivity) { + startActivity(LoginActivity::class.java, null, true, true, null) + } + }, + ) + } + + private fun removePinBottomSheet() { + pinComposeView?.let { view -> + (window?.decorView as? android.view.ViewGroup)?.removeView(view) + } + pinComposeView = null } override fun unsubscribe() { @@ -196,17 +189,13 @@ abstract class SessionManagerActivity : if (finishCurrent) finish() } - private fun showPinDialog() { - pinDialog!!.show(supportFragmentManager, PIN_DIALOG_TAG) - } - @Deprecated("Deprecated in Java") override fun onActivityResult( requestCode: Int, resultCode: Int, data: Intent?, ) { - if (activityResultObserver != null && sessionManagerServiceImpl.isUserLoggedIn()) { + if (activityResultObserver != null && ::sessionManagerServiceImpl.isInitialized && sessionManagerServiceImpl.isUserLoggedIn()) { comesFromImageSource = true activityResultObserver!!.onActivityResult(requestCode, resultCode, data) activityResultObserver = null @@ -223,14 +212,15 @@ abstract class SessionManagerActivity : this !is LoginActivity ) { workManagerController.cancelAllWork() - syncStatusController.restore() + lifecycleScope.launch { + syncStatusController.restore() + } } } override fun onStop() { super.onStop() - val dialog = pinDialog - dialog?.dismissAllowingStateLoss() + removePinBottomSheet() } override fun onDestroy() { @@ -254,11 +244,8 @@ abstract class SessionManagerActivity : comesFromImageSource = false } else { if (this.app().isSessionBlocked && this !is SplashActivity && this !is LoginActivity) { - if (pinDialog == null) { - initPinDialog() - showPinDialog() - } else if (pinDialog?.isVisible == false) { - showPinDialog() + if (pinComposeView == null) { + showPinBottomSheet() } } else { if (this !is LoginActivity && this !is SplashActivity) { @@ -270,9 +257,8 @@ abstract class SessionManagerActivity : private fun sessionAction(accountsCount: Int) { if (this.app().isSessionBlocked && this !is SplashActivity) { - if (pinDialog == null) { - initPinDialog() - showPinDialog() + if (pinComposeView == null) { + showPinBottomSheet() } } else { navigateToLogin(accountsCount) @@ -282,7 +268,7 @@ abstract class SessionManagerActivity : private fun navigateToLogin(accountsCount: Int) { startActivity( LoginActivity::class.java, - LoginActivity.bundle( + bundle( accountsCount = accountsCount, isDeletion = false, ), diff --git a/app/src/main/java/org/dhis2/usescases/login/LoginActivity.kt b/app/src/main/java/org/dhis2/usescases/login/LoginActivity.kt index 17341a2e655..a60ba03698b 100644 --- a/app/src/main/java/org/dhis2/usescases/login/LoginActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/login/LoginActivity.kt @@ -15,6 +15,7 @@ import org.dhis2.commons.Constants.SESSION_DIALOG_RQ import org.dhis2.commons.dialogs.CustomDialog import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.server.OpenIdSession +import org.dhis2.mobile.login.authentication.OpenIdController import org.dhis2.mobile.login.main.ui.navigation.AppLinkNavigation import org.dhis2.mobile.login.main.ui.screen.LoginScreen import org.dhis2.usescases.about.PolicyView @@ -25,6 +26,7 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.koin.android.ext.android.inject import javax.inject.Inject +import kotlin.getValue const val EXTRA_SKIP_SYNC = "SKIP_SYNC" const val FROM_MAIN_ACTIVITY = "FROM_MAIN_ACTIVITY" @@ -41,6 +43,7 @@ class LoginActivity : ActivityGlobalAbstract() { lateinit var resourceManager: ResourceManager private val appLinkNavigation: AppLinkNavigation by inject() + private val openIdController: OpenIdController by inject() private var isPinScreenVisible = false @@ -84,6 +87,8 @@ class LoginActivity : ActivityGlobalAbstract() { ) super.onCreate(savedInstanceState) + openIdController.bind(this) + checkMessage() skipSync = intent.getBooleanExtra(EXTRA_SKIP_SYNC, false) @@ -130,6 +135,11 @@ class LoginActivity : ActivityGlobalAbstract() { } } + override fun onDestroy() { + super.onDestroy() + openIdController.unbind() + } + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (isPinScreenVisible) { diff --git a/app/src/main/java/org/dhis2/usescases/login/SyncIsPerformedInteractor.kt b/app/src/main/java/org/dhis2/usescases/login/SyncIsPerformedInteractor.kt deleted file mode 100644 index cd676dd789b..00000000000 --- a/app/src/main/java/org/dhis2/usescases/login/SyncIsPerformedInteractor.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.dhis2.usescases.login - -import org.dhis2.data.server.UserManager -import org.dhis2.usescases.sync.WAS_INITIAL_SYNC_DONE - -class SyncIsPerformedInteractor( - private val userManager: UserManager?, -) { - fun execute(): Boolean { - if (userManager == null) return false - - val entryExists = - userManager.d2 - .dataStoreModule() - .localDataStore() - .value( - WAS_INITIAL_SYNC_DONE, - ).blockingExists() - - val serverUrl = - userManager.d2 - .systemInfoModule() - .systemInfo() - .blockingGet() - ?.contextPath() - val username = - userManager.d2 - .userModule() - .user() - .blockingGet() - ?.username() - - val dataBaseIsImport = - userManager.d2 - .userModule() - .accountManager() - .getCurrentAccount() - ?.let { - it.serverUrl() == serverUrl && it.username() == username && it.importDB() != null - } ?: false - - return when { - dataBaseIsImport -> true - entryExists -> { - val entry = - userManager.d2 - .dataStoreModule() - .localDataStore() - .value( - WAS_INITIAL_SYNC_DONE, - ).blockingGet() - !entry?.value().isNullOrEmpty() && entry?.value() == "True" - } - - else -> false - } - } -} diff --git a/app/src/main/java/org/dhis2/usescases/main/HomePageConfigurator.kt b/app/src/main/java/org/dhis2/usescases/main/HomePageConfigurator.kt deleted file mode 100644 index 8563d33d439..00000000000 --- a/app/src/main/java/org/dhis2/usescases/main/HomePageConfigurator.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.dhis2.usescases.main - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BarChart -import org.dhis2.R -import org.dhis2.commons.resources.ResourceManager -import org.dhis2.usescases.main.ui.Form -import org.dhis2.utils.customviews.navigationbar.NavigationPage -import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator -import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem - -class HomePageConfigurator( - private val homeRepository: HomeRepository, - private val resourceManager: ResourceManager, -) : NavigationPageConfigurator { - override fun displayPrograms(): Boolean = true - - override fun displayAnalytics(): Boolean = homeRepository.hasHomeAnalytics() - - override fun navigationItems(): List> = - buildList { - add( - NavigationBarItem( - id = NavigationPage.PROGRAMS, - icon = Icons.Filled.Form, - label = resourceManager.getString(R.string.navigation_programs), - ), - ) - if (displayAnalytics()) { - add( - NavigationBarItem( - id = NavigationPage.ANALYTICS, - icon = Icons.Filled.BarChart, - label = resourceManager.getString(R.string.navigation_charts), - ), - ) - } - } -} diff --git a/app/src/main/java/org/dhis2/usescases/main/HomeRepository.kt b/app/src/main/java/org/dhis2/usescases/main/HomeRepository.kt deleted file mode 100644 index d58baa1ecf9..00000000000 --- a/app/src/main/java/org/dhis2/usescases/main/HomeRepository.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dhis2.usescases.main - -import io.reactivex.Single -import org.hisp.dhis.android.core.category.CategoryCombo -import org.hisp.dhis.android.core.category.CategoryOptionCombo -import org.hisp.dhis.android.core.systeminfo.SystemInfo -import org.hisp.dhis.android.core.user.User - -interface HomeRepository { - fun user(): Single - - fun defaultCatCombo(): Single - - fun defaultCatOptCombo(): Single - - fun hasProgramWithAssignment(): Boolean - - fun checkDeleteBiometricsPermission() - - fun hasHomeAnalytics(): Boolean - - fun getServerVersion(): Single - - fun accountsCount(): Int - - fun isPinStored(): Boolean - - fun homeItemCount(): Int - - suspend fun singleHomeItemData(): HomeItemData? - - suspend fun logOut(): Result - - suspend fun clearSessionLock(): Result -} diff --git a/app/src/main/java/org/dhis2/usescases/main/HomeRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/main/HomeRepositoryImpl.kt deleted file mode 100644 index 3161fe3b480..00000000000 --- a/app/src/main/java/org/dhis2/usescases/main/HomeRepositoryImpl.kt +++ /dev/null @@ -1,159 +0,0 @@ -package org.dhis2.usescases.main - -import dhis2.org.analytics.charts.Charts -import io.reactivex.Single -import kotlinx.coroutines.withContext -import org.dhis2.commons.bindings.dataSet -import org.dhis2.commons.bindings.dataSetInstanceSummaries -import org.dhis2.commons.bindings.isStockProgram -import org.dhis2.commons.bindings.programs -import org.dhis2.commons.prefs.Preference -import org.dhis2.commons.prefs.Preference.Companion.PIN -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.mobile.commons.biometrics.CryptographicActions -import org.dhis2.mobile.commons.coroutine.Dispatcher -import org.dhis2.mobile.commons.error.DomainErrorMapper -import org.hisp.dhis.android.core.D2 -import org.hisp.dhis.android.core.category.CategoryCombo -import org.hisp.dhis.android.core.category.CategoryOptionCombo -import org.hisp.dhis.android.core.maintenance.D2Error -import org.hisp.dhis.android.core.program.ProgramType -import org.hisp.dhis.android.core.systeminfo.SystemInfo -import org.hisp.dhis.android.core.user.User - -class HomeRepositoryImpl( - private val d2: D2, - private val charts: Charts?, - private val preferences: PreferenceProvider, - private val cryptographyManager: CryptographicActions, - private val dispatcher: Dispatcher, - private val domainErrorMapper: DomainErrorMapper, -) : HomeRepository { - companion object { - const val BIOMETRICS_PERMISSION = "biometrics_permission" - } - - private suspend fun execute(block: suspend () -> Result): Result = - withContext(dispatcher.io) { - try { - block() - } catch (d2Error: D2Error) { - Result.failure(domainErrorMapper.mapToDomainError(d2Error)) - } - } - - override fun user(): Single = d2.userModule().user().get() - - override fun defaultCatCombo(): Single = - d2 - .categoryModule() - .categoryCombos() - .byIsDefault() - .eq(true) - .one() - .get() - - override fun defaultCatOptCombo(): Single = - d2 - .categoryModule() - .categoryOptionCombos() - .byCode() - .eq(DEFAULT) - .one() - .get() - - override suspend fun logOut(): Result = - execute { - Result.success(d2.userModule().blockingLogOut()) - } - - override suspend fun clearSessionLock(): Result = - execute { - preferences.setValue(Preference.SESSION_LOCKED, false) - d2 - .dataStoreModule() - .localDataStore() - .value(PIN) - .blockingDeleteIfExist() - Result.success(Unit) - } - - override fun hasProgramWithAssignment(): Boolean = - if (d2.userModule().isLogged().blockingGet()) { - !d2 - .programModule() - .programStages() - .byEnableUserAssignment() - .isTrue - .blockingIsEmpty() - } else { - false - } - - override fun checkDeleteBiometricsPermission() { - val hasLessThanTwoAccounts = - d2 - .userModule() - .accountManager() - .getAccounts() - .count() <= 2 - if (hasLessThanTwoAccounts) { - preferences.removeValue(BIOMETRICS_PERMISSION) - cryptographyManager.deleteInvalidKey() - } - } - - override fun hasHomeAnalytics(): Boolean = charts?.getVisualizationGroups(null)?.isNotEmpty() == true - - override fun getServerVersion(): Single = d2.systemInfoModule().systemInfo().get() - - override fun accountsCount() = - d2 - .userModule() - .accountManager() - .getAccounts() - .count() - - override fun isPinStored() = - d2 - .dataStoreModule() - .localDataStore() - .value(PIN) - .blockingExists() - - override fun homeItemCount(): Int = d2.programs().size + d2.dataSetInstanceSummaries().size - - override suspend fun singleHomeItemData(): HomeItemData? { - val program = d2.programs().firstOrNull() - val dataSetInstance = d2.dataSetInstanceSummaries().firstOrNull() - - return when { - program?.programType() == ProgramType.WITH_REGISTRATION -> - HomeItemData.TrackerProgram( - program.uid(), - program.displayName() ?: program.uid(), - program.access().data().write() == true, - program.trackedEntityType()?.uid() ?: "", - isStockUseCase = d2.isStockProgram(program.uid()), - ) - - program?.programType() == ProgramType.WITHOUT_REGISTRATION -> - HomeItemData.EventProgram( - program.uid(), - program.displayName() ?: program.uid(), - program.access().data().write() == true, - ) - - dataSetInstance != null -> { - val dataSet = d2.dataSet(dataSetInstance.dataSetUid()) - HomeItemData.DataSet( - dataSetInstance.dataSetUid(), - dataSetInstance.dataSetDisplayName(), - dataSet?.access()?.data()?.write() == true, - ) - } - - else -> null - } - } -} diff --git a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt index 9526973f96b..988e45bba5c 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt @@ -15,29 +15,20 @@ import android.view.View import android.webkit.MimeTypeMap import android.widget.TextView import android.widget.Toast -import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.constraintlayout.widget.ConstraintSet import androidx.core.app.NotificationCompat import androidx.core.net.toUri -import androidx.databinding.DataBindingUtil +import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout -import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch import org.dhis2.BuildConfig import org.dhis2.R -import org.dhis2.bindings.app import org.dhis2.bindings.hasPermissions -import org.dhis2.commons.animations.hide -import org.dhis2.commons.animations.show -import org.dhis2.commons.filters.FilterItem import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.filters.Filters import org.dhis2.commons.filters.FiltersAdapter @@ -51,54 +42,47 @@ import org.dhis2.usescases.development.DevelopmentActivity import org.dhis2.usescases.general.ActivityGlobalAbstract import org.dhis2.usescases.login.LoginActivity import org.dhis2.usescases.main.ui.NewVersionDialog -import org.dhis2.utils.analytics.CLICK -import org.dhis2.utils.analytics.CLOSE_SESSION -import org.dhis2.utils.customviews.navigationbar.NavigationPage -import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator -import org.dhis2.utils.extension.navigateTo +import org.dhis2.usescases.main.ui.TAG +import org.dhis2.usescases.main.ui.model.HomeEffect +import org.dhis2.usescases.main.ui.model.HomeScreenState +import org.dhis2.usescases.main.ui.model.VersionToUpdateState +import org.dhis2.usescases.main.ui.screens.MainScreen import org.dhis2.utils.granularsync.SyncStatusDialog -import org.dhis2.utils.session.PIN_DIALOG_TAG -import org.dhis2.utils.session.PinDialog -import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar -import java.io.File -import javax.inject.Inject +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf private const val FRAGMENT = "Fragment" -private const val SINGLE_PROGRAM_NAVIGATION = "SINGLE_PROGRAM_NAVIGATION" private const val INIT_DATA_SYNC = "INIT_DATA_SYNC" private const val WIPE_NOTIFICATION = "wipe_notification" private const val RESTART = "Restart" const val AVOID_SYNC = "AvoidSync" -class MainActivity : - ActivityGlobalAbstract(), - MainView, - DrawerLayout.DrawerListener { +class MainActivity : ActivityGlobalAbstract() { private lateinit var binding: ActivityMainBinding + private val mainViewModel: MainViewModel by viewModel(parameters = { + parametersOf( + context, + supportFragmentManager, + intent.getBooleanExtra(AVOID_SYNC, false), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(FRAGMENT, MainScreenType::class.java) + ?: MainScreenType.Home( + HomeScreen.Programs + ) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(FRAGMENT) ?: MainScreenType.Home(HomeScreen.Programs) + } + ) + }) - lateinit var mainComponent: MainComponent - - @Inject - lateinit var presenter: MainPresenter - - @Inject - lateinit var newAdapter: FiltersAdapter - - @Inject - lateinit var pageConfigurator: NavigationPageConfigurator - - private val getDevActivityContent = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - // no-op - } + private val filtersAdapter: FiltersAdapter = FiltersAdapter() private var backDropActive = false - private val requestWritePermissions = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - onDownloadNewVersion() - } else if (granted) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || granted) { onDownloadNewVersion() } else { Toast @@ -110,32 +94,30 @@ class MainActivity : } } - private var isPinLayoutVisible = false - - private lateinit var mainNavigator: MainNavigator - private val navigationLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { } companion object { fun intent( context: Context, - initScreen: MainNavigator.MainScreen? = null, + initScreen: MainScreenType? = null, launchDataSync: Boolean = false, ): Intent = Intent(context, MainActivity::class.java).apply { - initScreen?.let { - putExtra(FRAGMENT, initScreen.name) - } - putExtra(INIT_DATA_SYNC, launchDataSync) + putExtras( + bundle( + initScreen = initScreen, + launchDataSync = launchDataSync + ) + ) } fun bundle( - initScreen: MainNavigator.MainScreen? = null, + initScreen: MainScreenType? = null, launchDataSync: Boolean = false, ) = Bundle().apply { initScreen?.let { - putString(FRAGMENT, initScreen.name) + putParcelable(FRAGMENT, initScreen) } putBoolean(INIT_DATA_SYNC, launchDataSync) } @@ -143,231 +125,145 @@ class MainActivity : //region LIFECYCLE override fun onCreate(savedInstanceState: Bundle?) { - app().userComponent()?.let { - mainComponent = - it.plus( - MainModule( - view = this, - forceToNotSynced = intent.getBooleanExtra(AVOID_SYNC, false), - ), - ) - mainComponent.inject(this@MainActivity) - } ?: navigateTo(true) - mainNavigator = - MainNavigator( - dispatcherProvider = presenter.dispatcherProvider, - supportFragmentManager, - { - if (backDropActive) { - showHideFilter() - } - }, - ) { titleRes, showFilterButton, showBottomNavigation -> - setTitle(getString(titleRes)) - setFilterButtonVisibility(showFilterButton) - setBottomNavigationVisibility(showBottomNavigation) - } super.onCreate(savedInstanceState) - binding = DataBindingUtil.setContentView(this, R.layout.activity_main) - if (::presenter.isInitialized) { - binding.presenter = presenter - } else { - navigateTo(true) + // On recreation (e.g. rotation), the ViewModel survives but its MainNavigator holds a + // stale FragmentManager from the destroyed Activity. Update it before rendering so any + // fragment transaction below uses the correct, live FragmentManager. + var needsScreenRefresh = savedInstanceState != null + if (needsScreenRefresh) { + mainViewModel.mainNavigator.updateFragmentManager(supportFragmentManager) } + setContent { + DHIS2Theme { + val screenState by mainViewModel.homeScreenState.collectAsState() + MainScreen( + screenState = screenState, + effects = mainViewModel.homeEffects, + onAction = mainViewModel::onAction, + onEffect = { effect -> + when (effect) { + HomeEffect.BlockSession -> finish() + is HomeEffect.GoToLogin -> goToLogin( + accountsCount = effect.accountsCount, + isDeletion = effect.isDeletion, + ) + HomeEffect.OrgUnitFilterRequest -> openOrgUnitTreeSelector() + is HomeEffect.PeriodFilterRequest -> showPeriodRequest(effect.periodRequest) + HomeEffect.ShowDeleteNotification -> showProgressDeleteNotification() + HomeEffect.ShowGranularSync -> showGranularSync() + is HomeEffect.SingleProgramNavigation -> navigationLauncher.navigateTo( + this@MainActivity, + effect.homeItemData, + ) + HomeEffect.ShowPinDialog -> { /*handled in composable*/ } + HomeEffect.ToggleSideMenu -> openDrawer() + HomeEffect.ToggleFilters -> showHideFilter() + } + }, + onNewState = { state -> + if (::binding.isInitialized) { + updateScreen(state) + } + }, + onNewScreen = { currentScreen -> + if (backDropActive) { + showHideFilter() + } + val navigationId = when (currentScreen) { + MainScreenType.About -> R.id.menu_about + is MainScreenType.Home -> R.id.menu_home + MainScreenType.Loading -> null + MainScreenType.QRScanner -> R.id.qr_scan + MainScreenType.Settings -> R.id.sync_manager + MainScreenType.TroubleShooting -> R.id.menu_troubleshooting + } + navigationId?.let { + changeFragment(it) + // On recreation the ViewModel screen hasn't changed so + // navigateToScreen() would early-return without ever opening the + // fragment. Bypass it once to repopulate the fresh container. + if (needsScreenRefresh) { + needsScreenRefresh = false + mainViewModel.refreshCurrentScreen() + } else { + initCurrentScreen() + } + } + }, + onLayoutInflated = { + binding = it + initBinding() + }, + ) + } + } + } + + private fun initBinding() { binding.navView.setNavigationItemSelectedListener { item -> changeFragment(item.itemId) false } - binding.mainDrawerLayout.addDrawerListener(this) - - binding.filterRecycler.adapter = newAdapter - - setUpNavigationBar() - setUpDevelopmentMode() - - val restoreScreenName = savedInstanceState?.getString(FRAGMENT) - presenter.updateSingleProgramNavigationDone( - savedInstanceState?.getBoolean( - SINGLE_PROGRAM_NAVIGATION, - ) ?: false, - ) - - val openScreen = intent.getStringExtra(FRAGMENT) + binding.mainDrawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) = Unit - when { - openScreen != null || restoreScreenName != null -> { - changeFragment( - mainNavigator.currentNavigationViewItemId( - openScreen ?: restoreScreenName!!, - ), - ) - mainNavigator.restoreScreen( - screenToRestoreName = openScreen ?: restoreScreenName!!, - languageSelectorOpened = - openScreen != null && - MainNavigator.MainScreen.valueOf(openScreen) == - MainNavigator.MainScreen.TROUBLESHOOTING, - ) - } + override fun onDrawerOpened(drawerView: View) = Unit - else -> { - changeFragment(R.id.menu_home) + override fun onDrawerClosed(drawerView: View) { initCurrentScreen() } - } - - observeSyncState() - observeVersionUpdate() - observeSingleProgram() - if (!presenter.wasSyncAlreadyDone()) { - presenter.launchInitialDataSync() - } else { - presenter.checkSingleProgramNavigation() - } + override fun onDrawerStateChanged(newState: Int) = Unit + }) - checkNotificationPermission() + binding.filterRecycler.adapter = filtersAdapter - registerOnBackPressedCallback() - } - - private fun checkNotificationPermission() { - if (!hasPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS)) and - (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - ) { - requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putBoolean(SINGLE_PROGRAM_NAVIGATION, presenter.isSingleProgramNavigationDone()) - outState.putString(FRAGMENT, mainNavigator.currentScreenName()) - } - - override fun onResume() { - super.onResume() - if (sessionManagerServiceImpl.isUserLoggedIn()) { - presenter.init() - presenter.initFilters() - binding.totalFilters = FilterManager.getInstance().totalFilters - } - } - - override fun onPause() { - presenter.onDetach() - super.onPause() - } - - private fun setUpDevelopmentMode() { if (BuildConfig.DEBUG || BuildConfig.FLAVOR == "dhis2Training") { binding.navView.menu .findItem(R.id.menu_troubleshooting) .isVisible = true - binding.menu.setOnLongClickListener { - getDevActivityContent.launch(Intent(this, DevelopmentActivity::class.java)) - false - } + binding.navView.menu + .findItem(R.id.menu_dev) + .isVisible = true } + checkNotificationPermission() } - private fun registerOnBackPressedCallback() { - onBackPressedDispatcher.addCallback( - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - backPressed() - } - }, - ) - } - - private fun setUpNavigationBar() { - binding.navigationBar.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val currentScreen by mainNavigator.selectedScreen.observeAsState() - val selectedItemIndex by remember { - derivedStateOf { - when (currentScreen) { - MainNavigator.MainScreen.PROGRAMS -> 0 - MainNavigator.MainScreen.VISUALIZATIONS -> 1 - else -> null - } - } - } - if (pageConfigurator.navigationItems().size > 1) { - NavigationBar( - items = pageConfigurator.navigationItems(), - selectedItemIndex = selectedItemIndex ?: 0, - ) { navigationPage -> - when (navigationPage) { - NavigationPage.ANALYTICS -> { - presenter.trackHomeAnalytics() - mainNavigator.openVisualizations() - } + private fun updateScreen(screenState: HomeScreenState) { + binding.navView.getHeaderView(0).findViewById(R.id.user_info) + .text = screenState.userName - NavigationPage.PROGRAMS -> mainNavigator.openPrograms() - else -> { - // no-op - } - } - } - } - } + if (screenState.homeFilters.isNotEmpty()) { + filtersAdapter.submitList(screenState.homeFilters) } - } - private fun observeSyncState() { - lifecycleScope.launch { - presenter.observeDataSync().collect { - when (it.running) { - true -> { - binding.syncActionButton.visibility = View.GONE - setFilterButtonVisibility(false) - setBottomNavigationVisibility(false) - } - - false -> { - setFilterButtonVisibility(true) - binding.syncActionButton.visibility = View.VISIBLE - setBottomNavigationVisibility(true) - presenter.onDataSuccess() - presenter.checkSingleProgramNavigation() - } + when (val versionState = screenState.versionToUpdate) { + VersionToUpdateState.Downloading -> + binding.toolbarProgress.show() - else -> { - // no action - } + is VersionToUpdateState.New -> + if (supportFragmentManager.findFragmentByTag(TAG) == null) { + showNewVersionAlert(versionState.version) } - } - } - } - private fun observeVersionUpdate() { - presenter.versionToUpdate.observe(this) { versionName -> - versionName?.takeIf { it.isNotEmpty() }?.let { showNewVersionAlert(it) } - } - presenter.downloadingVersion.observe(this) { downloading -> - if (downloading) { - binding.toolbarProgress.show() - } else { + VersionToUpdateState.None -> binding.toolbarProgress.hide() - } } } - private fun observeSingleProgram() { - lifecycleScope.launch { - presenter.singleProgramNavigationChannel.collect { homeItemData -> - navigationLauncher.navigateTo(this@MainActivity, homeItemData) - } + /*TODO: MOVE TO BE HANDLED AFTER LOGIN*/ + private fun checkNotificationPermission() { + if (!hasPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS)) and + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + ) { + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } } - override fun showHideFilter() { + private fun showHideFilter() { val transition = ChangeBounds() transition.duration = 200 TransitionManager.beginDelayedTransition(binding.backdropLayout, transition) @@ -382,21 +278,21 @@ class MainActivity : ConstraintSet.BOTTOM, 50, ) - binding.navigationBar.hide() + mainViewModel.updateNavigationBarVisibility(false) } else { initSet.connect( R.id.fragment_container, ConstraintSet.TOP, - R.id.toolbar, + R.id.toolbarProgress, ConstraintSet.BOTTOM, 0, ) - binding.navigationBar.show() + mainViewModel.updateNavigationBarVisibility(true) } initSet.applyTo(binding.backdropLayout) } - override fun showGranularSync() { + private fun showGranularSync() { SyncStatusDialog .Builder() .withContext(this) @@ -404,9 +300,7 @@ class MainActivity : .onDismissListener( object : OnDismissListener { override fun onDismiss(hasChanged: Boolean) { - if (hasChanged) { - mainNavigator.getCurrentIfProgram()?.programViewModel?.updateProgramQueries() - } + mainViewModel.onGranularSyncFinished(hasChanged) } }, ).onNoConnectionListener { @@ -420,11 +314,7 @@ class MainActivity : }.show("ALL_SYNC") } - override fun updateFilters(totalFilters: Int) { - binding.totalFilters = totalFilters - } - - override fun showPeriodRequest(periodRequest: FilterManager.PeriodRequest) { + private fun showPeriodRequest(periodRequest: FilterManager.PeriodRequest) { if (periodRequest == FilterManager.PeriodRequest.FROM_TO) { FilterPeriodsDialog .newPeriodsFilter(filterType = Filters.PERIOD, isFromToFilter = true) @@ -436,7 +326,7 @@ class MainActivity : } } - override fun openOrgUnitTreeSelector() { + private fun openOrgUnitTreeSelector() { OUTreeFragment .Builder() .withPreselectedOrgUnits( @@ -445,13 +335,12 @@ class MainActivity : .orgUnitFilters .map { it.uid() } .toMutableList(), - ).onSelection { selectedOrgUnits -> - presenter.setOrgUnitFilters(selectedOrgUnits) - }.build() + ).onSelection(mainViewModel::setOrgUnitFilters) + .build() .show(supportFragmentManager, "OUTreeFragment") } - override fun goToLogin( + private fun goToLogin( accountsCount: Int, isDeletion: Boolean, ) { @@ -462,150 +351,54 @@ class MainActivity : isDeletion = isDeletion, fromMainActivity = true, ), - true, - true, - null, + finishCurrent = true, + finishAll = true, + transition = null, ) } - override fun renderUsername(username: String) { - binding.userName = username - (binding.navView.getHeaderView(0).findViewById(R.id.user_info) as TextView) - .text = username - binding.executePendingBindings() - } - - private fun setFilterButtonVisibility(showFilterButton: Boolean) { - binding.filterActionButton.visibility = - if (showFilterButton && presenter.hasFilters()) { - View.VISIBLE - } else { - View.GONE - } - binding.syncActionButton.visibility = - if (showFilterButton) { - View.VISIBLE - } else { - View.GONE - } - } - - override fun openDrawer(gravity: Int) { - if (!binding.mainDrawerLayout.isDrawerOpen(gravity)) { - binding.mainDrawerLayout.openDrawer(gravity) - } else { - binding.mainDrawerLayout.closeDrawer(gravity) - } - } - - override fun setFilters(filters: List) { - newAdapter.submitList(filters) - } - - override fun hideFilters() { - binding.filterActionButton.visibility = View.GONE - } - - override fun onLockClick() { - if (!presenter.isPinStored()) { - binding.mainDrawerLayout.closeDrawers() - PinDialog( - PinDialog.Mode.SET, - true, - { presenter.blockSession() }, - {}, - ).show(supportFragmentManager, PIN_DIALOG_TAG) - isPinLayoutVisible = true + private fun openDrawer() { + if (!binding.mainDrawerLayout.isDrawerOpen(GravityCompat.START)) { + binding.mainDrawerLayout.openDrawer(GravityCompat.START) } else { - presenter.blockSession() - } - } - - private fun backPressed() { - when { - !mainNavigator.isHome() -> presenter.onNavigateBackToHome() - isPinLayoutVisible -> isPinLayoutVisible = false - else -> back() + binding.mainDrawerLayout.closeDrawer(GravityCompat.START) } } - override fun goToHome() { - mainNavigator.openHome() - } - - override fun changeFragment(id: Int) { + private fun changeFragment(id: Int) { binding.navView.setCheckedItem(id) binding.mainDrawerLayout.closeDrawers() } - fun setTitle(title: String) { - binding.title.text = title - } - - private fun setBottomNavigationVisibility(showBottomNavigation: Boolean) { - if (showBottomNavigation) { - binding.navigationBar.show() - } else { - binding.navigationBar.hide() - } - } - - override fun onDrawerStateChanged(newState: Int) { - // no op - } - - override fun onDrawerSlide( - drawerView: View, - slideOffset: Float, - ) { - // no op - } - - override fun onDrawerClosed(drawerView: View) { - initCurrentScreen() - } - - override fun onDrawerOpened(drawerView: View) { - // no op - } - private fun initCurrentScreen() { when (binding.navView.checkedItem?.itemId) { - R.id.sync_manager -> { - presenter.onClickSyncManager() - mainNavigator.openSettings() - } + R.id.sync_manager -> + mainViewModel.onChangeScreen(MainScreenType.Settings) - R.id.qr_scan -> { - presenter.trackQRScanner() - mainNavigator.openQR() - } + R.id.qr_scan -> + mainViewModel.onChangeScreen(MainScreenType.QRScanner) - R.id.menu_about -> { - mainNavigator.openAbout() - } + R.id.menu_about -> + mainViewModel.onChangeScreen(MainScreenType.About) - R.id.block_button -> { - presenter.trackPinDialog() - onLockClick() - } + R.id.block_button -> + mainViewModel.onBlockSession() - R.id.logout_button -> { - analyticsHelper.setEvent(CLOSE_SESSION, CLICK, CLOSE_SESSION) - presenter.logOut() - } + R.id.logout_button -> + mainViewModel.logOut() - R.id.menu_home -> { - mainNavigator.openHome() - } + R.id.menu_home -> + mainViewModel.onChangeToHome() - R.id.menu_troubleshooting -> { - mainNavigator.openTroubleShooting() - } + R.id.menu_troubleshooting -> + mainViewModel.onChangeScreen(MainScreenType.TroubleShooting) - R.id.delete_account -> { + R.id.menu_dev -> + startActivity(Intent(this, DevelopmentActivity::class.java)) + + R.id.delete_account -> confirmAccountDelete() - } + } } @@ -614,14 +407,13 @@ class MainActivity : .setTitle(getString(R.string.delete_account)) .setMessage(getString(R.string.wipe_data_meesage)) .setView(R.layout.warning_layout) - .setPositiveButton(getString(R.string.wipe_data_ok)) { _, _ -> - presenter.onDeleteAccount() - }.setNegativeButton(getString(R.string.wipe_data_no)) { dialog, _ -> + .setPositiveButton(getString(R.string.wipe_data_ok)) { _, _ -> mainViewModel.onDeleteAccount() } + .setNegativeButton(getString(R.string.wipe_data_no)) { dialog, _ -> dialog.dismiss() }.show() } - override fun showProgressDeleteNotification() { + private fun showProgressDeleteNotification() { val notificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -646,18 +438,10 @@ class MainActivity : notificationManager.notify(123456, notificationBuilder.build()) } - override fun obtainFileView(): File? = this.cacheDir - - override fun cancelNotifications() { - val notificationManager: NotificationManager = - getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancelAll() - } - private fun showNewVersionAlert(version: String) { NewVersionDialog( newVersion = version, - onRemindMeLater = presenter::remindLaterAlertNewVersion, + onRemindMeLater = mainViewModel::remindLaterAlertNewVersion, onDownloadVersion = { if (hasPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE))) { onDownloadNewVersion() @@ -669,8 +453,7 @@ class MainActivity : } private fun onDownloadNewVersion() { - presenter.downloadVersion( - context = context, + mainViewModel.downloadVersion( onDownloadCompleted = ::installAPK, onLaunchUrl = ::launchUrl, ) @@ -701,7 +484,7 @@ class MainActivity : private fun hasNoPermissionToInstall(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !packageManager.canRequestPackageInstalls() + !packageManager.canRequestPackageInstalls() private val manageUnknownSources = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -735,25 +518,24 @@ class MainActivity : private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { - Toast - .makeText( - context, - getString(R.string.permission_notification_granted), - Toast.LENGTH_SHORT, - ).show() + val message = if (granted) { + getString(R.string.permission_notification_granted) } else { - Toast - .makeText( - context, - getString(R.string.permission_notification_denied), - Toast.LENGTH_SHORT, - ).show() + getString(R.string.permission_notification_denied) } + + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } private fun launchUrl(uri: Uri) { val intent = Intent(Intent.ACTION_VIEW, uri) startActivity(intent) } + + /** Required by QrReaderFragment. It should retrieve the activity mainViewModel and call + * onChangeToHome directly + * */ + fun goToHome() { + mainViewModel.onChangeToHome() + } } diff --git a/app/src/main/java/org/dhis2/usescases/main/MainComponent.kt b/app/src/main/java/org/dhis2/usescases/main/MainComponent.kt deleted file mode 100644 index 74662be2455..00000000000 --- a/app/src/main/java/org/dhis2/usescases/main/MainComponent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.dhis2.usescases.main - -import dagger.Subcomponent -import org.dhis2.commons.di.dagger.PerActivity -import org.dhis2.usescases.troubleshooting.TroubleshootingComponent -import org.dhis2.usescases.troubleshooting.TroubleshootingModule - -@PerActivity -@Subcomponent(modules = [MainModule::class]) -interface MainComponent { - fun inject(mainActivity: MainActivity) - - fun plus(troubleShootingModule: TroubleshootingModule): TroubleshootingComponent -} diff --git a/app/src/main/java/org/dhis2/usescases/main/MainModule.kt b/app/src/main/java/org/dhis2/usescases/main/MainModule.kt index 04ce3cab5f2..4e9b14cc5fa 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainModule.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainModule.kt @@ -1,160 +1,137 @@ package org.dhis2.usescases.main -import dagger.Module -import dagger.Provides -import dhis2.org.analytics.charts.Charts -import org.dhis2.commons.di.dagger.PerActivity +import android.content.Context +import androidx.fragment.app.FragmentManager +import androidx.work.WorkManager +import kotlinx.coroutines.Dispatchers import org.dhis2.commons.filters.FilterManager -import org.dhis2.commons.filters.FiltersAdapter -import org.dhis2.commons.filters.data.FilterRepository -import org.dhis2.commons.matomo.MatomoAnalyticsController -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.commons.resources.ColorUtils +import org.dhis2.commons.resources.LocaleSelector +import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.biometric.CryptographyManager -import org.dhis2.data.server.UserManager -import org.dhis2.data.service.SyncStatusController import org.dhis2.data.service.VersionRepository import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.mobile.commons.biometrics.CryptographicActions -import org.dhis2.mobile.commons.coroutine.Dispatcher -import org.dhis2.mobile.commons.error.DomainErrorMapper -import org.dhis2.mobile.commons.network.NetworkStatusProvider -import org.dhis2.mobile.commons.network.NetworkStatusProviderImpl -import org.dhis2.mobile.commons.resources.D2ErrorMessageProvider -import org.dhis2.mobile.commons.resources.D2ErrorMessageProviderImpl -import org.dhis2.usescases.login.SyncIsPerformedInteractor +import org.dhis2.data.service.workManager.WorkManagerControllerImpl +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.data.HomeRepositoryImpl +import org.dhis2.usescases.main.domain.CheckSingleNavigation +import org.dhis2.usescases.main.domain.ConfigureHomeNavigationBar +import org.dhis2.usescases.main.domain.DeleteAccount +import org.dhis2.usescases.main.domain.DownloadNewVersion +import org.dhis2.usescases.main.domain.GetHomeFilters +import org.dhis2.usescases.main.domain.GetLockAction +import org.dhis2.usescases.main.domain.GetUserName +import org.dhis2.usescases.main.domain.LaunchInitialSync import org.dhis2.usescases.main.domain.LogoutUser -import org.dhis2.usescases.settings.DeleteUserData -import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator -import org.hisp.dhis.android.core.D2 +import org.dhis2.usescases.main.domain.ScheduleNewVersionAlert +import org.dhis2.usescases.main.domain.UpdateInitialSyncStatus +import org.dhis2.usescases.troubleshooting.TroubleshootingRepository +import org.dhis2.usescases.troubleshooting.TroubleshootingViewModel +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModel +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module -@Module -class MainModule( - val view: MainView, - private val forceToNotSynced: Boolean, -) { - @Provides - @PerActivity - fun homePresenter( - homeRepository: HomeRepository, - schedulerProvider: SchedulerProvider, - preferences: PreferenceProvider, - workManagerController: WorkManagerController, - filterManager: FilterManager, - filterRepository: FilterRepository, - matomoAnalyticsController: MatomoAnalyticsController, - userManager: UserManager, - deleteUserData: DeleteUserData, - syncIsPerformedInteractor: SyncIsPerformedInteractor, - syncStatusController: SyncStatusController, - versionRepository: VersionRepository, - dispatcherProvider: DispatcherProvider, - logoutUser: LogoutUser, - ): MainPresenter = - MainPresenter( - view, - homeRepository, - schedulerProvider, - preferences, - workManagerController, - filterManager, - filterRepository, - matomoAnalyticsController, - userManager, - deleteUserData, - syncIsPerformedInteractor, - syncStatusController, - versionRepository, - dispatcherProvider, - forceToNotSynced, - logoutUser, - ) +val mainModule = module { - @Provides - @PerActivity - fun provideLogoutUser( - homeRepository: HomeRepository, - workManagerController: WorkManagerController, - syncStatusController: SyncStatusController, - filterManager: FilterManager, - ): LogoutUser = - LogoutUser( - homeRepository, - workManagerController, - syncStatusController, - filterManager, - ) + val dispatcher = object : DispatcherProvider { + override fun io() = Dispatchers.IO + override fun computation() = Dispatchers.Unconfined + override fun ui() = Dispatchers.Main + } - @Provides - @PerActivity - fun provideSyncIsPerfomedInteractor(userManager: UserManager): SyncIsPerformedInteractor = SyncIsPerformedInteractor(userManager) + factory { + WorkManagerControllerImpl(WorkManager.getInstance(androidContext())) + } - @Provides - @PerActivity - fun provideHomeRepository( - d2: D2, - charts: Charts?, - preferencesProvider: PreferenceProvider, - cryptographyManager: CryptographicActions, - domainErrorMapper: DomainErrorMapper, - ): HomeRepository = + factory { HomeRepositoryImpl( - d2, - charts, - preferencesProvider, - cryptographyManager, - Dispatcher(), - domainErrorMapper, + d2 = get(), + charts = get(), + preferences = get(), + workManagerController = get(), + syncStatusController = get(), + domainErrorMapper = get(), + dispatcher = get(), ) - - @Provides - @PerActivity - fun provideDomainErrorMapper( - d2ErrorMessageProvider: D2ErrorMessageProvider, - networkStatusProvider: NetworkStatusProvider, - ): DomainErrorMapper = - DomainErrorMapper( - d2ErrorMessageProvider = d2ErrorMessageProvider, - networkStatusProvider = networkStatusProvider, + } + factory { + FilterManager.getInstance() + } + singleOf(::VersionRepository) + factoryOf(::ResourceManager) + factory { params -> + MainNavigator( + fragmentManager = params.get(), ) - - @Provides - @PerActivity - fun provideD2ErrorMessageProvider(): D2ErrorMessageProvider = D2ErrorMessageProviderImpl() - - @Provides - @PerActivity - fun provideNetworkStatusProvider(): NetworkStatusProvider = - NetworkStatusProviderImpl( - context = view.context, + } + factory { params -> + GetUserName( + homeRepository = get { parametersOf(params.get()) } ) + } + factory { params -> + ConfigureHomeNavigationBar( + homeRepository = get { parametersOf(params.get()) }, + resourceManager = get() + ) + } + factoryOf(::GetHomeFilters) + factoryOf(::DownloadNewVersion) + factoryOf(::LogoutUser) + factoryOf(::DeleteAccount) + factoryOf(::GetLockAction) + factoryOf(::UpdateInitialSyncStatus) + factoryOf(::CheckSingleNavigation) + factoryOf(::LaunchInitialSync) + factory { + ScheduleNewVersionAlert( + workManagerController = get(), + versionRepository = get() + ) + } - @Provides - @PerActivity - fun provideCryptographicManager(): CryptographicActions = CryptographyManager() - - @Provides - @PerActivity - fun provideNewFiltersAdapter(): FiltersAdapter = FiltersAdapter() + viewModel { params -> + val context = params.get() + val fragmentManager: FragmentManager = params.get() + val skipInitialSync = params.get() + val initialScreen = params.get() - @Provides - @PerActivity - fun providePageConfigurator(homeRepository: HomeRepository): NavigationPageConfigurator = - HomePageConfigurator(homeRepository, ResourceManager(view.context, ColorUtils())) + MainViewModel( + preferences = get { parametersOf(context) }, + filterManager = get(), + matomoAnalyticsController = get(), + syncStatusController = get(), + mainNavigator = get { parametersOf(fragmentManager) }, + getUserName = get { parametersOf(context) }, + configureHomeNavigationBar = get { parametersOf(context) }, + getHomeFilters = get(), + downloadNewVersion = get(), + logOutUser = get { parametersOf(context) }, + deleteAccount = get(), + getLockAction = get(), + updateInitialSyncStatus = get(), + checkSingleNavigation = get { parametersOf(skipInitialSync) }, + launchInitialSync = get(), + scheduleNewVersionAlert = get(), + syncBackgroundJobAction = get(), + initialScreen = initialScreen, + dispatcher = dispatcher, + ) + } - @Provides - @PerActivity - fun provideDeleteUserData( - workManagerController: WorkManagerController, - preferencesProvider: PreferenceProvider, - filterManager: FilterManager, - ): DeleteUserData = - DeleteUserData( - workManagerController, - filterManager, - preferencesProvider, + factoryOf(::MetadataIconProvider) + factoryOf(::TroubleshootingRepository) + factory { + LocaleSelector(androidContext(), get()) + } + viewModel { params -> + TroubleshootingViewModel( + localeSelector = get(), + repository = get(), + openLanguageSection = params.get() ) + } } diff --git a/app/src/main/java/org/dhis2/usescases/main/MainNavigator.kt b/app/src/main/java/org/dhis2/usescases/main/MainNavigator.kt index 798f60cc68a..2cefd21ed02 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainNavigator.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainNavigator.kt @@ -2,19 +2,10 @@ package org.dhis2.usescases.main import android.transition.ChangeBounds import android.view.View -import androidx.annotation.IdRes -import androidx.annotation.StringRes import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentTransaction -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import dhis2.org.analytics.charts.ui.GroupAnalyticsFragment -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.dhis2.R -import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.usescases.about.AboutFragment import org.dhis2.usescases.main.program.ProgramFragment import org.dhis2.usescases.qrReader.QrReaderFragment @@ -22,155 +13,101 @@ import org.dhis2.usescases.settings.SyncManagerFragment import org.dhis2.usescases.troubleshooting.TroubleshootingFragment class MainNavigator( - private val dispatcherProvider: DispatcherProvider, - private val fragmentManager: FragmentManager, - private val onTransitionStart: () -> Unit, - private val onScreenChanged: ( - titleRes: Int, - showFilterButton: Boolean, - showBottomNavigation: Boolean, - ) -> Unit, + private var fragmentManager: FragmentManager, ) { - enum class MainScreen( - @StringRes val title: Int, - @IdRes val navViewId: Int, - ) { - PROGRAMS(R.string.done_task, R.id.menu_home), - VISUALIZATIONS(R.string.done_task, R.id.menu_home), - QR(R.string.QR_SCANNER, R.id.qr_scan), - SETTINGS(R.string.SYNC_MANAGER, R.id.sync_manager), - TROUBLESHOOTING(R.string.main_menu_troubleshooting, R.id.menu_troubleshooting), - ABOUT(R.string.about, R.id.menu_about), - } - - private var currentScreen = MutableLiveData(null) - var selectedScreen: LiveData = currentScreen private var currentFragment: Fragment? = null + private var lastHomeFragment: Fragment? = null - fun isHome(): Boolean = isPrograms() || isVisualizations() - - fun isPrograms(): Boolean = currentScreen.value == MainScreen.PROGRAMS - - fun isVisualizations(): Boolean = currentScreen.value == MainScreen.VISUALIZATIONS + fun updateFragmentManager(fm: FragmentManager) { + fragmentManager = fm + } - fun getCurrentIfProgram(): ProgramFragment? = currentFragment?.takeIf { it is ProgramFragment } as? ProgramFragment + fun getCurrentIfProgram(): ProgramFragment? = + currentFragment?.takeIf { it is ProgramFragment } as? ProgramFragment - fun currentScreenName() = currentScreen.value?.name - fun currentNavigationViewItemId(screenName: String): Int = MainScreen.valueOf(screenName).navViewId + fun openHome(): MainScreenType { + return when { + lastHomeFragment is GroupAnalyticsFragment -> { + MainScreenType.Home(HomeScreen.Visualizations) + } - fun openHome() { - when { - isVisualizations() -> openVisualizations() - else -> openPrograms() + else -> { + MainScreenType.Home(HomeScreen.Programs) + } } } fun openPrograms() { val programFragment = ProgramFragment() + lastHomeFragment = programFragment val sharedView = - if (isVisualizations()) { - (currentFragment as GroupAnalyticsFragment).sharedView() - } else { - null + (currentFragment as? GroupAnalyticsFragment)?.sharedView()?.let { sharedView -> + programFragment.sharedElementEnterTransition = ChangeBounds() + programFragment.sharedElementReturnTransition = ChangeBounds() + sharedView } - if (sharedView != null) { - programFragment.sharedElementEnterTransition = ChangeBounds() - programFragment.sharedElementReturnTransition = ChangeBounds() - } + beginTransaction( - ProgramFragment(), - MainScreen.PROGRAMS, + programFragment, sharedView, ) } - fun restoreScreen( - screenToRestoreName: String, - languageSelectorOpened: Boolean = false, - ) { - when (MainScreen.valueOf(screenToRestoreName)) { - MainScreen.PROGRAMS -> openPrograms() - MainScreen.VISUALIZATIONS -> openVisualizations() - MainScreen.QR -> openQR() - MainScreen.SETTINGS -> openSettings() - MainScreen.ABOUT -> openAbout() - MainScreen.TROUBLESHOOTING -> openTroubleShooting(languageSelectorOpened) - } - } - fun openVisualizations() { - beginTransaction(GroupAnalyticsFragment.forHome(), MainScreen.VISUALIZATIONS) + val visualizationsFragment = GroupAnalyticsFragment.forHome() + lastHomeFragment = visualizationsFragment + beginTransaction(visualizationsFragment) } fun openSettings() { beginTransaction( SyncManagerFragment(), - MainScreen.SETTINGS, ) } fun openQR() { beginTransaction( QrReaderFragment(), - MainScreen.QR, ) } fun openAbout() { beginTransaction( AboutFragment(), - MainScreen.ABOUT, ) } fun openTroubleShooting(languageSelectorOpened: Boolean = false) { beginTransaction( fragment = TroubleshootingFragment.instance(languageSelectorOpened), - screen = MainScreen.TROUBLESHOOTING, useFadeInTransition = languageSelectorOpened, ) } private fun beginTransaction( fragment: Fragment, - screen: MainScreen, sharedView: View? = null, useFadeInTransition: Boolean = false, ) { - if (currentScreen.value != screen) { - onTransitionStart() - currentScreen.value = screen - currentFragment = fragment - - CoroutineScope(dispatcherProvider.ui()).launch { - withContext(dispatcherProvider.io()) { - val transaction: FragmentTransaction = fragmentManager.beginTransaction() - transaction - .apply { - if (sharedView == null) { - val (enterAnimation, exitAnimation) = getEnterExitAnimation(useFadeInTransition) - val (enterPopAnimation, exitPopAnimation) = getEnterExitPopAnimation(useFadeInTransition) - setCustomAnimations( - enterAnimation, - exitAnimation, - enterPopAnimation, - exitPopAnimation, - ) - } else { - setReorderingAllowed(true) - addSharedElement(sharedView, "contenttest") - } - }.replace(R.id.fragment_container, fragment, fragment::class.simpleName) - .commitAllowingStateLoss() + currentFragment = fragment + fragmentManager.beginTransaction() + .apply { + if (sharedView == null) { + val (enterAnimation, exitAnimation) = getEnterExitAnimation(useFadeInTransition) + val (enterPopAnimation, exitPopAnimation) = getEnterExitPopAnimation(useFadeInTransition) + setCustomAnimations( + enterAnimation, + exitAnimation, + enterPopAnimation, + exitPopAnimation, + ) + } else { + setReorderingAllowed(true) + addSharedElement(sharedView, "contenttest") } - onScreenChanged( - screen.title, - isPrograms(), - isHome(), - ) - } - } + }.replace(R.id.fragment_container, fragment, fragment::class.simpleName) + .commitAllowingStateLoss() } private fun getEnterExitPopAnimation(useFadeInTransition: Boolean): Pair = diff --git a/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt b/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt deleted file mode 100644 index a651681199b..00000000000 --- a/app/src/main/java/org/dhis2/usescases/main/MainPresenter.kt +++ /dev/null @@ -1,401 +0,0 @@ -package org.dhis2.usescases.main - -import android.content.Context -import android.net.Uri -import android.view.Gravity -import androidx.core.net.toUri -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.asLiveData -import androidx.work.ExistingWorkPolicy -import io.reactivex.Flowable -import io.reactivex.disposables.CompositeDisposable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import org.dhis2.BuildConfig -import org.dhis2.commons.Constants -import org.dhis2.commons.filters.FilterManager -import org.dhis2.commons.filters.data.FilterRepository -import org.dhis2.commons.matomo.Actions.Companion.BLOCK_SESSION_PIN -import org.dhis2.commons.matomo.Actions.Companion.OPEN_ANALYTICS -import org.dhis2.commons.matomo.Actions.Companion.QR_SCANNER -import org.dhis2.commons.matomo.Actions.Companion.SETTINGS -import org.dhis2.commons.matomo.Categories.Companion.HOME -import org.dhis2.commons.matomo.Labels.Companion.CLICK -import org.dhis2.commons.matomo.MatomoAnalyticsController -import org.dhis2.commons.prefs.Preference -import org.dhis2.commons.prefs.Preference.Companion.DEFAULT_CAT_COMBO -import org.dhis2.commons.prefs.Preference.Companion.PREF_DEFAULT_CAT_OPTION_COMBO -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.commons.schedulers.SchedulerProvider -import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.server.UserManager -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.SyncStatusData -import org.dhis2.data.service.VersionRepository -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem -import org.dhis2.data.service.workManager.WorkerType -import org.dhis2.usescases.login.SyncIsPerformedInteractor -import org.dhis2.usescases.main.domain.LogoutUser -import org.dhis2.usescases.settings.DeleteUserData -import org.dhis2.usescases.sync.WAS_INITIAL_SYNC_DONE -import org.dhis2.utils.TRUE -import org.hisp.dhis.android.core.organisationunit.OrganisationUnit -import org.hisp.dhis.android.core.systeminfo.SystemInfo -import org.hisp.dhis.android.core.user.User -import timber.log.Timber -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.coroutines.CoroutineContext - -const val DEFAULT = "default" -const val SERVER_ACTION = "Server" -const val DHIS2 = "dhis2_server" -const val PLAY_FLAVOR = "dhisPlayServices" - -@OptIn(ExperimentalAtomicApi::class) -class MainPresenter( - private val view: MainView, - private val repository: HomeRepository, - private val schedulerProvider: SchedulerProvider, - private val preferences: PreferenceProvider, - private val workManagerController: WorkManagerController, - private val filterManager: FilterManager, - private val filterRepository: FilterRepository, - private val matomoAnalyticsController: MatomoAnalyticsController, - private val userManager: UserManager, - private val deleteUserData: DeleteUserData, - private val syncIsPerformedInteractor: SyncIsPerformedInteractor, - private val syncStatusController: SyncStatusController, - private val versionRepository: VersionRepository, - val dispatcherProvider: DispatcherProvider, - private val forceToNotSynced: Boolean, - private val logoutUser: LogoutUser, -) : CoroutineScope { - private var job = Job() - override val coroutineContext: CoroutineContext - get() = job + dispatcherProvider.io() - - var disposable: CompositeDisposable = CompositeDisposable() - - val versionToUpdate = versionRepository.newAppVersion.asLiveData(coroutineContext) - val downloadingVersion = MutableLiveData(false) - - private val _singleProgramNavigationChannel = Channel() - val singleProgramNavigationChannel = - _singleProgramNavigationChannel - .receiveAsFlow() - .onEach { - singleProgramNavigationDone.store(true) - } - - private var singleProgramNavigationDone = AtomicBoolean(false) - - fun init() { - preferences.removeValue(Preference.CURRENT_ORG_UNIT) - disposable.add( - repository - .user() - .map { username(it) } - .subscribeOn(schedulerProvider.io()) - .observeOn(schedulerProvider.ui()) - .subscribe( - { view.renderUsername(it) }, - { Timber.e(it) }, - ), - ) - - disposable.add( - repository - .defaultCatCombo() - .subscribeOn(schedulerProvider.io()) - .subscribe( - { categoryCombo -> - preferences.setValue(DEFAULT_CAT_COMBO, categoryCombo?.uid()) - }, - { Timber.e(it) }, - ), - ) - - disposable.add( - repository - .defaultCatOptCombo() - .subscribeOn(schedulerProvider.io()) - .subscribe( - { categoryOptionCombo -> - preferences.setValue( - PREF_DEFAULT_CAT_OPTION_COMBO, - categoryOptionCombo?.uid(), - ) - }, - { Timber.e(it) }, - ), - ) - trackDhis2Server() - } - - fun initFilters() { - disposable.add( - Flowable - .just(filterRepository.homeFilters()) - .subscribeOn(schedulerProvider.io()) - .observeOn(schedulerProvider.ui()) - .subscribe( - { filters -> - if (filters.isEmpty()) { - view.hideFilters() - } else { - view.setFilters(filters) - } - }, - { Timber.e(it) }, - ), - ) - - disposable.add( - filterManager - .asFlowable() - .subscribeOn(schedulerProvider.io()) - .observeOn(schedulerProvider.ui()) - .subscribe( - { filterManager -> view.updateFilters(filterManager.totalFilters) }, - { Timber.e(it) }, - ), - ) - - disposable.add( - filterManager.periodRequest - .subscribeOn(schedulerProvider.io()) - .observeOn(schedulerProvider.ui()) - .subscribe( - { periodRequest -> view.showPeriodRequest(periodRequest.first) }, - { Timber.e(it) }, - ), - ) - - disposable.add( - filterManager - .ouTreeFlowable() - .subscribeOn(schedulerProvider.io()) - .observeOn(schedulerProvider.ui()) - .subscribe( - { view.openOrgUnitTreeSelector() }, - { Timber.e(it) }, - ), - ) - } - - fun trackDhis2Server() { - disposable.add( - repository - .getServerVersion() - .subscribeOn(schedulerProvider.io()) - .observeOn(schedulerProvider.ui()) - .subscribe( - { systemInfo -> systemInfo?.let { compareAndTrack(systemInfo) } }, - { Timber.e(it) }, - ), - ) - } - - private fun compareAndTrack(systemInfo: SystemInfo) { - val dhis2ServerTracked = preferences.getString("$DHIS2${getUserUid()}", "") - val currentDhis2Server = systemInfo.version() ?: "" - - if ((dhis2ServerTracked.isNullOrEmpty() || dhis2ServerTracked != currentDhis2Server) && - currentDhis2Server.isNotEmpty() - ) { - matomoAnalyticsController.trackEvent( - HOME, - SERVER_ACTION, - currentDhis2Server, - ) - preferences.setValue("$DHIS2${getUserUid()}", currentDhis2Server) - } - } - - fun setOrgUnitFilters(selectedOrgUnits: List) { - filterManager.addOrgUnits(selectedOrgUnits) - } - - private fun getUserUid(): String = - try { - userManager.d2 - .userModule() - .user() - .blockingGet() - ?.uid() ?: "" - } catch (e: Exception) { - "" - } - - fun logOut() { - launch(dispatcherProvider.ui()) { - logoutUser().fold( - onSuccess = { accounts -> - view.goToLogin(accounts, isDeletion = false) - }, - onFailure = { - Timber.e(it) - }, - ) - } - } - - fun onDeleteAccount() { - view.showProgressDeleteNotification() - try { - repository.checkDeleteBiometricsPermission() - workManagerController.cancelAllWork() - syncStatusController.restore() - deleteUserData.wipeCacheAndPreferences(view.obtainFileView()) - userManager.d2?.wipeModule()?.wipeEverything() - userManager.d2 - ?.userModule() - ?.accountManager() - ?.deleteCurrentAccount() - view.cancelNotifications() - - view.goToLogin(repository.accountsCount(), isDeletion = true) - } catch (exception: Exception) { - Timber.e(exception) - } - } - - fun onSyncAllClick() { - view.showGranularSync() - } - - fun blockSession() { - workManagerController.cancelAllWork() - view.back() - } - - fun showFilter() { - view.showHideFilter() - } - - fun onDetach() { - disposable.clear() - } - - fun onMenuClick() { - view.openDrawer(Gravity.START) - } - - private fun username(user: User): String = - String.format( - "%s %s", - if (user.firstName().isNullOrEmpty()) "" else user.firstName(), - if (user.surname().isNullOrEmpty()) "" else user.surname(), - ) - - fun onNavigateBackToHome() { - view.goToHome() - initFilters() - } - - fun onClickSyncManager() { - matomoAnalyticsController.trackEvent(HOME, SETTINGS, CLICK) - } - - fun isPinStored() = repository.isPinStored() - - fun launchInitialDataSync() { - checkVersionUpdate() - workManagerController - .syncDataForWorker(Constants.DATA_NOW, Constants.INITIAL_SYNC) - } - - fun observeDataSync(): StateFlow = syncStatusController.observeDownloadProcess() - - fun wasSyncAlreadyDone(): Boolean { - if (forceToNotSynced) { - return true - } - return syncIsPerformedInteractor.execute() - } - - fun onDataSuccess() { - launch(dispatcherProvider.io()) { - userManager.d2 - .dataStoreModule() - .localDataStore() - .value(WAS_INITIAL_SYNC_DONE) - .blockingSet(TRUE) - } - } - - fun trackHomeAnalytics() { - matomoAnalyticsController.trackEvent(HOME, OPEN_ANALYTICS, CLICK) - } - - fun trackPinDialog() { - matomoAnalyticsController.trackEvent(HOME, BLOCK_SESSION_PIN, CLICK) - } - - fun trackQRScanner() { - matomoAnalyticsController.trackEvent(HOME, QR_SCANNER, CLICK) - } - - fun checkVersionUpdate() { - launch { - versionRepository.checkVersionUpdates() - } - } - - fun remindLaterAlertNewVersion() { - val workerItem = - WorkerItem( - Constants.NEW_APP_VERSION, - WorkerType.NEW_VERSION, - delayInSeconds = 24 * 60 * 60, - policy = ExistingWorkPolicy.REPLACE, - ) - workManagerController.beginUniqueWork(workerItem) - versionRepository.removeVersionInfo() - } - - fun downloadVersion( - context: Context, - onDownloadCompleted: (Uri) -> Unit, - onLaunchUrl: (Uri) -> Unit, - ) { - if (BuildConfig.FLAVOR == PLAY_FLAVOR) { - val url = versionRepository.getUrl() - url?.toUri()?.let { onLaunchUrl(it) } - } else { - versionRepository.download( - context = context, - onDownloadCompleted = { - onDownloadCompleted(it) - downloadingVersion.value = false - }, - onDownloading = { downloadingVersion.value = true }, - ) - } - } - - fun checkSingleProgramNavigation() { - if (!singleProgramNavigationDone.load() && repository.homeItemCount() == 1) { - launch(coroutineContext) { - repository.singleHomeItemData()?.let { - _singleProgramNavigationChannel.send(it) - } - } - } - } - - fun hasFilters(): Boolean = filterRepository.homeFilters().isNotEmpty() - - fun updateSingleProgramNavigationDone(done: Boolean) { - singleProgramNavigationDone.store(done) - } - - fun isSingleProgramNavigationDone() = singleProgramNavigationDone.load() -} diff --git a/app/src/main/java/org/dhis2/usescases/main/MainScreenType.kt b/app/src/main/java/org/dhis2/usescases/main/MainScreenType.kt new file mode 100644 index 00000000000..dfff1ba3dda --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/MainScreenType.kt @@ -0,0 +1,24 @@ +package org.dhis2.usescases.main + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class MainScreenType : Parcelable { + data object Loading : MainScreenType() + data class Home(val homeScreen: HomeScreen) : MainScreenType() + data object QRScanner : MainScreenType() + data object Settings : MainScreenType() + data object About : MainScreenType() + data object TroubleShooting : MainScreenType() + + fun isHome() = this is Home + fun isPrograms() = this is Home && this.homeScreen == HomeScreen.Programs + fun isVisualizations() = this is Home && this.homeScreen == HomeScreen.Visualizations +} + +@Parcelize +sealed interface HomeScreen : Parcelable { + data object Programs : HomeScreen + data object Visualizations : HomeScreen +} \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/usescases/main/MainView.kt b/app/src/main/java/org/dhis2/usescases/main/MainView.kt deleted file mode 100644 index e870cbbc861..00000000000 --- a/app/src/main/java/org/dhis2/usescases/main/MainView.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2004 - 2019, University of Oslo - * All rights reserved. - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * Neither the name of the HISP project nor the names of its contributors may - * be used to endorse or promote products derived from this software without - * specific prior written permission. - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.dhis2.usescases.main - -import androidx.annotation.UiThread -import org.dhis2.commons.filters.FilterItem -import org.dhis2.commons.filters.FilterManager -import org.dhis2.usescases.general.AbstractActivityContracts -import java.io.File - -interface MainView : AbstractActivityContracts.View { - @UiThread - fun renderUsername(username: String) - - fun openDrawer(gravity: Int) - - fun showHideFilter() - - fun onLockClick() - - fun changeFragment(id: Int) - - fun updateFilters(totalFilters: Int) - - fun showPeriodRequest(periodRequest: FilterManager.PeriodRequest) - - fun openOrgUnitTreeSelector() - - fun goToHome() - - fun setFilters(filters: List) - - fun hideFilters() - - fun showGranularSync() - - fun goToLogin( - accountsCount: Int, - isDeletion: Boolean, - ) - - fun showProgressDeleteNotification() - - fun obtainFileView(): File? - - fun cancelNotifications() -} diff --git a/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt new file mode 100644 index 00000000000..cbbbac8702b --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/MainViewModel.kt @@ -0,0 +1,452 @@ +package org.dhis2.usescases.main + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.withContext +import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.matomo.Actions.Companion.BLOCK_SESSION_PIN +import org.dhis2.commons.matomo.Actions.Companion.OPEN_ANALYTICS +import org.dhis2.commons.matomo.Actions.Companion.QR_SCANNER +import org.dhis2.commons.matomo.Actions.Companion.SETTINGS +import org.dhis2.commons.matomo.Categories.Companion.HOME +import org.dhis2.commons.matomo.Labels.Companion.CLICK +import org.dhis2.commons.matomo.MatomoAnalyticsController +import org.dhis2.commons.prefs.Preference +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.extensions.launchUseCase +import org.dhis2.mobile.commons.providers.PreferenceProvider +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncStatusData +import org.dhis2.usescases.main.domain.CheckSingleNavigation +import org.dhis2.usescases.main.domain.ConfigureHomeNavigationBar +import org.dhis2.usescases.main.domain.DeleteAccount +import org.dhis2.usescases.main.domain.DownloadNewVersion +import org.dhis2.usescases.main.domain.GetHomeFilters +import org.dhis2.usescases.main.domain.GetLockAction +import org.dhis2.usescases.main.domain.GetUserName +import org.dhis2.usescases.main.domain.InitialSyncAction +import org.dhis2.usescases.main.domain.LaunchInitialSync +import org.dhis2.usescases.main.domain.LogoutUser +import org.dhis2.usescases.main.domain.ScheduleNewVersionAlert +import org.dhis2.usescases.main.domain.UpdateInitialSyncStatus +import org.dhis2.usescases.main.domain.model.DownloadMethod +import org.dhis2.usescases.main.domain.model.LockAction +import org.dhis2.usescases.main.ui.model.HomeAction +import org.dhis2.usescases.main.ui.model.HomeEffect +import org.dhis2.usescases.main.ui.model.HomeScreenState +import org.dhis2.usescases.main.ui.model.VersionToUpdateState +import org.dhis2.usescases.main.ui.model.defaultHomeScreenState +import org.dhis2.utils.analytics.CLOSE_SESSION +import org.hisp.dhis.android.core.organisationunit.OrganisationUnit +import timber.log.Timber + +class MainViewModel( + private val preferences: PreferenceProvider, + private val filterManager: FilterManager, + private val matomoAnalyticsController: MatomoAnalyticsController, + syncStatusController: SyncStatusController, + val mainNavigator: MainNavigator, + private val getUserName: GetUserName, + private val configureHomeNavigationBar: ConfigureHomeNavigationBar, + private val getHomeFilters: GetHomeFilters, + private val downloadNewVersion: DownloadNewVersion, + private val logOutUser: LogoutUser, + private val deleteAccount: DeleteAccount, + private val getLockAction: GetLockAction, + private val updateInitialSyncStatus: UpdateInitialSyncStatus, + private val checkSingleNavigation: CheckSingleNavigation, + private val launchInitialSync: LaunchInitialSync, + private val scheduleNewVersionAlert: ScheduleNewVersionAlert, + private val syncBackgroundJobAction: SyncBackgroundJobAction, + private val initialScreen: MainScreenType, + private val dispatcher: DispatcherProvider, +) : ViewModel() { + private val _homeScreenState = MutableStateFlow(defaultHomeScreenState) + + private val _homeEffects = Channel(Channel.BUFFERED) + val homeEffects = _homeEffects.receiveAsFlow() + + val homeScreenState = + _homeScreenState + .onStart { + loadData(initialScreen) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + defaultHomeScreenState, + ) + + init { + + launchUseCase(dispatcher.io()) { + preferences.removeValue(Preference.CURRENT_ORG_UNIT) + } + + syncStatusController + .observeDownloadProcess() + .onEach(::handleDownloadProcess) + .launchIn(viewModelScope) + + scheduleNewVersionAlert.newVersionFlow + .onEach(::onNewVersionAvailable) + .launchIn(viewModelScope) + + filterManager + .asFlow(viewModelScope) + .onEach { + _homeScreenState.update { + it.copy(activeFilters = filterManager.totalFilters) + } + }.launchIn(viewModelScope) + + filterManager.periodRequest + .asFlow() + .onEach { + _homeEffects.send(HomeEffect.PeriodFilterRequest(it.first)) + }.launchIn(viewModelScope) + + filterManager + .ouTreeFlowable() + .asFlow() + .onEach { + _homeEffects.send(HomeEffect.OrgUnitFilterRequest) + }.launchIn(viewModelScope) + + launchUseCase(dispatcher.io()) { + launchInitialSync().fold( + onSuccess = ::handleInitialSyncResult, + onFailure = { + Timber.e(it) + }, + ) + } + } + + private fun handleInitialSyncResult(result: InitialSyncAction) { + when (result) { + InitialSyncAction.Skip -> shouldNavigateToSingleProgram() + InitialSyncAction.Syncing -> { + // Do nothing + } + } + } + + private fun onNewVersionAvailable(version: String?) { + _homeScreenState.update { + it.copy( + versionToUpdate = + when { + version.isNullOrEmpty() -> VersionToUpdateState.None + else -> VersionToUpdateState.New(version) + }, + ) + } + } + + private fun loadData(initialScreen: MainScreenType) { + launchUseCase(dispatcher.io()) { + val userName = getUserName().getOrDefault("") + val navigationBarItems = configureHomeNavigationBar().getOrDefault(emptyList()) + val homeFilters = getHomeFilters().getOrDefault(emptyList()) + + _homeScreenState.update { + HomeScreenState( + userName = userName, + navigationBarItems = navigationBarItems, + homeFilters = homeFilters, + activeFilters = filterManager.totalFilters, + versionToUpdate = VersionToUpdateState.None, + filterButtonVisible = initialScreen.isPrograms(), + bottomNavigationBarVisible = initialScreen.isHome() && navigationBarItems.size > 1, + syncButtonVisible = initialScreen.isHome(), + currentScreen = initialScreen, + ) + } + withContext(dispatcher.ui()) { + openScreen(initialScreen) + } + } + } + + private fun handleDownloadProcess(syncStatusData: SyncStatusData) { + when (syncStatusData.running) { + true -> + _homeScreenState.update { + it.copy( + filterButtonVisible = false, + bottomNavigationBarVisible = false, + syncButtonVisible = false, + ) + } + + false -> { + _homeScreenState.update { + it.copy( + filterButtonVisible = true, + bottomNavigationBarVisible = true, + syncButtonVisible = true, + ) + } + onDataSuccess() + if (syncStatusData.isInitialSync) { + shouldNavigateToSingleProgram() + } + } + + else -> { + // do nothing + } + } + } + + private fun shouldNavigateToSingleProgram() { + launchUseCase(dispatcher.io()) { + checkSingleNavigation().getOrNull()?.let { + _homeEffects.send(HomeEffect.SingleProgramNavigation(it)) + } + } + } + + fun setOrgUnitFilters(selectedOrgUnits: List) { + filterManager.addOrgUnits(selectedOrgUnits) + } + + fun logOut() { + launchUseCase(dispatcher.io()) { + matomoAnalyticsController.trackEvent(HOME, CLOSE_SESSION, CLICK) + logOutUser().fold( + onSuccess = { accountCount -> + _homeEffects.send(HomeEffect.GoToLogin(accountCount, false)) + }, + onFailure = { + Timber.e(it) + }, + ) + } + } + + context(context: Context) + fun onDeleteAccount() { + launchUseCase(dispatcher.io()) { + _homeEffects.send(HomeEffect.ShowDeleteNotification) + deleteAccount(context.cacheDir).fold( + onSuccess = { accountCount -> + syncBackgroundJobAction.cancelAll() + _homeEffects.send(HomeEffect.GoToLogin(accountCount, true)) + }, + onFailure = { + Timber.e(it) + }, + ) + } + } + + private fun onSyncAllClick() { + launchUseCase(dispatcher.io()) { + _homeEffects.send(HomeEffect.ShowGranularSync) + } + } + + private fun showFilter() { + launchUseCase(dispatcher.io()) { + _homeEffects.send(HomeEffect.ToggleFilters) + _homeScreenState.update { + it.copy( + bottomNavigationBarVisible = !it.bottomNavigationBarVisible && it.currentScreen.isHome() && it.navigationBarItems.size > 1, + ) + } + } + } + + private fun onMenuClick() { + launchUseCase(dispatcher.io()) { + _homeEffects.send(HomeEffect.ToggleSideMenu) + } + } + + fun onDataSuccess() { + launchUseCase(dispatcher.io()) { + updateInitialSyncStatus().fold( + onSuccess = { + // Do nothing + }, + onFailure = { + Timber.e(it) + }, + ) + } + } + + fun onBlockSession() { + launchUseCase(dispatcher.io()) { + getLockAction().fold( + onSuccess = { result -> + when (result) { + LockAction.BlockSession -> + _homeEffects.send(HomeEffect.BlockSession) + + LockAction.CreatePin -> + _homeEffects.send(HomeEffect.ShowPinDialog) + } + }, + onFailure = { Timber.e(it) }, + ) + } + matomoAnalyticsController.trackEvent(HOME, BLOCK_SESSION_PIN, CLICK) + } + + fun remindLaterAlertNewVersion() { + _homeScreenState.update { + it.copy( + versionToUpdate = VersionToUpdateState.None, + ) + } + launchUseCase(dispatcher.io()) { + scheduleNewVersionAlert() + } + } + + context(context: Context) + fun downloadVersion( + onDownloadCompleted: (Uri) -> Unit, + onLaunchUrl: (Uri) -> Unit, + ) { + launchUseCase(dispatcher.io()) { + _homeScreenState.update { + it.copy( + versionToUpdate = VersionToUpdateState.Downloading, + ) + } + downloadNewVersion(context).fold( + onSuccess = { result -> + when (result) { + is DownloadMethod.File -> onDownloadCompleted(result.path.toUri()) + is DownloadMethod.Url -> onLaunchUrl(result.url.toUri()) + } + _homeScreenState.update { + it.copy( + versionToUpdate = VersionToUpdateState.None, + ) + } + }, + onFailure = Timber::e, + ) + } + } + + private fun onBackPressed() { + if (!_homeScreenState.value.currentScreen.isHome()) { + navigateToScreen(mainNavigator.openHome()) + } else { + launchUseCase { _homeEffects.send(HomeEffect.BlockSession) } + } + } + + fun onChangeToHome() { + navigateToScreen(mainNavigator.openHome()) + } + + fun updateNavigationBarVisibility(bottomNavigationBarIsVisible: Boolean) { + launchUseCase(dispatcher.io()) { + _homeScreenState.update { + it.copy(bottomNavigationBarVisible = bottomNavigationBarIsVisible && it.currentScreen.isHome() && it.navigationBarItems.size > 1) + } + } + } + + fun onChangeScreen(screenToOpen: MainScreenType) { + navigateToScreen(screenToOpen) + } + + private fun navigateToScreen(screenToOpen: MainScreenType) { + if (_homeScreenState.value.currentScreen == screenToOpen) return + _homeScreenState.update { + it.copy( + filterButtonVisible = screenToOpen.isPrograms(), + bottomNavigationBarVisible = screenToOpen.isHome() && it.navigationBarItems.size > 1, + syncButtonVisible = screenToOpen.isHome(), + currentScreen = screenToOpen, + ) + } + openScreen(screenToOpen) + } + + private fun openScreen(screenToOpen: MainScreenType) { + when (screenToOpen) { + MainScreenType.About -> mainNavigator.openAbout() + is MainScreenType.Home -> { + when (screenToOpen.homeScreen) { + HomeScreen.Programs -> + mainNavigator.openPrograms() + + HomeScreen.Visualizations -> { + matomoAnalyticsController.trackEvent(HOME, OPEN_ANALYTICS, CLICK) + mainNavigator.openVisualizations() + } + } + } + + MainScreenType.Loading -> { + /*no-op*/ + } + + MainScreenType.QRScanner -> { + matomoAnalyticsController.trackEvent(HOME, QR_SCANNER, CLICK) + mainNavigator.openQR() + } + + MainScreenType.Settings -> { + matomoAnalyticsController.trackEvent(HOME, SETTINGS, CLICK) + mainNavigator.openSettings() + } + + MainScreenType.TroubleShooting -> + mainNavigator.openTroubleShooting() + + } + } + + fun refreshCurrentScreen() { + openScreen(_homeScreenState.value.currentScreen) + } + + fun onGranularSyncFinished(hasChanged: Boolean) { + launchUseCase(dispatcher.io()) { + if (hasChanged) { + mainNavigator.getCurrentIfProgram()?.programViewModel?.updateProgramQueries() + } + } + } + + private fun onPinSet() { + launchUseCase(dispatcher.io()) { + _homeEffects.send(HomeEffect.BlockSession) + } + } + + fun onAction(action: HomeAction) { + when (action) { + HomeAction.BackPressed -> onBackPressed() + HomeAction.MenuClicked -> onMenuClick() + HomeAction.SyncClicked -> onSyncAllClick() + HomeAction.FilterClicked -> showFilter() + is HomeAction.ScreenChanged -> onChangeScreen(action.screen) + HomeAction.PinSet -> onPinSet() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt index f9740fb75ec..3b13b4ecc97 100644 --- a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepository.kt @@ -7,9 +7,9 @@ import java.io.File interface HomeRepository { suspend fun user(): User? - suspend fun logOut() + suspend fun logOut(): Result - suspend fun clearPin() + suspend fun clearPin(): Result suspend fun hasHomeAnalytics(): Boolean @@ -34,4 +34,8 @@ interface HomeRepository { suspend fun getInitialSyncDone(): Boolean suspend fun isImportedDb(): Boolean + + suspend fun stopBackgroundSync() + + suspend fun restoreSyncStatus() } diff --git a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt index 09203ab805e..eb521b6fb4b 100644 --- a/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/main/data/HomeRepositoryImpl.kt @@ -8,9 +8,11 @@ import org.dhis2.commons.bindings.isStockProgram import org.dhis2.commons.bindings.programs import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.Preference.Companion.PIN +import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.mobile.commons.coroutine.Dispatcher import org.dhis2.mobile.commons.error.DomainErrorMapper import org.dhis2.mobile.commons.providers.PreferenceProvider +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.main.HomeItemData import org.dhis2.usescases.settings.deleteCache import org.dhis2.usescases.sync.WAS_INITIAL_SYNC_DONE @@ -26,6 +28,8 @@ class HomeRepositoryImpl( private val d2: D2, private val charts: Charts?, private val preferences: PreferenceProvider, + private val workManagerController: WorkManagerController, + private val syncStatusController: SyncStatusController, private val domainErrorMapper: DomainErrorMapper, private val dispatcher: Dispatcher, ) : HomeRepository { @@ -89,6 +93,7 @@ class HomeRepositoryImpl( override suspend fun logOut() = execute { d2.userModule().blockingLogOut() + Result.success(Unit) } override suspend fun clearPin() = @@ -99,6 +104,7 @@ class HomeRepositoryImpl( .localDataStore() .value(PIN) .blockingDeleteIfExist() + Result.success(Unit) } override suspend fun hasHomeAnalytics(): Boolean = @@ -187,4 +193,13 @@ class HomeRepositoryImpl( .accountManager() .deleteCurrentAccount() } + + override suspend fun stopBackgroundSync() { + workManagerController.cancelAllWorkAndWait() + workManagerController.pruneWork() + } + + override suspend fun restoreSyncStatus() { + syncStatusController.restore() + } } diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/CheckSingleNavigation.kt b/app/src/main/java/org/dhis2/usescases/main/domain/CheckSingleNavigation.kt new file mode 100644 index 00000000000..a3e066abb70 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/CheckSingleNavigation.kt @@ -0,0 +1,22 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.HomeItemData +import org.dhis2.usescases.main.data.HomeRepository + +class CheckSingleNavigation( + private val homeRepository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + val homeItemCount = homeRepository.homeItemCount() + if (homeItemCount == 1) { + Result.success(homeRepository.singleHomeItemData()) + } else { + Result.failure(DomainError.ConfigurationError("Expected exactly one home item")) + } + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBar.kt b/app/src/main/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBar.kt new file mode 100644 index 00000000000..ebd125de769 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBar.kt @@ -0,0 +1,51 @@ +package org.dhis2.usescases.main.domain + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import org.dhis2.R +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.ui.Form +import org.dhis2.utils.customviews.navigationbar.NavigationPage +import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem + +class ConfigureHomeNavigationBar( + private val homeRepository: HomeRepository, + private val resourceManager: ResourceManager, +) : UseCase>> { + override suspend operator fun invoke(input: Unit) = + try { + val list = + buildList { + add( + NavigationBarItem( + id = NavigationPage.PROGRAMS, + icon = Icons.Filled.Form, + label = resourceManager.getString(R.string.navigation_programs), + ) + ) + if (homeRepository.hasHomeAnalytics()) { + add( + NavigationBarItem( + id = NavigationPage.ANALYTICS, + icon = Icons.Filled.BarChart, + label = resourceManager.getString(R.string.navigation_charts), + ) + ) + } + } + Result.success(list) + } catch (_: DomainError) { + Result.success( + listOf( + NavigationBarItem( + id = NavigationPage.PROGRAMS, + icon = Icons.Filled.Form, + label = resourceManager.getString(R.string.navigation_programs), + ) + ) + ) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/DeleteAccount.kt b/app/src/main/java/org/dhis2/usescases/main/domain/DeleteAccount.kt new file mode 100644 index 00000000000..c8f6eb9df43 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/DeleteAccount.kt @@ -0,0 +1,25 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.commons.filters.FilterManager +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import java.io.File + +class DeleteAccount( + private val filterManager: FilterManager, + private val repository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: File?): Result = + try { + filterManager.clearAllFilters() + input?.let { repository.clearCache(it) } + repository.clearPreferences() + repository.wipeAll() + repository.deleteCurrentAccount() + val accountCount = repository.accountsCount() + Result.success(accountCount) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/GetHomeFilters.kt b/app/src/main/java/org/dhis2/usescases/main/domain/GetHomeFilters.kt new file mode 100644 index 00000000000..ac75779773d --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/GetHomeFilters.kt @@ -0,0 +1,17 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.commons.filters.FilterItem +import org.dhis2.commons.filters.data.FilterRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError + +class GetHomeFilters( + private val filterRepository: FilterRepository, +) : UseCase> { + override suspend fun invoke(input: Unit): Result> = + try { + Result.success(filterRepository.homeFilters()) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/GetLockAction.kt b/app/src/main/java/org/dhis2/usescases/main/domain/GetLockAction.kt new file mode 100644 index 00000000000..002a6ffa476 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/GetLockAction.kt @@ -0,0 +1,22 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.domain.model.LockAction + +class GetLockAction( + private val repository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + val isPinSet = repository.isPinStored() + if (isPinSet) { + Result.success(LockAction.BlockSession) + } else { + Result.success(LockAction.CreatePin) + } + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/GetUserName.kt b/app/src/main/java/org/dhis2/usescases/main/domain/GetUserName.kt new file mode 100644 index 00000000000..064ee7c7722 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/GetUserName.kt @@ -0,0 +1,20 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository + +class GetUserName( + private val homeRepository: HomeRepository, +) : UseCase { + override suspend operator fun invoke(input: Unit) = + try { + val user = homeRepository.user() + val firstName = user?.firstName() + val surname = user?.surname() + val userName = listOfNotNull(firstName, surname).joinToString(" ") + Result.success(userName) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt b/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt new file mode 100644 index 00000000000..886d645c9ff --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/LaunchInitialSync.kt @@ -0,0 +1,33 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.usescases.main.data.HomeRepository + +class LaunchInitialSync( + private val skipSync: Boolean, + private val homeRepository: HomeRepository, + private val versionRepository: VersionRepository, + private val syncBackgroundJobAction: SyncBackgroundJobAction, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + if (skipSync || homeRepository.isImportedDb() || homeRepository.getInitialSyncDone()) { + Result.success(InitialSyncAction.Skip) + } else { + versionRepository.checkVersionUpdates() + syncBackgroundJobAction.launchDataSync(0) + Result.success(InitialSyncAction.Syncing) + } + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} + +sealed interface InitialSyncAction { + data object Skip : InitialSyncAction + + data object Syncing : InitialSyncAction +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt b/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt index a54b2277973..b9243b956f5 100644 --- a/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt +++ b/app/src/main/java/org/dhis2/usescases/main/domain/LogoutUser.kt @@ -1,25 +1,26 @@ package org.dhis2.usescases.main.domain import org.dhis2.commons.filters.FilterManager -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.usescases.main.HomeRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.usescases.main.data.HomeRepository typealias AccountCount = Int class LogoutUser( private val repository: HomeRepository, - private val workManagerController: WorkManagerController, + private val syncBackgroundJobAction: SyncBackgroundJobAction, private val syncStatusController: SyncStatusController, private val filterManager: FilterManager, -) { - suspend operator fun invoke(): Result { - workManagerController.cancelAllWorkAndWait() +) : UseCase { + override suspend operator fun invoke(input: Unit): Result { + syncBackgroundJobAction.cancelAll() syncStatusController.restore() filterManager.clearAllFilters() repository - .clearSessionLock() + .clearPin() .onFailure { return Result.failure(it) } repository diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlert.kt b/app/src/main/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlert.kt new file mode 100644 index 00000000000..213c1000fac --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlert.kt @@ -0,0 +1,35 @@ +package org.dhis2.usescases.main.domain + +import androidx.work.ExistingWorkPolicy +import org.dhis2.commons.Constants +import org.dhis2.data.service.VersionRepository +import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.data.service.workManager.WorkerItem +import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import kotlin.time.Duration.Companion.days + +class ScheduleNewVersionAlert( + private val workManagerController: WorkManagerController, + private val versionRepository: VersionRepository, +) : UseCase { + + val newVersionFlow = versionRepository.newAppVersion + + override suspend fun invoke(input: Unit): Result = + try { + val workerItem = + WorkerItem( + Constants.NEW_APP_VERSION, + WorkerType.NEW_VERSION, + delayInSeconds = 1.days.inWholeSeconds, + policy = ExistingWorkPolicy.REPLACE, + ) + workManagerController.beginUniqueWork(workerItem) + versionRepository.removeVersionInfo() + Result.success(Unit) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatus.kt b/app/src/main/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatus.kt new file mode 100644 index 00000000000..c62d756a229 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatus.kt @@ -0,0 +1,17 @@ +package org.dhis2.usescases.main.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository + +class UpdateInitialSyncStatus( + val repository: HomeRepository, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + repository.setInitialSyncDone() + Result.success(Unit) + } catch (domainError: DomainError) { + Result.failure(domainError) + } +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/model/BottomNavigationItem.kt b/app/src/main/java/org/dhis2/usescases/main/domain/model/BottomNavigationItem.kt new file mode 100644 index 00000000000..43b4696c439 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/model/BottomNavigationItem.kt @@ -0,0 +1,7 @@ +package org.dhis2.usescases.main.domain.model + +sealed interface BottomNavigationItem { + data object Program : BottomNavigationItem + + data object Analytics : BottomNavigationItem +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/model/DownloadMethod.kt b/app/src/main/java/org/dhis2/usescases/main/domain/model/DownloadMethod.kt new file mode 100644 index 00000000000..c28de7abf9c --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/model/DownloadMethod.kt @@ -0,0 +1,11 @@ +package org.dhis2.usescases.main.domain.model + +sealed interface DownloadMethod { + data class Url( + val url: String, + ) : DownloadMethod + + data class File( + val path: String, + ) : DownloadMethod +} diff --git a/app/src/main/java/org/dhis2/usescases/main/domain/model/LockAction.kt b/app/src/main/java/org/dhis2/usescases/main/domain/model/LockAction.kt new file mode 100644 index 00000000000..01df14106f6 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/domain/model/LockAction.kt @@ -0,0 +1,7 @@ +package org.dhis2.usescases.main.domain.model + +sealed interface LockAction { + data object CreatePin : LockAction + + data object BlockSession : LockAction +} diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt index 9b3511c0f0b..6340a06b260 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramFragment.kt @@ -21,6 +21,7 @@ import org.dhis2.App import org.dhis2.R import org.dhis2.commons.sync.OnDismissListener import org.dhis2.commons.sync.SyncContext +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.usescases.general.FragmentGlobalAbstract import org.dhis2.usescases.main.navigateTo import org.dhis2.usescases.main.toHomeItemData @@ -28,12 +29,15 @@ import org.dhis2.utils.HelpManager import org.dhis2.utils.analytics.SELECT_PROGRAM import org.dhis2.utils.analytics.TYPE_PROGRAM_SELECTED import org.dhis2.utils.granularsync.SyncStatusDialog +import org.koin.android.ext.android.inject import timber.log.Timber import javax.inject.Inject class ProgramFragment : FragmentGlobalAbstract(), ProgramView { + private val syncStatusController: SyncStatusController by inject() + @Inject lateinit var programViewModelFactory: ProgramViewModelFactory @@ -51,7 +55,10 @@ class ProgramFragment : override fun onAttach(context: Context) { super.onAttach(context) activity?.let { - (it.applicationContext as App).userComponent()?.plus(ProgramModule(this))?.inject(this) + (it.applicationContext as App) + .userComponent() + ?.plus(ProgramModule(this, syncStatusController)) + ?.inject(this) } } diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt index f69e5d56660..72536aa84bd 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramModule.kt @@ -13,12 +13,13 @@ import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisProgramUtils -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 @Module class ProgramModule( private val view: ProgramView, + private val syncStatusController: SyncStatusController, ) { @Provides @PerFragment @@ -28,7 +29,6 @@ class ProgramModule( featureConfigRepository: FeatureConfigRepository, matomoAnalyticsController: MatomoAnalyticsController, filterManager: FilterManager, - syncStatusController: SyncStatusController, schedulerProvider: SchedulerProvider, ): ProgramViewModelFactory = ProgramViewModelFactory( diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt index 8070022df75..e670e95e689 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepository.kt @@ -1,7 +1,7 @@ package org.dhis2.usescases.main.program import io.reactivex.Flowable -import org.dhis2.data.service.SyncStatusData +import org.dhis2.mobile.sync.model.SyncStatusData interface ProgramRepository { fun homeItems(syncStatusData: SyncStatusData): Flowable> diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt index 7f1d6ed5119..fb87f283a06 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramRepositoryImpl.kt @@ -8,7 +8,7 @@ import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.dhislogic.DhisProgramUtils -import org.dhis2.data.service.SyncStatusData +import org.dhis2.mobile.sync.model.SyncStatusData import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.program.Program @@ -178,7 +178,7 @@ internal class ProgramRepositoryImpl( private fun getTrackerTeiCount(program: Program): Int { val teiIds = filterPresenter - .filteredTrackerProgram(program) + .filteredTrackerProgram(program.uid()) .offlineFirst() .blockingGetUids() val teiCount = teiIds.size diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt index 65e761d05d2..bbfcca25910 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramUi.kt @@ -57,9 +57,9 @@ import org.dhis2.R import org.dhis2.commons.bindings.addIf import org.dhis2.commons.date.toDateSpan import org.dhis2.commons.ui.icons.toIconData -import org.dhis2.data.service.SyncStatusData import org.dhis2.mobile.commons.extensions.toColorInt import org.dhis2.mobile.commons.model.MetadataIconData +import org.dhis2.mobile.sync.model.SyncStatusData import org.hisp.dhis.android.core.common.State import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem import org.hisp.dhis.mobile.ui.designsystem.component.Avatar diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt index 007acb62cf5..cc0ccd829d6 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModel.kt @@ -18,7 +18,8 @@ import org.dhis2.commons.matomo.Labels.Companion.CLICK_ON import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.koin.core.component.KoinComponent import timber.log.Timber import java.util.concurrent.TimeUnit @@ -31,7 +32,8 @@ class ProgramViewModel internal constructor( private val filterManager: FilterManager, private val syncStatusController: SyncStatusController, private val schedulerProvider: SchedulerProvider, -) : ViewModel() { +) : ViewModel(), + KoinComponent { private val _programs = MutableLiveData>() val programs: LiveData> = _programs private val refreshData = PublishProcessor.create() diff --git a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt index 1ac164a3b1c..e3b2f78c0ad 100644 --- a/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/main/program/ProgramViewModelFactory.kt @@ -7,7 +7,7 @@ import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController class ProgramViewModelFactory( private val view: ProgramView, diff --git a/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeAction.kt b/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeAction.kt new file mode 100644 index 00000000000..a21ea33e4be --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeAction.kt @@ -0,0 +1,15 @@ +package org.dhis2.usescases.main.ui.model + +import org.dhis2.usescases.main.MainScreenType + +/** + * Actions sent to the ViewModel from the UI. + */ +sealed interface HomeAction { + data object BackPressed : HomeAction + data object MenuClicked : HomeAction + data object SyncClicked : HomeAction + data object FilterClicked : HomeAction + data class ScreenChanged(val screen: MainScreenType) : HomeAction + data object PinSet : HomeAction +} diff --git a/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeEffect.kt b/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeEffect.kt new file mode 100644 index 00000000000..0830de90c0f --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeEffect.kt @@ -0,0 +1,36 @@ +package org.dhis2.usescases.main.ui.model + +import org.dhis2.commons.filters.FilterManager +import org.dhis2.usescases.main.HomeItemData + +/** + * Effects generated by the ViewModel and sent to the UI. + */ +sealed interface HomeEffect { + data class SingleProgramNavigation( + val homeItemData: HomeItemData, + ) : HomeEffect + + data class GoToLogin( + val accountsCount: Int, + val isDeletion: Boolean, + ) : HomeEffect + + data object ShowDeleteNotification : HomeEffect + + data object ShowGranularSync : HomeEffect + + data object BlockSession : HomeEffect + + data object ShowPinDialog : HomeEffect + + data class PeriodFilterRequest( + val periodRequest: FilterManager.PeriodRequest, + ) : HomeEffect + + data object OrgUnitFilterRequest : HomeEffect + + data object ToggleSideMenu : HomeEffect + + data object ToggleFilters : HomeEffect +} diff --git a/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeScreenState.kt b/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeScreenState.kt new file mode 100644 index 00000000000..9be1e1b0f42 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/ui/model/HomeScreenState.kt @@ -0,0 +1,43 @@ +package org.dhis2.usescases.main.ui.model + +import androidx.compose.runtime.Stable +import org.dhis2.commons.filters.FilterItem +import org.dhis2.usescases.main.MainScreenType +import org.dhis2.utils.customviews.navigationbar.NavigationPage +import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem + +@Stable +data class HomeScreenState( + val userName: String, + val navigationBarItems: List>, + val homeFilters: List, + val activeFilters: Int, + val versionToUpdate: VersionToUpdateState, + val filterButtonVisible: Boolean, + val bottomNavigationBarVisible: Boolean, + val syncButtonVisible: Boolean, + val currentScreen: MainScreenType, +) + +val defaultHomeScreenState = + HomeScreenState( + userName = "", + navigationBarItems = emptyList(), + homeFilters = emptyList(), + activeFilters = 0, + versionToUpdate = VersionToUpdateState.None, + filterButtonVisible = false, + bottomNavigationBarVisible = false, + syncButtonVisible = false, + currentScreen = MainScreenType.Loading, + ) + +sealed class VersionToUpdateState { + data object None : VersionToUpdateState() + + data class New( + val version: String, + ) : VersionToUpdateState() + + data object Downloading : VersionToUpdateState() +} diff --git a/app/src/main/java/org/dhis2/usescases/main/ui/screens/MainScreen.kt b/app/src/main/java/org/dhis2/usescases/main/ui/screens/MainScreen.kt new file mode 100644 index 00000000000..e2932bef505 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/main/ui/screens/MainScreen.kt @@ -0,0 +1,232 @@ +package org.dhis2.usescases.main.ui.screens + +import android.view.LayoutInflater +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.flow.Flow +import org.dhis2.R +import org.dhis2.databinding.ActivityMainBinding +import org.dhis2.mobile.commons.extensions.ObserveAsEvents +import org.dhis2.mobile.login.pin.domain.model.PinMode +import org.dhis2.mobile.login.pin.ui.components.PinDialog +import org.dhis2.usescases.main.HomeScreen +import org.dhis2.usescases.main.MainScreenType +import org.dhis2.usescases.main.ui.model.HomeAction +import org.dhis2.usescases.main.ui.model.HomeEffect +import org.dhis2.usescases.main.ui.model.HomeScreenState +import org.dhis2.utils.customviews.navigationbar.NavigationPage +import org.dhis2.utils.getOnToolbarColor +import org.dhis2.utils.getThemePrimaryColor +import org.hisp.dhis.mobile.ui.designsystem.component.Badge +import org.hisp.dhis.mobile.ui.designsystem.component.IconButton +import org.hisp.dhis.mobile.ui.designsystem.component.TopBar +import org.hisp.dhis.mobile.ui.designsystem.component.TopBarActionIcon +import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar + +@Composable +fun MainScreen( + screenState: HomeScreenState, + effects: Flow, + onAction: (HomeAction) -> Unit, + onEffect: (HomeEffect) -> Unit, + onNewState: (HomeScreenState) -> Unit, + onNewScreen: (MainScreenType) -> Unit, + onLayoutInflated: (ActivityMainBinding) -> Unit, +) { + var showPinDialog by remember { mutableStateOf(false) } + + BackHandler { onAction(HomeAction.BackPressed) } + + ObserveAsEvents(effects) { effect -> + if (effect is HomeEffect.ShowPinDialog) { + showPinDialog = true + } else { + onEffect(effect) + } + } + + LaunchedEffect(screenState) { + onNewState(screenState) + } + LaunchedEffect(screenState.currentScreen) { + onNewScreen(screenState.currentScreen) + } + + Scaffold( + topBar = { + HomeTopBar( + screenState = screenState, + onMenuClicked = { onAction(HomeAction.MenuClicked) }, + onSyncClicked = { onAction(HomeAction.SyncClicked) }, + onFilterClicked = { onAction(HomeAction.FilterClicked) }, + ) + }, + bottomBar = { + if (screenState.bottomNavigationBarVisible) { + HomeBottomBar(screenState) { navigationPage -> + when (navigationPage) { + NavigationPage.ANALYTICS -> onAction(HomeAction.ScreenChanged(MainScreenType.Home(HomeScreen.Visualizations))) + NavigationPage.PROGRAMS -> onAction(HomeAction.ScreenChanged(MainScreenType.Home(HomeScreen.Programs))) + else -> { /*no op*/ } + } + } + } + }, + ) { paddingValues -> + AndroidView( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + factory = { context -> + ActivityMainBinding + .inflate(LayoutInflater.from(context)) + .also { onLayoutInflated(it) } + .root + }, + ) + if (showPinDialog) { + PinDialog( + mode = PinMode.SET, + onSuccess = { + onAction(HomeAction.PinSet) + showPinDialog = false + }, + onDismiss = { showPinDialog = false }, + ) + } + } +} + +@Composable +fun HomeTopBar( + screenState: HomeScreenState, + onMenuClicked: () -> Unit, + onSyncClicked: () -> Unit, + onFilterClicked: () -> Unit, +) { + val primaryColor = getThemePrimaryColor() + val onToolbarColor = getOnToolbarColor() + TopBar( + navigationIcon = { + TopBarActionIcon( + icon = Icons.Filled.Menu, + tint = onToolbarColor, + contentDescription = "menu", + onClick = onMenuClicked, + ) + }, + actions = { + AnimatedVisibility(visible = screenState.syncButtonVisible) { + IconButton( + onClick = onSyncClicked, + icon = { + Icon( + imageVector = Icons.Filled.Sync, + contentDescription = "Sync", + tint = onToolbarColor, + ) + }, + ) + } + AnimatedVisibility( + visible = screenState.filterButtonVisible && screenState.homeFilters.isNotEmpty(), + ) { + IconButton( + onClick = onFilterClicked, + icon = { + BadgedBox( + badge = { + if (screenState.activeFilters > 0) { + Badge( + text = "${screenState.activeFilters}", + color = onToolbarColor, + textColor = primaryColor, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = "Filters", + tint = onToolbarColor, + ) + } + }, + ) + } + }, + title = { + when (screenState.currentScreen) { + MainScreenType.About -> R.string.about + is MainScreenType.Home -> R.string.done_task + MainScreenType.Loading -> null + MainScreenType.QRScanner -> R.string.QR_SCANNER + MainScreenType.Settings -> R.string.SYNC_MANAGER + MainScreenType.TroubleShooting -> R.string.main_menu_troubleshooting + }?.let { titleId -> + Text( + text = stringResource(titleId), + color = onToolbarColor, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors().copy( + containerColor = primaryColor, + titleContentColor = onToolbarColor, + ), + ) +} + +@Composable +fun HomeBottomBar( + homeScreenState: HomeScreenState, + onNavigationSelected: (NavigationPage) -> Unit, +) { + val navigationItems = homeScreenState.navigationBarItems + + val selectedItemIndex by remember(homeScreenState) { + derivedStateOf { + when { + homeScreenState.currentScreen.isPrograms() -> 0 + homeScreenState.currentScreen.isVisualizations() -> 1 + else -> null + } + } + } + NavigationBar( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + items = navigationItems, + selectedItemIndex = selectedItemIndex ?: 0, + onItemClick = { navigationPage -> onNavigationSelected(navigationPage) }, + ) +} diff --git a/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt b/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt index e6177b63925..2b2ae97b545 100644 --- a/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/notes/noteDetail/NoteDetailPresenter.kt @@ -2,6 +2,7 @@ package org.dhis2.usescases.notes.noteDetail import io.reactivex.disposables.CompositeDisposable import org.dhis2.commons.schedulers.SchedulerProvider +import org.dhis2.usescases.notes.NotesIdlingResource import timber.log.Timber class NoteDetailPresenter( @@ -33,6 +34,8 @@ class NoteDetailPresenter( disposable.add( repository .saveNote(noteType, uid, message) + .doOnSubscribe { NotesIdlingResource.increment() } + .doFinally { NotesIdlingResource.decrement() } .subscribeOn(scheduler.io()) .observeOn(scheduler.ui()) .subscribe( diff --git a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt index 1cf2f62d25b..256591f4ade 100644 --- a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailViewModel.kt @@ -14,9 +14,11 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.dhis2.R import org.dhis2.commons.resources.ResourceManager @@ -58,12 +60,10 @@ class ProgramEventDetailViewModel( private val _backdropActive = MutableLiveData() val backdropActive: LiveData get() = _backdropActive - private val _shouldNavigateToEventDetails: MutableSharedFlow = - MutableSharedFlow( - replay = Int.MAX_VALUE, - ) - val shouldNavigateToEventDetails: SharedFlow - get() = _shouldNavigateToEventDetails + private val _shouldNavigateToEventDetails = Channel() + + val shouldNavigateToEventDetails: Flow + get() = _shouldNavigateToEventDetails.receiveAsFlow() private val _navigationBarUIState = mutableStateOf(NavigationBarUIState()) val navigationBarUIState: State> = _navigationBarUIState @@ -172,7 +172,7 @@ class ProgramEventDetailViewModel( programStageUid = programStageUid, enrollmentUid = null, ).getOrNull()?.let { eventUid -> - _shouldNavigateToEventDetails.emit(eventUid) + _shouldNavigateToEventDetails.send(eventUid) } } } diff --git a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt index 896f7665d49..0538363ac81 100644 --- a/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/programEventDetail/ProgramEventMapper.kt @@ -275,6 +275,7 @@ class ProgramEventMapper( .byProgramStageUid() .eq(programStage) .withDataElements() + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() private fun getCategoryOptionCombo(attributeOptionCombo: String?) = diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/MapDataRepository.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/MapDataRepository.kt index c08b0304a96..6dd1b2908f7 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/MapDataRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/MapDataRepository.kt @@ -8,6 +8,7 @@ import org.dhis2.maps.geometry.mapper.featurecollection.MapCoordinateFieldToFeat import org.dhis2.maps.geometry.mapper.featurecollection.MapTeiEventsToFeatureCollection import org.dhis2.maps.geometry.mapper.featurecollection.MapTeisToFeatureCollection import org.dhis2.maps.layer.MapLayer +import org.dhis2.maps.layer.types.FieldMapLayer import org.dhis2.maps.utils.DhisMapUtils import org.hisp.dhis.android.core.program.Program import org.maplibre.geojson.FeatureCollection @@ -54,14 +55,30 @@ class MapDataRepository( val eventsByProgramStage = mapTeiEventsToFeatureCollection.map(mapEvents).component1() + val noFieldLayers = layersVisibility.values.none { it is FieldMapLayer } + val anyLayerVisible = layersVisibility.values.any { it.visible } + val teiItems = mapTeis.filterTeiByLayerVisibility(layersVisibility, coordinateAttributes) + val eventItems = mapEvents.filterTrackerEventsByLayerVisibility(layersVisibility, coordinateDataElements) + val relationshipItems = mapRelationships.filterRelationshipsByLayerVisibility(layersVisibility) + val extraTeis = + if (noFieldLayers && anyLayerVisible) { + mapTeis.filter { tei -> + tei !in teiItems && coordinateAttributes.any { it.tei.uid() == tei.uid } + } + } else { + emptyList() + } + val extraEvents = + if (noFieldLayers && anyLayerVisible) { + mapEvents.filter { event -> + event !in eventItems && coordinateDataElements.any { it.event.uid() == event.uid } + } + } else { + emptyList() + } + return TrackerMapData( - mapItems = - mapTeis.filterTeiByLayerVisibility(layersVisibility, coordinateAttributes) + - mapEvents.filterTrackerEventsByLayerVisibility( - layersVisibility, - coordinateDataElements, - ) + - mapRelationships.filterRelationshipsByLayerVisibility(layersVisibility), + mapItems = teiItems + extraTeis + eventItems + extraEvents + relationshipItems, eventFeatures = eventsByProgramStage, teiFeatures = teiFeatureCollection.first, teiBoundingBox = teiFeatureCollection.second, diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java index 0c199898d14..7d8a17ce1a2 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepository.java @@ -33,9 +33,6 @@ public interface SearchRepository { void clearFetchedList(); - @NonNull - Flowable> searchTeiForMap(SearchParametersModel searchParametersModel, boolean isOnline); - @NonNull Observable> saveToEnroll(@NonNull String teiType, @NonNull String orgUnitUID, @NonNull String programUid, @Nullable String teiUid, HashMap> queryData, @Nullable String fromRelationshipUid); @@ -47,14 +44,10 @@ public interface SearchRepository { TrackedEntityType getTrackedEntityType(); - List getEventsForMap(List teis); - Observable downloadTei(String teiUid); TeiDownloadResult download(String teiUid, @Nullable String enrollmentUid, String reason); - SearchTeiModel transform(TrackedEntitySearchItem searchItem, @Nullable Program selectedProgram, boolean offlineOnly, SortingItem sortingItem); - TrackedEntitySearchCollectionRepository getFilteredRepository(SearchParametersModel searchParametersModel); void setCurrentProgram(@Nullable String currentProgram); @@ -85,9 +78,4 @@ public interface SearchRepository { boolean filtersApplyOnGlobalSearch(); - @NotNull HashSet getFetchedTeiUIDs(); - - SearchParametersModel getSavedSearchParameters(); - - FilterManager getSavedFilters(); } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java index cd66d341233..c5c45ddd3c5 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java @@ -3,77 +3,44 @@ import android.database.sqlite.SQLiteConstraintException; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.dhis2.R; -import org.dhis2.bindings.ExtensionsKt; -import org.dhis2.bindings.ValueExtensionsKt; import org.dhis2.commons.Constants; import org.dhis2.commons.data.EntryMode; -import org.dhis2.commons.data.EventModel; -import org.dhis2.commons.data.EventViewModelType; import org.dhis2.commons.date.DateUtils; import org.dhis2.commons.filters.FilterManager; import org.dhis2.commons.filters.data.FilterPresenter; -import org.dhis2.commons.filters.sorting.SortingItem; -import org.dhis2.commons.network.NetworkUtils; -import org.dhis2.commons.resources.DhisPeriodUtils; -import org.dhis2.commons.resources.MetadataIconProvider; import org.dhis2.commons.resources.ResourceManager; +import org.dhis2.commons.viewmodel.DispatcherProvider; import org.dhis2.data.dhislogic.DhisEnrollmentUtils; import org.dhis2.data.forms.dataentry.SearchTEIRepository; import org.dhis2.data.forms.dataentry.ValueStore; import org.dhis2.data.forms.dataentry.ValueStoreImpl; import org.dhis2.data.search.SearchParametersModel; -import org.dhis2.data.sorting.SearchSortingValueSetter; import org.dhis2.metadata.usecases.FileResourceConfiguration; import org.dhis2.metadata.usecases.ProgramConfiguration; import org.dhis2.metadata.usecases.TrackedEntityInstanceConfiguration; import org.dhis2.mobile.commons.customintents.CustomIntentRepository; import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel; -import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider; +import org.dhis2.mobile.commons.network.NetworkStatusProvider; import org.dhis2.mobile.commons.reporting.CrashReportController; -import org.dhis2.tracker.data.ProfilePictureProvider; -import org.dhis2.tracker.relationships.model.RelationshipDirection; -import org.dhis2.tracker.relationships.model.RelationshipGeometry; -import org.dhis2.tracker.relationships.model.RelationshipModel; -import org.dhis2.tracker.relationships.model.RelationshipOwnerType; import org.dhis2.ui.ThemeManager; import org.dhis2.usescases.teiDownload.TeiDownloader; -import org.dhis2.utils.ValueUtils; import org.hisp.dhis.android.core.D2; import org.hisp.dhis.android.core.arch.call.D2Progress; import org.hisp.dhis.android.core.arch.helpers.UidsHelper; -import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope; import org.hisp.dhis.android.core.common.FeatureType; -import org.hisp.dhis.android.core.common.ObjectStyle; -import org.hisp.dhis.android.core.common.State; import org.hisp.dhis.android.core.common.ValueType; -import org.hisp.dhis.android.core.enrollment.Enrollment; -import org.hisp.dhis.android.core.enrollment.EnrollmentCollectionRepository; import org.hisp.dhis.android.core.enrollment.EnrollmentCreateProjection; -import org.hisp.dhis.android.core.enrollment.EnrollmentStatus; -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.Program; -import org.hisp.dhis.android.core.program.ProgramStage; import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute; -import org.hisp.dhis.android.core.relationship.Relationship; -import org.hisp.dhis.android.core.relationship.RelationshipItem; -import org.hisp.dhis.android.core.relationship.RelationshipItemTrackedEntityInstance; import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationsGroup; import org.hisp.dhis.android.core.settings.ProgramConfigurationSetting; import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute; -import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue; -import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance; import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstanceCreateProjection; import org.hisp.dhis.android.core.trackedentity.TrackedEntityType; import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttribute; import org.hisp.dhis.android.core.trackedentity.internal.TrackedEntityInstanceDownloader; import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchCollectionRepository; -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem; -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemAttribute; -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemHelper; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; @@ -86,44 +53,30 @@ import java.util.Map; import dhis2.org.analytics.charts.Charts; -import io.reactivex.Flowable; import io.reactivex.Observable; import io.reactivex.Single; import kotlin.Pair; -import kotlin.Triple; public class SearchRepositoryImpl implements SearchRepository { private final String teiType; private final ResourceManager resources; private final D2 d2; - private final SearchSortingValueSetter sortingValueSetter; + private final DispatcherProvider dispatcherProvider; private TrackedEntitySearchCollectionRepository trackedEntityInstanceQuery; - public SearchParametersModel savedSearchParameters; - private FilterManager savedFilters; private FilterPresenter filterPresenter; - private DhisPeriodUtils periodUtils; private String currentProgram; private final Charts charts; private final CrashReportController crashReportController; private DateUtils dateUtils; - private final NetworkUtils networkUtils; + private final NetworkStatusProvider networkStatusProvider; private final SearchTEIRepository searchTEIRepository; private TrackedEntityInstanceDownloader downloadRepository = null; private ThemeManager themeManager; private HashSet fetchedTeiUids = new HashSet<>(); private TeiDownloader teiDownloader; private HashMap programCache = new HashMap<>(); - private HashMap orgUnitNameCache = new HashMap<>(); - private HashMap profilePictureCache = new HashMap<>(); - - private HashMap> attributesUidsCache = new HashMap(); - - private HashMap> trackedEntityTypeAttributesUidsCache = new HashMap(); - - private final MetadataIconProvider metadataIconProvider; - private final ProfilePictureProvider profilePictureProvider; private CustomIntentRepository customIntentRepository; SearchRepositoryImpl(String teiType, @@ -131,29 +84,24 @@ public class SearchRepositoryImpl implements SearchRepository { D2 d2, FilterPresenter filterPresenter, ResourceManager resources, - SearchSortingValueSetter sortingValueSetter, - DhisPeriodUtils periodUtils, Charts charts, CrashReportController crashReportController, - NetworkUtils networkUtils, + NetworkStatusProvider networkStatusProvider, SearchTEIRepository searchTEIRepository, ThemeManager themeManager, - MetadataIconProvider metadataIconProvider, - ProfilePictureProvider profilePictureProvider, DateUtils dateUtils, - CustomIntentRepository customIntentRepository + CustomIntentRepository customIntentRepository, + DispatcherProvider dispatcherProvider ) { this.teiType = teiType; this.d2 = d2; this.resources = resources; - this.sortingValueSetter = sortingValueSetter; this.filterPresenter = filterPresenter; - this.periodUtils = periodUtils; this.charts = charts; this.crashReportController = crashReportController; this.dateUtils = dateUtils; this.currentProgram = initialProgram; - this.networkUtils = networkUtils; + this.networkStatusProvider = networkStatusProvider; this.searchTEIRepository = searchTEIRepository; this.themeManager = themeManager; this.teiDownloader = new TeiDownloader( @@ -162,9 +110,8 @@ public class SearchRepositoryImpl implements SearchRepository { new FileResourceConfiguration(d2), currentProgram, resources); - this.metadataIconProvider = metadataIconProvider; - this.profilePictureProvider = profilePictureProvider; this.customIntentRepository = customIntentRepository; + this.dispatcherProvider = dispatcherProvider; } @@ -184,36 +131,15 @@ public void clearFetchedList() { fetchedTeiUids.clear(); } - @NonNull - @Override - public Flowable> searchTeiForMap(SearchParametersModel searchParametersModel, boolean isOnline) { - - boolean allowCache = false; - if (!searchParametersModel.equals(savedSearchParameters) || !FilterManager.getInstance().equals(savedFilters)) { - trackedEntityInstanceQuery = getFilteredRepository(searchParametersModel); - } else { - allowCache = true; - } - - if (isOnline && FilterManager.getInstance().getStateFilters().isEmpty()) - return trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineFirst().get().toFlowable() - .flatMapIterable(list -> list) - .map(tei -> transform(tei, searchParametersModel.getSelectedProgram(), false, FilterManager.getInstance().getSortingItem())) - .toList().toFlowable(); - else - return trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineOnly().get().toFlowable() - .flatMapIterable(list -> list) - .map(tei -> transform(tei, searchParametersModel.getSelectedProgram(), true, FilterManager.getInstance().getSortingItem())) - .toList().toFlowable(); - } @Override public TrackedEntitySearchCollectionRepository getFilteredRepository(SearchParametersModel searchParametersModel) { - this.savedSearchParameters = searchParametersModel.copy(); - this.savedFilters = FilterManager.getInstance().copy(); + String programUid = searchParametersModel.getSelectedProgram() != null + ? searchParametersModel.getSelectedProgram().uid() + : null; trackedEntityInstanceQuery = filterPresenter.filteredTrackedEntityInstances( - searchParametersModel.getSelectedProgram(), teiType + programUid, teiType ); for (int i = 0; i < searchParametersModel.getQueryData().keySet().size(); i++) { @@ -305,10 +231,10 @@ public Observable> saveToEnroll(@NonNull String teiType, EntryMode.ATTR, new DhisEnrollmentUtils(d2), crashReportController, - networkUtils, searchTEIRepository, - new FieldErrorMessageProvider(), - resources + resources, + networkStatusProvider, + dispatcherProvider ); if (queryData.containsKey(Constants.ENROLLMENT_DATE_UID)) @@ -359,260 +285,6 @@ public Observable> getOrgUnits(@Nullable String selectedP } - private void setEnrollmentInfo(SearchTeiModel searchTei) { - List enrollments = - d2.enrollmentModule().enrollments() - .byTrackedEntityInstance().eq(searchTei.getTei().uid()) - .byDeleted().eq(false) - .orderByCreated(RepositoryScope.OrderByDirection.DESC) - .blockingGet(); - for (Enrollment enrollment : enrollments) { - if (enrollments.indexOf(enrollment) == 0) - searchTei.resetEnrollments(); - searchTei.addEnrollment(enrollment); - Program program = getProgram(enrollment.program()); - if (program.displayFrontPageList()) { - searchTei.addProgramInfo( - program, - metadataIconProvider.invoke(program.style()) - ); - } - searchTei.addEnrollmentInfo(getProgramInfo(program)); - } - } - - private Triple getProgramInfo(Program program) { - String programColor = program.style() != null && program.style().color() != null ? program.style().color() : ""; - String programIcon = program.style() != null && program.style().icon() != null ? program.style().icon() : ""; - return new Triple<>(program.displayName(), programColor, programIcon); - } - - private void setAttributesInfo(SearchTeiModel searchTei, TrackedEntitySearchItem searchTeiItem) { - for (TrackedEntitySearchItemAttribute attribute : searchTeiItem.getAttributeValues()) { - if (attribute.getDisplayInList() && isAcceptedValueType(attribute.getValueType())) { - setAttributeValue(searchTei, attribute); - } - } - } - - private boolean isAcceptedValueType(ValueType valueType) { - return switch (valueType) { - case IMAGE, COORDINATE, FILE_RESOURCE -> false; - default -> true; - }; - } - - private void setAttributeValue(SearchTeiModel searchTei, TrackedEntitySearchItemAttribute attribute) { - String value = attribute.getValue(); - String transformedValue; - if (value != null) { - transformedValue = ValueUtils.Companion.transformValue(d2, value, attribute.getValueType(), attribute.getOptionSet()); - } else { - transformedValue = sortingValueSetter.getUnknownLabel(); - } - TrackedEntityAttributeValue attributeValue = TrackedEntityAttributeValue.builder() - .created(attribute.getCreated()) - .lastUpdated(attribute.getLastUpdated()) - .trackedEntityAttribute(attribute.getAttribute()) - .trackedEntityInstance(searchTei.getTei().uid()) - .value(transformedValue) - .build(); - - searchTei.addAttributeValue(attribute.getDisplayFormName(), attributeValue); - - if (attribute.getValueType() == ValueType.TEXT || attribute.getValueType() == ValueType.LONG_TEXT) { - searchTei.addTextAttribute(attribute.getDisplayName(), attributeValue); - } - } - - private void setOverdueEvents(@NonNull SearchTeiModel tei, Program selectedProgram) { - String teiId = tei.getTei() != null && tei.getTei().uid() != null ? tei.getTei().uid() : ""; - List enrollments = d2.enrollmentModule().enrollments().byTrackedEntityInstance().eq(teiId).blockingGet(); - - EventCollectionRepository overdueEvents = d2.eventModule().events() - .byEnrollmentUid().in(UidsHelper.getUidsList(enrollments)) - .byStatus().eq(EventStatus.OVERDUE); - - EventCollectionRepository overdueScheduledEvents = d2.eventModule().events() - .byEnrollmentUid().in(UidsHelper.getUidsList(enrollments)) - .byStatus().eq(EventStatus.SCHEDULE); - - if (selectedProgram != null) { - overdueEvents = overdueEvents.byProgramUid().eq(selectedProgram.uid()); - overdueScheduledEvents = overdueScheduledEvents.byProgramUid().eq(selectedProgram.uid()); - } - - List overdueList = overdueEvents.orderByDueDate(RepositoryScope.OrderByDirection.DESC).blockingGet(); - List scheduledList = overdueScheduledEvents.orderByDueDate(RepositoryScope.OrderByDirection.DESC).blockingGet(); - - List filteredScheduled = new ArrayList<>(); - for (Event event : scheduledList) { - if (Boolean.TRUE.equals(dateUtils.isEventDueDateOverdue(event.dueDate()))) { - filteredScheduled.add(event); - } - } - - for (Event event : overdueList) { - if (Boolean.TRUE.equals(dateUtils.isEventDueDateOverdue(event.dueDate()))) { - filteredScheduled.add(event); - } - } - - List combinedOverdue = new ArrayList<>(filteredScheduled); - - if (!combinedOverdue.isEmpty()) { - combinedOverdue.sort((e1, e2) -> { - if (e1.dueDate() == null || e2.dueDate() == null) return 0; - return e2.dueDate().compareTo(e1.dueDate()); - }); - tei.setHasOverdue(true); - tei.setOverdueDate(combinedOverdue.get(0).dueDate()); - } - } - - private void setRelationshipsInfo(@NonNull SearchTeiModel searchTeiModel, Program selectedProgram) { - List relationshipModels = new ArrayList<>(); - List relationships = d2.relationshipModule().relationships().getByItem( - RelationshipItem.builder().trackedEntityInstance( - RelationshipItemTrackedEntityInstance.builder() - .trackedEntityInstance(searchTeiModel.getTei().uid()) - .build() - ).build() - ); - for (Relationship relationship : relationships) { - if (relationship.from().trackedEntityInstance() != null) { - String relationshipTEIUid; - RelationshipDirection direction; - if (!searchTeiModel.getTei().uid().equals(relationship.from().trackedEntityInstance().trackedEntityInstance())) { - relationshipTEIUid = relationship.from().trackedEntityInstance().trackedEntityInstance(); - direction = RelationshipDirection.FROM; - } else { - relationshipTEIUid = relationship.to().trackedEntityInstance().trackedEntityInstance(); - direction = RelationshipDirection.TO; - } - - String fromTeiUid = relationship.from().trackedEntityInstance().trackedEntityInstance(); - String toTeiUid = relationship.to().trackedEntityInstance().trackedEntityInstance(); - - TrackedEntityInstance fromTei = d2.trackedEntityModule().trackedEntityInstances().uid(fromTeiUid).blockingGet(); - TrackedEntityInstance toTei = d2.trackedEntityModule().trackedEntityInstances().uid(toTeiUid).blockingGet(); - - List> fromValues = new ArrayList<>(); - List fromAttr = getTrackedEntityAttributesForRelationship(fromTei, selectedProgram); - List> toValues = new ArrayList<>(); - List toAttr = getTrackedEntityAttributesForRelationship(toTei, selectedProgram); - for (TrackedEntityAttributeValue attributeValue : fromAttr) { - fromValues.add(new kotlin.Pair<>(attributeValue.trackedEntityAttribute(), attributeValue.value())); - } - for (TrackedEntityAttributeValue attributeValue : toAttr) { - toValues.add(new kotlin.Pair<>(attributeValue.trackedEntityAttribute(), attributeValue.value())); - } - - RelationshipGeometry fromGeometry = null; - RelationshipGeometry toGeometry = null; - - if (fromTei.geometry() != null) { - fromGeometry = new RelationshipGeometry( - fromTei.geometry().type().name(), - fromTei.geometry().coordinates() - ); - } - - if (toTei.geometry() != null) { - toGeometry = new RelationshipGeometry( - toTei.geometry().type().name(), - toTei.geometry().coordinates() - ); - } - - relationshipModels.add(new RelationshipModel( - relationship.uid(), - relationship.syncState().name(), - fromGeometry, - toGeometry, - direction, - relationshipTEIUid, - RelationshipOwnerType.TEI, - fromValues, - toValues, - profilePicturePath(fromTei, selectedProgram.uid()), - profilePicturePath(toTei, selectedProgram.uid()), - getTeiDefaultRes(fromTei), - getTeiDefaultRes(toTei), - null, - null, - true, - null, - null, - null, - null - )); - } - } - - searchTeiModel.setRelationships(relationshipModels); - } - - private String profilePicturePath(TrackedEntityInstance tei, String programUid) { - if (!profilePictureCache.containsKey(tei.uid())) { - profilePictureCache.put(tei.uid(), ExtensionsKt.profilePicturePath(tei, d2, programUid)); - } - return profilePictureCache.get(tei.uid()); - } - - private List getProgramAttributeUids(String programUid) { - if (!attributesUidsCache.containsKey(programUid)) { - List attributeUids = new ArrayList<>(); - List programTrackedEntityAttributes = d2.programModule().programTrackedEntityAttributes() - .byProgram().eq(programUid) - .byDisplayInList().isTrue() - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .blockingGet(); - for (ProgramTrackedEntityAttribute programAttribute : programTrackedEntityAttributes) { - attributeUids.add(programAttribute.trackedEntityAttribute().uid()); - } - attributesUidsCache.put(programUid, attributeUids); - } - - return attributesUidsCache.get(programUid); - } - - private List getTETypeAttributeUids(String teTypeUid) { - if (!trackedEntityTypeAttributesUidsCache.containsKey(teTypeUid)) { - List attributeUids = new ArrayList<>(); - List typeAttributes = d2.trackedEntityModule().trackedEntityTypeAttributes() - .byTrackedEntityTypeUid().eq(teTypeUid) - .byDisplayInList().isTrue() - .blockingGet(); - - for (TrackedEntityTypeAttribute typeAttribute : typeAttributes) { - attributeUids.add(typeAttribute.trackedEntityAttribute().uid()); - } - } - return trackedEntityTypeAttributesUidsCache.get(teTypeUid); - } - - private int getTeiDefaultRes(TrackedEntityInstance tei) { - TrackedEntityType teiTypeValues = d2.trackedEntityModule().trackedEntityTypes().uid(tei.trackedEntityType()).blockingGet(); - return resources.getObjectStyleDrawableResource(teiTypeValues.style().icon(), R.drawable.photo_temp_gray); - } - - private List getTrackedEntityAttributesForRelationship(TrackedEntityInstance tei, Program selectedProgram) { - - List values; - values = d2.trackedEntityModule().trackedEntityAttributeValues() - .byTrackedEntityInstance().eq(tei.uid()) - .byTrackedEntityAttribute().in(getProgramAttributeUids(selectedProgram.uid())).blockingGet(); - - if (values.isEmpty()) { - values = d2.trackedEntityModule().trackedEntityAttributeValues() - .byTrackedEntityInstance().eq(tei.uid()) - .byTrackedEntityAttribute().in(getTETypeAttributeUids(tei.trackedEntityType())).blockingGet(); - } - - return values; - } - @Override public String getProgramColor(@NonNull String programUid) { Program program = getProgram(programUid); @@ -666,20 +338,6 @@ public boolean filtersApplyOnGlobalSearch() { !FilterManager.getInstance().getStateFilters().isEmpty(); } - @Override - public @NotNull HashSet getFetchedTeiUIDs() { - return fetchedTeiUids; - } - - @Override - public SearchParametersModel getSavedSearchParameters() { - return savedSearchParameters; - } - - @Override - public FilterManager getSavedFilters() { - return savedFilters; - } @Override public Observable getTrackedEntityType(String trackedEntityUid) { @@ -691,72 +349,6 @@ public TrackedEntityType getTrackedEntityType() { return d2.trackedEntityModule().trackedEntityTypes().uid(teiType).blockingGet(); } - @Override - public List getEventsForMap(List teis) { - List eventModels = new ArrayList<>(); - List teiUidList = new ArrayList<>(); - for (SearchTeiModel tei : teis) { - teiUidList.add(tei.getTei().uid()); - } - - List events = d2.eventModule().events() - .byTrackedEntityInstanceUids(teiUidList) - .byDeleted().isFalse() - .blockingGet(); - - HashMap cacheStages = new HashMap<>(); - - for (Event event : events) { - if (!cacheStages.containsKey(event.programStage())) { - ProgramStage stage = d2.programModule().programStages() - .uid(event.programStage()) - .blockingGet(); - cacheStages.put(event.programStage(), stage); - } - - eventModels.add( - new EventModel( - EventViewModelType.EVENT, - cacheStages.get(event.programStage()), - event, - 0, - null, - true, - true, - orgUnitName(event.organisationUnit()), - true, - null, - null, - false, - false, - false, - false, - false, - 0, - periodUtils.getPeriodUIString(cacheStages.get(event.programStage()).periodType(), event.eventDate() != null ? event.eventDate() : event.dueDate(), Locale.getDefault()), - null, - metadataIconProvider.invoke(cacheStages.get(event.programStage()).style()), - true, - true - )); - } - - return eventModels; - } - - private String orgUnitName(String orgUnitUid) { - if (!orgUnitNameCache.containsKey(orgUnitUid)) { - OrganisationUnit organisationUnit = d2.organisationUnitModule() - .organisationUnits() - .uid(orgUnitUid) - .blockingGet(); - if (organisationUnit != null) { - orgUnitNameCache.put(orgUnitUid, organisationUnit.displayName()); - } - } - return orgUnitNameCache.get(orgUnitUid); - } - @Override public Observable downloadTei(String teiUid) { downloadRepository = d2.trackedEntityModule().trackedEntityInstanceDownloader() @@ -775,122 +367,6 @@ public TeiDownloadResult download(String teiUid, @Nullable String enrollmentUid, return teiDownloader.download(teiUid, enrollmentUid, reason); } - @Override - public SearchTeiModel transform(TrackedEntitySearchItem searchItem, @Nullable Program selectedProgram, boolean offlineOnly, SortingItem sortingItem) { - if (!fetchedTeiUids.contains(searchItem.uid())) { - fetchedTeiUids.add(searchItem.uid()); - } - TrackedEntityInstance teiFromItem = TrackedEntitySearchItemHelper.INSTANCE.toTrackedEntityInstance(searchItem); - - TrackedEntityInstance dbTei = searchItem.isOnline() ? - d2.trackedEntityModule().trackedEntityInstances().uid(searchItem.uid()).blockingGet() : - teiFromItem; - - SearchTeiModel searchTei = new SearchTeiModel(); - if (dbTei != null && dbTei.aggregatedSyncState() != State.RELATIONSHIP) { - searchTei.setTei(dbTei); - EnrollmentCollectionRepository enrollmentRepository = d2.enrollmentModule().enrollments() - .byTrackedEntityInstance().eq(dbTei.uid()); - - if (selectedProgram != null) { - enrollmentRepository = enrollmentRepository.byProgram().eq(selectedProgram.uid()); - } - - List enrollmentsInProgram = enrollmentRepository - .orderByEnrollmentDate(RepositoryScope.OrderByDirection.DESC) - .blockingGet(); - - if (selectedProgram != null && !enrollmentsInProgram.isEmpty()) { - for (Enrollment enrollment : enrollmentsInProgram) { - if (enrollment.status() == EnrollmentStatus.ACTIVE) { - searchTei.setCurrentEnrollment(enrollment); - break; - } - } - if (searchTei.getSelectedEnrollment() == null) { - searchTei.setCurrentEnrollment(enrollmentsInProgram.get(0)); - } - } - // set online parameter from the tei search item - searchTei.setOnline(searchItem.isOnline()); - // If search is being conducted offline only, set the search as offline - if (offlineOnly) - searchTei.setOnline(false); - // if the local database tei is deleted, set search as online - if (Boolean.TRUE.equals(dbTei.deleted())) { - searchTei.setOnline(true); - } - - setEnrollmentInfo(searchTei); - setAttributesInfo(searchTei, searchItem); - setOverdueEvents(searchTei, selectedProgram); - if (selectedProgram != null) { - setRelationshipsInfo(searchTei, selectedProgram); - } - if (!searchItem.getProgramOwners().isEmpty() && - !searchItem.getProgramOwners().get(0).getOwnerOrgUnit().equals(searchItem.getOrganisationUnit())) { - searchTei.setOwnerOrgUnit(orgUnitName(searchItem.getProgramOwners().get(0).getOwnerOrgUnit())); - } - if (searchTei.getSelectedEnrollment() != null) { - searchTei.setEnrolledOrgUnit(orgUnitName(searchTei.getSelectedEnrollment().organisationUnit())); - } else { - searchTei.setEnrolledOrgUnit(orgUnitName(searchTei.getTei().organisationUnit())); - } - searchTei.setProfilePicture(profilePictureProvider.invoke(dbTei, selectedProgram != null ? selectedProgram.uid() : null)); - } else { - searchTei.setTei(teiFromItem); - if (!searchItem.getProgramOwners().isEmpty() && - searchItem.getProgramOwners().get(0).getOwnerOrgUnit().equals(searchItem.getOrganisationUnit())) { - searchTei.setEnrolledOrgUnit(orgUnitName(searchTei.getTei().organisationUnit())); - } else { - searchTei.setEnrolledOrgUnit(orgUnitName(searchTei.getTei().organisationUnit())); - if (!searchItem.getProgramOwners().isEmpty()) { - searchTei.setOwnerOrgUnit(orgUnitName(searchItem.getProgramOwners().get(0).getOwnerOrgUnit())); - } - } - - for (TrackedEntitySearchItemAttribute attribute : searchItem.getAttributeValues()) { - if (attribute.getDisplayInList()) { - addAttribute(searchTei, attribute); - } - } - } - - ObjectStyle os = searchItem.getType().style(); - searchTei.setDefaultTypeIcon(os != null ? os.icon() : null); - - searchTei.setHeader(searchItem.getHeader()); - searchTei.setSortingValue(sortingValueSetter.setSortingItem(searchTei, sortingItem)); - searchTei.setTEType(searchItem.getType().displayName()); - searchTei.setDisplayOrgUnit(displayOrgUnit()); - return searchTei; - } - - private void addAttribute(SearchTeiModel searchTei, TrackedEntitySearchItemAttribute att) { - TrackedEntityAttributeValue attributeValue = TrackedEntityAttributeValue.builder() - .value(att.getValue()) - .created(att.getCreated()) - .lastUpdated(att.getLastUpdated()) - .trackedEntityAttribute(att.getAttribute()) - .trackedEntityInstance(searchTei.getTei().uid()) - .build(); - - String friendlyValue = ValueExtensionsKt.userFriendlyValue(attributeValue, d2, true); - - TrackedEntityAttributeValue friendlyAttributeValue = attributeValue.toBuilder() - .value(friendlyValue) - .build(); - - if (att.getValueType() == ValueType.IMAGE) - searchTei.setProfilePicture(att.getAttribute()); - - searchTei.addAttributeValue(att.getDisplayFormName(), friendlyAttributeValue); - } - - private String profilePicturePath(TrackedEntityInstance tei, @Nullable Program selectedProgram) { - return ExtensionsKt.profilePicturePath(tei, d2, selectedProgram != null ? selectedProgram.uid() : null); - } - @Override public void setCurrentProgram(String currentProgram) { this.currentProgram = currentProgram; @@ -1012,12 +488,6 @@ public boolean canCreateInProgramWithoutSearch() { } } - private boolean displayOrgUnit() { - return d2.organisationUnitModule().organisationUnits() - .byProgramUids(Collections.singletonList(currentProgram)) - .blockingCount() > 1; - } - 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 63daa1aa06b..ebe848c5748 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt @@ -2,54 +2,38 @@ package org.dhis2.usescases.searchTrackEntity -import androidx.paging.PagingData -import androidx.paging.map import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import org.dhis2.commons.filters.FilterManager -import org.dhis2.commons.resources.MetadataIconProvider +import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.OptionSetConfiguration -import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.maps.model.MapItemModel import org.dhis2.mobile.commons.customintents.CustomIntentRepository -import org.dhis2.mobile.commons.extensions.toColor import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel -import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.FieldUid +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult import org.dhis2.usescases.events.EventInfoProvider import org.dhis2.usescases.tracker.TrackedEntityInstanceInfoProvider import org.hisp.dhis.android.core.D2 -import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.common.ObjectStyle import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute -import org.hisp.dhis.android.core.program.SectionRenderingType import org.hisp.dhis.android.core.relationship.RelationshipItem import org.hisp.dhis.android.core.relationship.RelationshipItemTrackedEntityInstance -import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchCollectionRepository import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemHelper.toTrackedEntityInstance -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import timber.log.Timber class SearchRepositoryImplKt( private val searchRepositoryJava: SearchRepository, private val d2: D2, private val dispatcher: DispatcherProvider, - private val fieldViewModelFactory: FieldViewModelFactory, - private val metadataIconProvider: MetadataIconProvider, private val trackedEntityInstanceInfoProvider: TrackedEntityInstanceInfoProvider, private val eventInfoProvider: EventInfoProvider, private val customIntentRepository: CustomIntentRepository, @@ -62,86 +46,35 @@ class SearchRepositoryImplKt( private val fetchedTeiUids = HashSet() - override fun searchTrackedEntities( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): Flow> = - trackedEntitySearchQuery(searchParametersModel, isOnline) - .getPagingData(10) - - private fun trackedEntitySearchQuery( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): TrackedEntitySearchCollectionRepository { - var allowCache = false - savedSearchParameters = searchParametersModel.copy() - savedFilters = FilterManager.getInstance().copy() - - if (searchParametersModel != savedSearchParameters || - !FilterManager - .getInstance() - .sameFilters(savedFilters) - ) { - trackedEntityInstanceQuery = - searchRepositoryJava.getFilteredRepository(searchParametersModel) - } else { - trackedEntityInstanceQuery = - searchRepositoryJava.getFilteredRepository(searchParametersModel) - allowCache = true + override fun saveSearchValuesAndGetAllowCache( + queryData: MutableMap?>?, + programUid: String?, + ): Boolean { + if (!this::savedSearchParameters.isInitialized) { + savedSearchParameters = + SearchParametersModel( + queryData = queryData, + selectedProgram = searchRepositoryJava.getProgram(programUid), + ) } - - if (fetchedTeiUids.isNotEmpty() && searchParametersModel.selectedProgram == null) { - trackedEntityInstanceQuery = - trackedEntityInstanceQuery.excludeUids().`in`(fetchedTeiUids.toList()) + if (!this::savedFilters.isInitialized) { + savedFilters = FilterManager.getInstance().copy() } - - val pagerFlow = - if (isOnline && FilterManager.getInstance().stateFilters.isEmpty()) { - trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineFirst() - } else { - trackedEntityInstanceQuery.allowOnlineCache().eq(allowCache).offlineOnly() - } - - return pagerFlow + val allowCache = + queryData == savedSearchParameters.queryData && + FilterManager + .getInstance() + .sameFilters(savedFilters) + savedSearchParameters = savedSearchParameters.copy(queryData = queryData) + savedFilters = FilterManager.getInstance().copy() + return allowCache } - override suspend fun searchParameters( - programUid: String?, - teiTypeUid: String, - ): List = - withContext(dispatcher.io()) { - val searchParameters = - programUid?.let { - programTrackedEntityAttributes(programUid) - } ?: trackedEntitySearchFields(teiTypeUid) - - sortSearchParameters(searchParameters) + override fun getExcludeValues(): HashSet? = + fetchedTeiUids.ifEmpty { + null } - fun sortSearchParameters(parameters: List): List = - parameters.sortedWith( - compareByDescending { - it.renderingType?.isQROrBarcode() == true && isUnique(it.uid) - }.thenByDescending { - it.renderingType?.isQROrBarcode() == true - }.thenByDescending { isUnique(it.uid) }, - ) - - private fun isUnique(teaUid: String): Boolean = - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(teaUid) - .blockingGet() - ?.unique() ?: false - - override suspend fun searchTrackedEntitiesImmediate( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): List = - trackedEntitySearchQuery(searchParametersModel, isOnline) - .blockingGet() - override fun searchTeiForMap( searchParametersModel: SearchParametersModel, isOnline: Boolean, @@ -198,7 +131,7 @@ class SearchRepositoryImplKt( } val attributeValues = - trackedEntityInstanceInfoProvider.getTeiAdditionalInfoList( + trackedEntityInstanceInfoProvider.getTeiAdditionalInfoListForMap( searchItem.attributeValues ?: emptyList(), ) @@ -234,6 +167,83 @@ class SearchRepositoryImplKt( ) } + override fun trackerValueTypeToSDKValueType(trackerInputType: TrackerInputType): ValueType? = + when (trackerInputType) { + TrackerInputType.TEXT -> ValueType.TEXT + TrackerInputType.LONG_TEXT -> ValueType.LONG_TEXT + TrackerInputType.LETTER -> ValueType.LETTER + TrackerInputType.PHONE_NUMBER -> ValueType.PHONE_NUMBER + TrackerInputType.EMAIL -> ValueType.EMAIL + TrackerInputType.URL -> ValueType.URL + TrackerInputType.NUMBER -> ValueType.NUMBER + TrackerInputType.INTEGER -> ValueType.INTEGER + TrackerInputType.INTEGER_POSITIVE -> ValueType.INTEGER_POSITIVE + TrackerInputType.INTEGER_NEGATIVE -> ValueType.INTEGER_NEGATIVE + TrackerInputType.INTEGER_ZERO_OR_POSITIVE -> ValueType.INTEGER_ZERO_OR_POSITIVE + TrackerInputType.PERCENTAGE -> ValueType.PERCENTAGE + TrackerInputType.UNIT_INTERVAL -> ValueType.UNIT_INTERVAL + TrackerInputType.AGE -> ValueType.AGE + TrackerInputType.ORGANISATION_UNIT -> ValueType.ORGANISATION_UNIT + TrackerInputType.DATE_TIME -> ValueType.DATETIME + TrackerInputType.DATE -> ValueType.DATE + TrackerInputType.TIME -> ValueType.TIME + TrackerInputType.HORIZONTAL_CHECKBOXES, + TrackerInputType.VERTICAL_CHECKBOXES, + TrackerInputType.HORIZONTAL_RADIOBUTTONS, + TrackerInputType.VERTICAL_RADIOBUTTONS, + -> ValueType.BOOLEAN + + TrackerInputType.YES_ONLY_SWITCH, + TrackerInputType.YES_ONLY_CHECKBOX, + -> ValueType.TRUE_ONLY + + TrackerInputType.QR_CODE, + TrackerInputType.BAR_CODE, + -> ValueType.TEXT + + TrackerInputType.MULTI_SELECTION -> ValueType.MULTI_TEXT + TrackerInputType.DROPDOWN, + TrackerInputType.PERIOD_SELECTOR, + TrackerInputType.MATRIX, + TrackerInputType.SEQUENTIAL, + TrackerInputType.NOT_SUPPORTED, + TrackerInputType.CUSTOM_INTENT, + -> ValueType.TEXT + TrackerInputType.COORDINATES -> ValueType.COORDINATE + TrackerInputType.IMAGE -> ValueType.IMAGE + } + + override fun mapTrackedEntitySearchItemResultToSearchTeiModel( + searchItemResult: TrackedEntitySearchItemResult, + sortingItem: SortingItem?, + ): SearchTeiModel { + val searchTeiModel = SearchTeiModel() + searchTeiModel.tei = searchItemResult + searchItemResult.enrolledPrograms?.forEach { + searchTeiModel.addProgramInfo( + it.uid, + trackedEntityInstanceInfoProvider.getMetadataIcon( + ObjectStyle.builder().icon(it.style.icon).color(it.style.color).build() + ) + ) + } + searchItemResult.attributeValues.forEach { attr -> + if (attr.displayInList && isAcceptedValueType(attr.valueType)) { + val transformedValue = if (attr.value != null) + trackedEntityInstanceInfoProvider.getTransformedValue(attr) else trackedEntityInstanceInfoProvider.getUnknownLabel() + searchTeiModel.addAttributeValue(attr.displayName, attr.copy(value = transformedValue)) + if(attr.valueType == TrackerInputType.TEXT || attr.valueType == TrackerInputType.LONG_TEXT){ + searchTeiModel.addTextAttribute(attr.displayFormName, attr.copy(value = transformedValue)) + } + } + } + sortingItem?.let { + searchTeiModel.setSortingValue(trackedEntityInstanceInfoProvider.getSortingKeyValue(searchTeiModel, sortingItem)) + } + return searchTeiModel + + } + override fun searchRelationshipsForMap( teis: List, selectedProgram: Program?, @@ -276,7 +286,7 @@ class SearchRepositoryImplKt( when { relationshipTarget?.trackedEntityInstance() != null && - teis.none { it.uid == relationshipTarget.elementUid() } -> { + teis.none { it.uid == relationshipTarget.elementUid() } -> { val trackedEntityType = d2 .trackedEntityModule() @@ -313,6 +323,34 @@ class SearchRepositoryImplKt( } } + override fun validateValue( + inputType: TrackerInputType, + value: String, + ): Any = + { + when (inputType) { + TrackerInputType.DATE -> { + ValueType.DATE.validator.validate(value) + } + + TrackerInputType.DATE_TIME -> { + ValueType.DATETIME.validator.validate(value) + } + + TrackerInputType.TIME -> { + ValueType.TIME.validator.validate(value) + } + + TrackerInputType.AGE -> { + ValueType.AGE.validator.validate(value) + } + + else -> { + false + } + } + } + override fun searchEventForMap( teiUids: List, selectedProgram: Program?, @@ -345,176 +383,19 @@ class SearchRepositoryImplKt( } } - private fun programTrackedEntityAttributes(programUid: String): List { - val searchableAttributes = - d2 - .programModule() - .programTrackedEntityAttributes() - .withRenderType() - .byProgram() - .eq(programUid) - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .blockingGet() - .filter { programAttribute -> - val isSearchable = programAttribute.searchable()!! - val isUnique = - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(programAttribute.trackedEntityAttribute()!!.uid()) - .blockingGet() - ?.unique() === java.lang.Boolean.TRUE - isSearchable || isUnique - } - - val program = - d2 - .programModule() - .programs() - .uid(programUid) - .blockingGet() - - return searchableAttributes - .mapNotNull { programAttribute -> - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(programAttribute.trackedEntityAttribute()!!.uid()) - .blockingGet() - ?.let { attribute -> - val searchFlow = MutableStateFlow("") - val optionSetConfiguration = - attribute.optionSet()?.let { - OptionSetConfiguration( - searchEmitter = searchFlow, - optionFlow = - searchFlow.debounce(300).flatMapLatest { - d2 - .optionModule() - .options() - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .byOptionSetUid() - .eq(attribute.optionSet()!!.uid()) - .getPagingData(10) - .map { pagingData -> - pagingData.map { option -> - OptionSetConfiguration.OptionData( - option, - metadataIconProvider( - option.style(), - program?.style()?.color()?.toColor() - ?: SurfaceColor.Primary, - ), - ) - } - } - }, - onSearch = { searchFlow.value = it }, - ) - } - val customIntentModel = - customIntentRepository.getCustomIntent( - triggerUid = attribute.uid(), - orgUnitUid = null, - actionType = CustomIntentActionTypeModel.SEARCH, - ) - createField( - trackedEntityAttribute = attribute, - programTrackedEntityAttribute = programAttribute, - optionSetConfiguration = optionSetConfiguration, - customIntent = customIntentModel, - ) - } - }.filter { parameter -> - parameter.valueType !== ValueType.IMAGE && - parameter.valueType !== ValueType.COORDINATE && - parameter.valueType !== ValueType.FILE_RESOURCE - } - } - - private fun trackedEntitySearchFields(teiTypeUid: String): List { - val teTypeAttributes = - d2 - .trackedEntityModule() - .trackedEntityTypeAttributes() - .byTrackedEntityTypeUid() - .eq(teiTypeUid) - .bySearchable() - .isTrue - .blockingGet() - - return teTypeAttributes - .mapNotNull { typeAttribute -> - d2 - .trackedEntityModule() - .trackedEntityAttributes() - .uid(typeAttribute.trackedEntityAttribute()!!.uid()) - .blockingGet() - ?.let { attribute -> - val searchEmitter = MutableStateFlow("") - val optionSetConfiguration = - attribute.optionSet()?.let { - OptionSetConfiguration( - searchEmitter = searchEmitter, - optionFlow = - d2 - .optionModule() - .options() - .byOptionSetUid() - .eq(attribute.optionSet()!!.uid()) - .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) - .getPagingData(10) - .map { pagingData -> - pagingData.map { option -> - OptionSetConfiguration.OptionData( - option, - metadataIconProvider( - option.style(), - SurfaceColor.Primary, - ), - ) - } - }, - onSearch = { searchEmitter.value = it }, - ) - } + override suspend fun getCustomIntent(fieldUid: FieldUid) = + withContext(dispatcher.io()) { + customIntentRepository.getCustomIntent( + triggerUid = fieldUid, + orgUnitUid = null, + actionType = CustomIntentActionTypeModel.SEARCH, + ) + } - createField( - trackedEntityAttribute = attribute, - programTrackedEntityAttribute = null, - optionSetConfiguration = optionSetConfiguration, - ) - } - }.filter { parameter -> - parameter.valueType !== ValueType.IMAGE && - parameter.valueType !== ValueType.COORDINATE && - parameter.valueType !== ValueType.FILE_RESOURCE - } + private fun isAcceptedValueType(valueType: TrackerInputType): Boolean { + return when (valueType) { + TrackerInputType.NOT_SUPPORTED -> false + else -> true + } } - - private fun createField( - trackedEntityAttribute: TrackedEntityAttribute, - programTrackedEntityAttribute: ProgramTrackedEntityAttribute?, - optionSetConfiguration: OptionSetConfiguration?, - customIntent: CustomIntentModel? = null, - ): FieldUiModel = - fieldViewModelFactory.create( - id = trackedEntityAttribute.uid(), - label = trackedEntityAttribute.displayFormName() ?: "", - valueType = trackedEntityAttribute.valueType()!!, - mandatory = false, - optionSet = trackedEntityAttribute.optionSet()?.uid(), - value = null, - programStageSection = null, - allowFutureDates = programTrackedEntityAttribute?.allowFutureDate() ?: true, - editable = true, - renderingType = SectionRenderingType.LISTING, - description = null, - fieldRendering = programTrackedEntityAttribute?.renderType()?.mobile(), - objectStyle = trackedEntityAttribute.style() ?: ObjectStyle.builder().build(), - fieldMask = trackedEntityAttribute.fieldMask(), - optionSetConfiguration = optionSetConfiguration, - featureType = null, - customIntentModel = customIntent, - ) } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt index 1b57f339c79..43d715b49d9 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryKt.kt @@ -1,34 +1,26 @@ package org.dhis2.usescases.searchTrackEntity -import androidx.paging.PagingData -import kotlinx.coroutines.flow.Flow +import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.data.search.SearchParametersModel -import org.dhis2.form.model.FieldUiModel import org.dhis2.maps.model.MapItemModel +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.FieldUid +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem interface SearchRepositoryKt { - fun searchTrackedEntities( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): Flow> - - suspend fun searchParameters( - programUid: String?, - teiTypeUid: String, - ): List - - suspend fun searchTrackedEntitiesImmediate( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): List - fun searchTeiForMap( searchParametersModel: SearchParametersModel, isOnline: Boolean, ): List + fun validateValue( + inputType: TrackerInputType, + value: String, + ): Any + fun searchEventForMap( teiUids: List, selectedProgram: Program?, @@ -38,4 +30,20 @@ interface SearchRepositoryKt { teis: List, selectedProgram: Program?, ): List + + suspend fun getCustomIntent(fieldUid: FieldUid): CustomIntentModel? + + fun saveSearchValuesAndGetAllowCache( + queryData: MutableMap?>?, + programUid: String?, + ): Boolean + + fun getExcludeValues(): HashSet? + + fun trackerValueTypeToSDKValueType(trackerInputType: TrackerInputType): ValueType? + + fun mapTrackedEntitySearchItemResultToSearchTeiModel( + searchItemResult: TrackedEntitySearchItemResult, + sortingItem: SortingItem?, + ): SearchTeiModel } 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 9c93bc87b68..e135873c575 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -55,7 +55,6 @@ import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.sync.SyncContext.TrackerProgramTei import org.dhis2.data.forms.dataentry.ProgramAdapter import org.dhis2.databinding.ActivitySearchBinding -import org.dhis2.form.ui.intent.FormIntent.OnSave import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope import org.dhis2.tracker.NavigationBarUIState import org.dhis2.ui.ThemeManager @@ -67,7 +66,7 @@ import org.dhis2.usescases.searchTrackEntity.LegacyInteraction.OnSyncIconClick import org.dhis2.usescases.searchTrackEntity.LegacyInteraction.OnTeiClick import org.dhis2.usescases.searchTrackEntity.listView.SearchTEList.Companion.get import org.dhis2.usescases.searchTrackEntity.mapView.SearchTEMap.Companion.get -import org.dhis2.usescases.searchTrackEntity.searchparameters.initSearchScreen +import org.dhis2.usescases.searchTrackEntity.searchparameters.provideSearchScreen import org.dhis2.usescases.searchTrackEntity.ui.SearchScreenConfigurator import org.dhis2.utils.customviews.BreakTheGlassBottomDialog import org.dhis2.utils.customviews.navigationbar.NavigationPage @@ -76,7 +75,6 @@ import org.dhis2.utils.granularsync.shouldLaunchSyncDialog import org.dhis2.utils.isLandscape import org.dhis2.utils.isPortrait import org.hisp.dhis.android.core.arch.call.D2Progress -import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme @@ -245,6 +243,7 @@ class SearchTEActivity : initialProgram, context, initialQuery, + syncStatusController, ), ) searchComponent?.inject(this) @@ -322,7 +321,7 @@ class SearchTEActivity : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString(Constants.QUERY_DATA, JSONObject(viewModel.queryData).toString()) + outState.putString(Constants.QUERY_DATA, JSONObject(viewModel.queryDataAsMap()).toString()) outState.putString(CURRENT_SCREEN, currentContent?.name) } @@ -357,7 +356,7 @@ class SearchTEActivity : } private fun initSearchParameters() { - initSearchScreen( + provideSearchScreen( binding.searchContainer, viewModel, initialProgram, @@ -373,14 +372,9 @@ class SearchTEActivity : if (selectedOrgUnits.isNotEmpty()) { selectedOrgUnit = selectedOrgUnits[0].uid() } - viewModel.onParameterIntent( - OnSave( - uid, - selectedOrgUnit, - ValueType.ORGANISATION_UNIT, - null, - true, - ), + viewModel.onValueChange( + fieldUid = uid, + value = selectedOrgUnit, ) }.orgUnitScope(orgUnitScope) .build() 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 7aefc1d7cc0..f79503c24b6 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -19,11 +19,16 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow @@ -45,10 +50,9 @@ import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FieldUiModelImpl -import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.customintent.CustomIntentResult import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.extensions.toStringProperty import org.dhis2.maps.layer.MapLayer @@ -56,16 +60,28 @@ 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.model.CustomIntentModel import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase import org.dhis2.simprints.SimprintsLoadPossibleDuplicatesSearchResultsUseCase import org.dhis2.simprints.SimprintsSearchViewModel import org.dhis2.tracker.NavigationBarUIState +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.CustomIntentUid +import org.dhis2.tracker.input.ui.action.FieldUid +import org.dhis2.tracker.input.ui.action.TrackerInputAction +import org.dhis2.tracker.input.ui.mapper.toTrackerInputUiState +import org.dhis2.tracker.input.ui.state.TrackerOptionItem +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.FetchSearchParametersData +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput +import org.dhis2.tracker.search.ui.state.SearchParametersUiState import org.dhis2.usescases.searchTrackEntity.listView.SearchResult -import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState import org.dhis2.usescases.searchTrackEntity.ui.UnableToSearchOutsideData import org.dhis2.utils.customviews.navigationbar.NavigationPage import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator -import org.hisp.dhis.android.core.arch.helpers.Result import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.maintenance.D2ErrorCode import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem @@ -94,6 +110,7 @@ sealed class SimprintsNavigationAction { object AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates : SimprintsNavigationAction() } +@OptIn(ExperimentalCoroutinesApi::class) class SearchTEIViewModel( val initialProgramUid: String?, initialQuery: MutableMap?>?, @@ -107,11 +124,22 @@ class SearchTEIViewModel( private val resourceManager: ResourceManager, private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, + private val searchTrackedEntities: SearchTrackedEntities, + private val fetchSearchParameters: FetchSearchParameters, + private val fetchOptionSetOptions: FetchOptionSetOptions, private val simprintsSearchViewModel: SimprintsSearchViewModel, private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase, ) : ViewModel() { private var layersVisibility: Map = emptyMap() + private val customIntentByFieldUid = mutableMapOf() + + // Store option set flows per field UID + private val optionSetFlows = mutableMapOf>>() + + // Store search query states for option sets + private val optionSetSearchQueries = mutableMapOf>() + private val pageConfiguration = MutableLiveData() private val _navigationBarUIState = @@ -121,26 +149,18 @@ class SearchTEIViewModel( val navigationBarUIState: MutableState> = _navigationBarUIState - val queryData = - mutableMapOf?>().apply { - initialQuery?.let { putAll(it) } - } - private val _legacyInteraction = MutableLiveData() val legacyInteraction: LiveData = _legacyInteraction private val _refreshData = MutableLiveData(Unit) val refreshData: LiveData = _refreshData - private val _mapResults = Channel() - val mapResults: Flow = _mapResults.receiveAsFlow() + private val _mapResults = MutableSharedFlow(replay = 1) + val mapResults: SharedFlow = _mapResults.asSharedFlow() private val _mapItemClicked = MutableSharedFlow() val mapItemClicked: Flow = _mapItemClicked - private val _simprintsNavigation = Channel() - val simprintsNavigation: Flow = _simprintsNavigation.receiveAsFlow() - private val _screenState = MutableLiveData() val screenState: LiveData = _screenState @@ -175,17 +195,31 @@ class SearchTEIViewModel( var searchParametersUiState by mutableStateOf(SearchParametersUiState()) + val queryDataList = + mutableListOf().apply { + initialQuery?.let { addAll(it.toQueryDataList()) } + } + var mapManager: MapManager? = null private var fetchJob: Job? = null private val onNewSearch = MutableSharedFlow(extraBufferCapacity = 1) + private val _searchActions = Channel() + val searchActions = _searchActions.receiveAsFlow() + + private val _simprintsNavigation = Channel() + val simprintsNavigation: Flow = _simprintsNavigation.receiveAsFlow() + private var simprintsPossibleDuplicatesAutoNoneOfAboveTriggered: Boolean = false private var keepSearchScreenOpenForSimprintsBiometricNoMatches: Boolean = false private val loadSimprintsPossibleDuplicatesSearchResultsUseCase = - SimprintsLoadPossibleDuplicatesSearchResultsUseCase(searchRepository, searchRepositoryKt) + SimprintsLoadPossibleDuplicatesSearchResultsUseCase( + searchRepositoryKt = searchRepositoryKt, + searchTrackedEntities = searchTrackedEntities, + ) val searchPagingData = onNewSearch @@ -196,7 +230,7 @@ class SearchTEIViewModel( emitAll( when { searching -> loadSearchResults() - displayFrontPageList() -> loadDisplayInListResults() + shouldDisplayFrontPageList() -> loadDisplayInListResults() else -> emptyFlow() }, ) @@ -228,6 +262,52 @@ class SearchTEIViewModel( _isSimprintsPossibleDuplicatesSearch.value = isSimprintsPossibleDuplicatesSearch } + /** + * Get or create option set flow for a given field. + */ + fun getOptionSetFlow( + fieldUid: String, + optionSetUid: String, + ): Flow> = + optionSetFlows.getOrPut(fieldUid) { + flow { + val searchQuery = + optionSetSearchQueries.getOrPut(fieldUid) { + MutableStateFlow(null) + } + + searchQuery.collect { query -> + val result = + fetchOptionSetOptions( + FetchOptionSetOptions.Params( + optionSetUid = optionSetUid, + pageSize = 10, + searchQuery = query, + ), + ) + + result.fold( + onSuccess = { optionsFlow -> + emitAll(optionsFlow) + }, + onFailure = { + emit(PagingData.empty()) + }, + ) + } + }.cachedIn(viewModelScope) + } + + /** + * Handle search in option sets. + */ + fun onOptionSetSearch( + fieldUid: String, + query: String, + ) { + optionSetSearchQueries[fieldUid]?.value = query.takeIf { it.isNotBlank() } + } + private fun loadNavigationBarItems() { CoroutineTracker.increment() val pageConfigurator = searchNavPageConfigurator.initVariables() @@ -292,11 +372,10 @@ class SearchTEIViewModel( searchRepository.getProgram(initialProgramUid)?.displayFrontPageList() ?: true val shouldForceSearch = !displayFrontPageList && - !searchRepository.canCreateInProgramWithoutSearch() && - !searching && - filtersActive.value == false - val shouldOpenSearch = - shouldForceSearch || keepSearchScreenOpenForSimprintsBiometricNoMatches + !searchRepository.canCreateInProgramWithoutSearch() && + !searching && + filtersActive.value == false + val shouldOpenSearch = shouldForceSearch || keepSearchScreenOpenForSimprintsBiometricNoMatches createButtonScrollVisibility.postValue( if (searching) { @@ -317,7 +396,7 @@ class SearchTEIViewModel( isSearching = searching, searchForm = SearchForm( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), minAttributesToSearch = searchRepository .getProgram(initialProgramUid) @@ -354,7 +433,7 @@ class SearchTEIViewModel( isSearching = searching, searchForm = SearchForm( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), minAttributesToSearch = searchRepository .getProgram(initialProgramUid) @@ -394,7 +473,7 @@ class SearchTEIViewModel( isSearching = searching, searchForm = SearchForm( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), minAttributesToSearch = searchRepository .getProgram(initialProgramUid) @@ -437,9 +516,24 @@ class SearchTEIViewModel( values: List?, ) { if (values.isNullOrEmpty()) { - queryData.remove(uid) + queryDataList.removeIf { it.attributeId == uid } } else { - queryData[uid] = values + if (queryDataList.none { it.attributeId == uid }) { + queryDataList.add( + QueryData( + attributeId = uid, + values = values, + searchOperator = searchParametersUiState.items.firstOrNull { it.uid == uid }?.searchOperator, + ), + ) + } else { + queryDataList + .indexOfFirst { it.attributeId == uid } + .takeIf { it != -1 } + ?.let { index -> + queryDataList[index] = queryDataList[index].copy(values = values) + } + } } updateSearchParameters(uid, values) @@ -449,31 +543,33 @@ class SearchTEIViewModel( private fun updateSearchParameters( uid: String, values: List?, + errorMessage: String? = null, ) { val updatedItems = searchParametersUiState.items.map { if (it.uid == uid) { - (it as FieldUiModelImpl).copy( + it.copy( value = values?.joinToString(","), displayName = displayNameProvider.provideDisplayName( - valueType = it.valueType, + valueType = searchRepositoryKt.trackerValueTypeToSDKValueType(it.valueType), value = values?.joinToString(","), optionSet = it.optionSet, - periodType = it.periodSelector?.type, + periodType = null, ), + error = errorMessage, ) } else { it } - } + } searchParametersUiState = searchParametersUiState.copy(items = updatedItems) refreshSimprintsUiState() } fun clearQueryData() { clearSimprintsBiometricNoMatchesState() - queryData.clear() + queryDataList.clear() clearSearchParameters() simprintsSearchViewModel.clearPendingSession() updateSearch() @@ -481,22 +577,32 @@ class SearchTEIViewModel( } fun clearSimprintsBiometricQueryData() { - val updatedItems = + val queryData = queryDataAsMap() + val updatedSearchItems = simprintsSearchViewModel.clearSimprintsBiometricQueryData( - searchItems = searchParametersUiState.items, + searchItems = simprintsSearchItems(), queryData = queryData, ) ?: return + queryDataList.removeIf { it.attributeId !in queryData.keys } + val updatedSearchItemsByUid = updatedSearchItems.associateBy(FieldUiModel::uid) searchParametersUiState = searchParametersUiState.copy( - items = updatedItems, - clearSearchEnabled = queryData.isNotEmpty(), - ) - searchParametersUiState = - searchParametersUiState.copy( + items = + searchParametersUiState.items.map { item -> + updatedSearchItemsByUid[item.uid] + ?.let { updatedItem -> + item.copy( + value = updatedItem.value, + displayName = updatedItem.displayName, + ) + } ?: item + }, + clearSearchEnabled = queryDataList.isNotEmpty(), searchedItems = getFriendlyQueryData(), ) updateSearch() + refreshSimprintsUiState() } fun onSimprintsPossibleDuplicatesNoneOfAboveClick() { @@ -517,7 +623,7 @@ class SearchTEIViewModel( private fun clearSearchParameters() { val updatedItems = searchParametersUiState.items.map { - (it as FieldUiModelImpl).copy(value = null, displayName = null) + it.copy(value = null, displayName = null) } searchParametersUiState = searchParametersUiState.copy( @@ -536,76 +642,78 @@ class SearchTEIViewModel( currentSearchList.copy( searchForm = currentSearchList.searchForm.copy( - queryHasData = queryData.isNotEmpty(), + queryHasData = queryDataList.isNotEmpty(), ), ), ) } - searchParametersUiState = searchParametersUiState.copy(searchEnabled = queryData.isNotEmpty()) + searchParametersUiState = + searchParametersUiState.copy(searchEnabled = queryDataList.isNotEmpty()) } - private suspend fun loadSearchResults() = - withContext(dispatchers.io()) { - val searchParametersModel = - SearchParametersModel( - selectedProgram = searchRepository.getProgram(initialProgramUid), - queryData = queryData, - ) - val isSimprintsPossibleDuplicatesSearch = _isSimprintsPossibleDuplicatesSearch.value == true + private fun loadSearchResults(): Flow> = + flow { + // get uids to exclude for possible duplicates + val excludeValues = searchRepositoryKt.getExcludeValues() + val isOnline = searching && networkUtils.isOnline() + val selectedProgram = searchRepository.getProgram(initialProgramUid) - if (isSimprintsPossibleDuplicatesSearch) { + val allowCache = + searchRepositoryKt.saveSearchValuesAndGetAllowCache( + queryDataAsMap(), + selectedProgram?.uid(), + ) + val trackerSearchModel = + SearchTrackedEntitiesInput( + selectedProgram = selectedProgram?.uid(), + allowCache = allowCache, + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = isOnline, + queryDataList = queryDataList, + ) + if (_isSimprintsPossibleDuplicatesSearch.value == true) { val simprintsPossibleDuplicatesResults = loadSimprintsPossibleDuplicatesSearchResultsUseCase( - searchParametersModel = searchParametersModel, - isOnline = isOnline, - offlineOnly = !(isOnline && filterManager.stateFilters.isEmpty()), + queryDataList = queryDataList, + searchInput = trackerSearchModel, sortingItem = filterManager.sortingItem, ) ?: emptyList() - if (simprintsPossibleDuplicatesResults.isEmpty() && !simprintsPossibleDuplicatesAutoNoneOfAboveTriggered) { + if ( + simprintsPossibleDuplicatesResults.isEmpty() && + !simprintsPossibleDuplicatesAutoNoneOfAboveTriggered + ) { simprintsPossibleDuplicatesAutoNoneOfAboveTriggered = true onSimprintsPossibleDuplicatesAutoEnrollLastClick() } - return@withContext flow { emit(PagingData.from(simprintsPossibleDuplicatesResults)) } - } else { - loadSimprintsBiometricSearchResults( - searchParametersModel = searchParametersModel, - isOnline = isOnline, - )?.let { return@withContext it } + emit(PagingData.from(simprintsPossibleDuplicatesResults)) + return@flow } - val getPagingData = - searchRepositoryKt.searchTrackedEntities( - searchParametersModel, - isOnline, - ) - return@withContext getPagingData.map { pagingData -> - pagingData.map { item -> - withContext(dispatchers.io()) { - if ( - searching && - networkUtils.isOnline() && - filterManager.stateFilters.isEmpty() - ) { - searchRepository.transform( - item, - searchParametersModel.selectedProgram, - false, - filterManager.sortingItem, - ) - } else { - searchRepository.transform( + loadSimprintsBiometricSearchResults( + searchInput = trackerSearchModel, + )?.let { simprintsResults -> + emitAll(simprintsResults) + return@flow + } + + val results = searchTrackedEntities.invoke(trackerSearchModel) + + emitAll( + results.getOrThrow().map { pagingData -> + pagingData.map { item -> + withContext(dispatchers.io()) { + searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel( item, - searchParametersModel.selectedProgram, - true, filterManager.sortingItem, ) } } - } - } + }, + ) } private fun onSimprintsPossibleDuplicatesAutoEnrollLastClick() { @@ -614,94 +722,98 @@ class SearchTEIViewModel( } } - private suspend fun loadDisplayInListResults() = - withContext(dispatchers.io()) { - val searchParametersModel = - SearchParametersModel( - selectedProgram = searchRepository.getProgram(initialProgramUid), - queryData = queryData, + private fun loadDisplayInListResults(): Flow> = + flow { + val excludeValues = searchRepositoryKt.getExcludeValues() + val selectedProgram = searchRepository.getProgram(initialProgramUid) + + val allowCache = + searchRepositoryKt.saveSearchValuesAndGetAllowCache( + queryDataAsMap(), + selectedProgram?.uid(), ) - val getPagingData = - searchRepositoryKt.searchTrackedEntities( - searchParametersModel, - false, + val newTrackerSearchModel = + SearchTrackedEntitiesInput( + selectedProgram = selectedProgram?.uid(), + allowCache = allowCache, + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = false, + queryDataList = queryDataList, ) + val results = searchTrackedEntities.invoke(newTrackerSearchModel) - return@withContext getPagingData.map { pagingData -> - pagingData.map { item -> - withContext(dispatchers.io()) { - searchRepository.transform( - item, - searchParametersModel.selectedProgram, - true, - filterManager.sortingItem, - ) + emitAll( + results.getOrThrow().map { pagingData -> + pagingData.map { item -> + withContext(dispatchers.io()) { + searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel( + item, + filterManager.sortingItem, + ) + } } - } - } + }, + ) } - suspend fun fetchGlobalResults() = - withContext(dispatchers.io()) { - val searchParametersModel = - SearchParametersModel( + fun fetchGlobalResults(): Flow>? { + // get uids to exclude for possible duplicates + return if (searching) { + flow { + val excludeValues = searchRepositoryKt.getExcludeValues() + + val isOnline = searching && networkUtils.isOnline() + val selectedProgram = searchRepository.getProgram(initialProgramUid) + + val allowCache = + searchRepositoryKt.saveSearchValuesAndGetAllowCache( + queryDataAsMap(), + selectedProgram?.uid(), + ) + val newTrackerSearchModel = + SearchTrackedEntitiesInput( selectedProgram = null, - queryData = queryData, - ) - val getPagingData = - searchRepositoryKt.searchTrackedEntities( - searchParametersModel, - searching && networkUtils.isOnline(), + allowCache = allowCache, + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = isOnline, + queryDataList = queryDataList, ) - - return@withContext if (searching) { loadSimprintsBiometricSearchResults( - searchParametersModel = searchParametersModel, - isOnline = networkUtils.isOnline(), - )?.let { return@withContext it } - getPagingData.map { pagingData -> - pagingData.map { item -> - withContext(dispatchers.io()) { - if ( - searching && - networkUtils.isOnline() && - filterManager.stateFilters.isEmpty() - ) { - searchRepository.transform( - item, - searchParametersModel.selectedProgram, - false, - filterManager.sortingItem, - ) - } else { - searchRepository.transform( + searchInput = newTrackerSearchModel, + )?.let { simprintsResults -> + emitAll(simprintsResults) + return@flow + } + val results = searchTrackedEntities.invoke(newTrackerSearchModel) + + emitAll( + results.getOrThrow().map { pagingData -> + pagingData.map { item -> + withContext(dispatchers.io()) { + searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel( item, - searchParametersModel.selectedProgram, - true, filterManager.sortingItem, ) } } - } - } - } else { - null + }, + ) } + } else { + null } + } private suspend fun loadSimprintsBiometricSearchResults( - searchParametersModel: SearchParametersModel, - isOnline: Boolean, - ): Flow>? { - val offlineOnly = !(isOnline && filterManager.stateFilters.isEmpty()) - return loadSimprintsBiometricSearchResultsUseCase( - searchItems = searchParametersUiState.items, - searchParametersModel = searchParametersModel, - isOnline = isOnline, - offlineOnly = offlineOnly, + searchInput: SearchTrackedEntitiesInput, + ): Flow>? = + loadSimprintsBiometricSearchResultsUseCase( + searchItems = simprintsSearchItems(), + searchInput = searchInput, sortingItem = filterManager.sortingItem, ) - } fun fetchMapResults() { CoroutineTracker.increment() @@ -710,10 +822,12 @@ class SearchTEIViewModel( val data = mapDataRepository.getTrackerMapData( searchRepository.getProgram(initialProgramUid), - queryData, + queryDataAsMap(), layersVisibility, ) - _mapResults.send(data) + _mapResults.emit(data) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.e(e) } finally { @@ -725,18 +839,22 @@ class SearchTEIViewModel( fun onSearch() { clearSimprintsBiometricNoMatchesState() - searchRepository.clearFetchedList() - performSearch() + if (canPerformSearch()) { + searchRepository.clearFetchedList() + performSearch() + } else { + displayNotEnoughAttributesToSearchMessage() + } } private fun performSearch() { viewModelScope.launch(dispatchers.io()) { + CoroutineTracker.increment() try { - if (canPerformSearch()) { - searching = queryData.isNotEmpty() + searching = queryDataList.isNotEmpty() searchParametersUiState = searchParametersUiState.copy( - clearSearchEnabled = queryData.isNotEmpty(), + clearSearchEnabled = queryDataList.isNotEmpty(), searchedItems = getFriendlyQueryData(), ) @@ -744,7 +862,7 @@ class SearchTEIViewModel( SearchScreenState.LIST, SearchScreenState.NONE, null, - -> { + -> { setListScreen() onNewSearch.emit(Unit) } @@ -757,7 +875,19 @@ class SearchTEIViewModel( else -> searching = false } - } else { + + } catch (e: Exception) { + Timber.d(e) + } finally { + CoroutineTracker.decrement() + } + } + } + + private fun displayNotEnoughAttributesToSearchMessage() { + viewModelScope.launch(dispatchers.io()) { + CoroutineTracker.increment() + try { val minAttributesToSearch = searchRepository .getProgram(initialProgramUid) @@ -768,31 +898,36 @@ class SearchTEIViewModel( R.string.search_min_num_attr, minAttributesToSearch, ) - searchParametersUiState = searchParametersUiState.copy(minAttributesMessage = message) + searchParametersUiState = + searchParametersUiState.copy(minAttributesMessage = message) searchParametersUiState.updateMinAttributeWarning(true) setSearchScreen() _refreshData.postValue(Unit) onNewSearch.emit(Unit) - } } catch (e: Exception) { Timber.d(e) + } finally { + CoroutineTracker.decrement() } } } + private fun shouldDisplayFrontPageList(): Boolean = + displayFrontPageListSettingIsConfigured() + private fun canPerformSearch(): Boolean = - (_isSimprintsPossibleDuplicatesSearch.value == true && queryData.isNotEmpty()) || + (_isSimprintsPossibleDuplicatesSearch.value == true && queryDataList.isNotEmpty()) || minAttributesToSearchCheck() || - displayFrontPageList() + shouldDisplayFrontPageList() private fun minAttributesToSearchCheck(): Boolean = searchRepository.getProgram(initialProgramUid)?.let { program -> - (program.minAttributesRequiredToSearch() ?: 0) <= queryData.size + (program.minAttributesRequiredToSearch() ?: 0) <= queryDataList.size } ?: true - private fun displayFrontPageList(): Boolean = + private fun displayFrontPageListSettingIsConfigured(): Boolean = searchRepository.getProgram(initialProgramUid)?.let { program -> - program.displayFrontPageList() == true && queryData.isEmpty() + program.displayFrontPageList() == true && queryDataList.isEmpty() } ?: false private fun canDisplayResult( @@ -800,24 +935,24 @@ class SearchTEIViewModel( onlineTooManyResults: Boolean, ): Boolean = !onlineTooManyResults && - when (initialProgramUid) { - null -> itemCount <= TEI_TYPE_SEARCH_MAX_RESULTS - else -> - searchRepository - .getProgram(initialProgramUid) - ?.maxTeiCountToReturn() - ?.takeIf { it != 0 } - ?.let { maxTeiCount -> - itemCount <= maxTeiCount - } ?: true - } + when (initialProgramUid) { + null -> itemCount <= TEI_TYPE_SEARCH_MAX_RESULTS + else -> + searchRepository + .getProgram(initialProgramUid) + ?.maxTeiCountToReturn() + ?.takeIf { it != 0 } + ?.let { maxTeiCount -> + itemCount <= maxTeiCount + } ?: true + } fun queryDataByProgram(programUid: String?): MutableMap> = - searchRepository.filterQueryForProgram(queryData, programUid) + searchRepository.filterQueryForProgram(queryDataAsMap(), programUid) fun prepareEnrollmentQueryData(queryData: Map?>): HashMap> = simprintsSearchViewModel.prepareEnrollmentQueryData( - searchItems = searchParametersUiState.items, + searchItems = simprintsSearchItems(), queryData = queryData, ) @@ -831,7 +966,7 @@ class SearchTEIViewModel( when ( val action = simprintsSearchViewModel.onDashboardRequested( - searchItems = searchParametersUiState.items, + searchItems = simprintsSearchItems(), teiUid = teiUid, programUid = programUid, enrollmentUid = enrollmentUid, @@ -901,6 +1036,13 @@ class SearchTEIViewModel( fun onSimprintsBiometricNoMatches() { keepSearchScreenOpenForSimprintsBiometricNoMatches = true setSearchScreen() + viewModelScope.launch { + _simprintsNavigation.send( + SimprintsNavigationAction.ShowMessage( + resourceManager.getString(R.string.simprints_biometric_search_no_matches), + ), + ) + } } fun clearSimprintsBiometricNoMatchesState() { @@ -908,11 +1050,11 @@ class SearchTEIViewModel( } fun refreshSimprintsUiState() { - simprintsSearchViewModel.refreshSimprintsUiState(searchParametersUiState.items) + simprintsSearchViewModel.refreshSimprintsUiState(simprintsSearchItems()) } fun onEnrollClick() { - _legacyInteraction.postValue(LegacyInteraction.OnEnrollClick(queryData)) + _legacyInteraction.postValue(LegacyInteraction.OnEnrollClick(queryDataAsMap())) } fun onAddRelationship( @@ -950,7 +1092,7 @@ class SearchTEIViewModel( LegacyInteraction.OnEnroll( initialProgramUid, downloadResult.teiUid, - queryData, + queryDataAsMap(), ), ) } else { @@ -996,7 +1138,7 @@ class SearchTEIViewModel( hasProgramResults, hasGlobalResults, ) - } else if (displayFrontPageList()) { + } else if (shouldDisplayFrontPageList()) { handleDisplayInListResult(hasProgramResults) } else { handleInitWithoutData() @@ -1036,9 +1178,10 @@ class SearchTEIViewModel( } hasGlobalResults == null && - searchRepository.getProgram(initialProgramUid) != null && - searchRepository.filterQueryForProgram(queryData, null).isNotEmpty() && - searchRepository.filtersApplyOnGlobalSearch() -> { + searchRepository.getProgram(initialProgramUid) != null && + searchRepository.filterQueryForProgram(queryDataAsMap(), null) + .isNotEmpty() && + searchRepository.filtersApplyOnGlobalSearch() -> { listOf( SearchResult( SearchResult.SearchResultType.SEARCH_OUTSIDE, @@ -1048,9 +1191,9 @@ class SearchTEIViewModel( } hasGlobalResults == null && - searchRepository.getProgram(initialProgramUid) != null && - searchRepository.trackedEntityTypeFields().isNotEmpty() && - searchRepository.filtersApplyOnGlobalSearch() -> { + searchRepository.getProgram(initialProgramUid) != null && + searchRepository.trackedEntityTypeFields().isNotEmpty() && + searchRepository.filtersApplyOnGlobalSearch() -> { listOf( SearchResult( type = SearchResult.SearchResultType.UNABLE_SEARCH_OUTSIDE, @@ -1218,191 +1361,147 @@ class SearchTEIViewModel( fetchJob?.cancel() fetchJob = viewModelScope.launch { - val fieldUiModels = - hydrateInitialQueryInFields( - searchRepositoryKt.searchParameters(programUid, teiTypeUid), - ) - searchParametersUiState = - searchParametersUiState.copy( - items = preserveExistingSearchParameterValues(fieldUiModels), - ) - refreshSimprintsUiState() + fetchSearchParameters + .invoke( + input = + FetchSearchParametersData( + teiTypeUid = teiTypeUid, + programUid = programUid, + ), + ).fold( + onSuccess = { searchParameters -> + val newItems = + searchParameters.map { searchParameter -> + searchParameter.toTrackerInputUiState() + } + customIntentByFieldUid.clear() + newItems + .filter { it.customIntentUid != null } + .forEach { item -> + searchRepositoryKt.getCustomIntent(item.uid)?.let { customIntent -> + customIntentByFieldUid[item.uid] = customIntent + } + } + searchParametersUiState = + searchParametersUiState.copy( + items = preserveExistingSearchParameterValues( + hydrateInitialQueryInFields(newItems), + ), + ) - if (queryData.isNotEmpty()) { - updateSearch() - searchParametersUiState = - searchParametersUiState.copy( - clearSearchEnabled = true, - searchedItems = getFriendlyQueryData(), - ) - if (_screenState.value?.screenState == null) { - setListScreen() - } - performSearch() - } + queryDataList.forEachIndexed { index, entry -> + val searchOperator = + newItems + .firstOrNull { it.uid == entry.attributeId } + ?.searchOperator + queryDataList[index] = entry.copy(searchOperator = searchOperator) + } + refreshSimprintsUiState() + if (queryDataList.isNotEmpty()) { + updateSearch() + searchParametersUiState = + searchParametersUiState.copy( + clearSearchEnabled = true, + searchedItems = getFriendlyQueryData(), + ) + if (_screenState.value?.screenState == null) { + setListScreen() + } + performSearch() + } + }, + onFailure = { + // TODO(Implement error) + }, + ) } } - private fun hydrateInitialQueryInFields(fieldUiModels: List): List = - fieldUiModels.map { fieldUiModel -> - val values = queryData[fieldUiModel.uid] - if (fieldUiModel !is FieldUiModelImpl || values.isNullOrEmpty()) { - fieldUiModel + private fun hydrateInitialQueryInFields(items: List) = + items.map { item -> + val values = queryDataList.firstOrNull { it.attributeId == item.uid }?.values + if (values.isNullOrEmpty()) { + item } else { val joinedValues = values.joinToString(",") - fieldUiModel.copy( + item.copy( value = joinedValues, displayName = displayNameProvider.provideDisplayName( - valueType = fieldUiModel.valueType, + valueType = searchRepositoryKt.trackerValueTypeToSDKValueType(item.valueType), value = joinedValues, - optionSet = fieldUiModel.optionSet, - periodType = fieldUiModel.periodSelector?.type, + optionSet = item.optionSet, + periodType = null, ), ) } } - private fun preserveExistingSearchParameterValues(fieldUiModels: List): List { - val currentItemsByUid = searchParametersUiState.items.associateBy(FieldUiModel::uid) - return fieldUiModels.map { fieldUiModel -> - val currentItem = currentItemsByUid[fieldUiModel.uid] - if (fieldUiModel is FieldUiModelImpl && currentItem is FieldUiModelImpl) { - fieldUiModel.copy( + private fun preserveExistingSearchParameterValues( + items: List, + ): List { + val currentItemsByUid = searchParametersUiState.items.associateBy { it.uid } + return items.map { item -> + val currentItem = currentItemsByUid[item.uid] + if (currentItem != null) { + item.copy( value = currentItem.value, displayName = currentItem.displayName, ) } else { - fieldUiModel + item } } } - fun onParameterIntent(formIntent: FormIntent) = - when (formIntent) { - is FormIntent.OnTextChange -> { - updateQuery( - formIntent.uid, - formIntent.value?.split(","), - ) - } - - is FormIntent.OnSave -> { - updateQuery( - formIntent.uid, - formIntent.value?.split(","), - ) - viewModelScope.launch { - simprintsSearchViewModel - .onSimprintsParameterSaved( - uid = formIntent.uid, - value = formIntent.value, - searchItems = searchParametersUiState.items, - initialProgramUid = initialProgramUid, - queryData = queryData, - )?.let { navigation -> - onNavigationPageChanged(NavigationPage.LIST_VIEW) - setListScreen() - clearQueryData() - onTeiClick( - teiUid = navigation.teiUid, - enrollmentUid = navigation.enrollmentUid, - online = navigation.isOnline, - ) - } - } - } - - is FormIntent.OnQrCodeScanned -> { - onQrCodeScanned(formIntent) - } - - is FormIntent.OnFocus -> { - val updatedItems = - searchParametersUiState.items.map { field -> - if (field.focused && field.uid != formIntent.uid) { - val validation = - field.value - ?.takeIf { - field.valueType in - listOf( - ValueType.DATE, - ValueType.DATETIME, - ValueType.AGE, - ValueType.TIME, - ) - }?.let { value -> field.valueType?.validator?.validate(value) } - - (field as FieldUiModelImpl).copy( - focused = false, - error = - when (validation) { - is Result.Failure -> resourceManager.getString(R.string.formatting_error) - else -> null - }, - ) - } else if (field.uid == formIntent.uid) { - (field as FieldUiModelImpl).copy(focused = true) - } else { - field - } - } - searchParametersUiState = searchParametersUiState.copy(items = updatedItems) - } - - is FormIntent.ClearValue -> { - updateQuery( - formIntent.uid, - null, - ) - } - - else -> { - // no-op - } - } - - private fun onQrCodeScanned(formIntent: FormIntent.OnQrCodeScanned) { + private fun onQrCodeScanned( + uid: String, + value: String?, + ) { viewModelScope.launch { updateQuery( - formIntent.uid, - formIntent.value?.let { listOf(it) }, + uid, + value?.let { listOf(it) }, ) - searching = queryData.isNotEmpty() + searching = queryDataList.isNotEmpty() searchParametersUiState = searchParametersUiState.copy( - clearSearchEnabled = queryData.isNotEmpty(), + clearSearchEnabled = queryDataList.isNotEmpty(), searchedItems = getFriendlyQueryData(), ) - val searchParametersModel = - SearchParametersModel( - selectedProgram = searchRepository.getProgram(initialProgramUid), - queryData = queryData, - ) val isOnline = searching && networkUtils.isOnline() - val trackedEntities = - async(dispatchers.io()) { - searchRepositoryKt.searchTrackedEntitiesImmediate( - searchParametersModel = searchParametersModel, - isOnline = isOnline, - ) - }.await() + val selectedProgram = searchRepository.getProgram(initialProgramUid) + + // get uids to exclude for possible duplicates + val excludeValues = searchRepositoryKt.getExcludeValues() + + val trackerSearchModel = + SearchTrackedEntitiesInput( + selectedProgram = selectedProgram?.uid(), + allowCache = false, // No need for cache in immediate search + excludeValues = excludeValues, + hasStateFilters = filterManager.stateFilters.isNotEmpty(), + isOnline = isOnline, + queryDataList = queryDataList, + ) + + // Use invokeImmediate for QR code scanning to get immediate non-paginated results + val trackedEntitiesResult = searchTrackedEntities.invokeImmediate(trackerSearchModel) + + val trackedEntities = trackedEntitiesResult.getOrNull() ?: emptyList() if (trackedEntities.isEmpty() || trackedEntities.size > 1) return@launch val tei = trackedEntities.first() + + // Transform domain model to SDK model for compatibility with existing code + val searchTeiModel = withContext(dispatchers.io()) { - searchRepository.transform( - // searchItem = + searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel( tei, - // selectedProgram = - searchParametersModel.selectedProgram, - // offlineOnly = - !(isOnline && filterManager.stateFilters.isEmpty()), - // sortingItem = filterManager.sortingItem, ) } @@ -1414,9 +1513,9 @@ class SearchTEIViewModel( // Open TEI dashboard for the found TEI onTeiClick( - teiUid = searchTeiModel.uid(), - enrollmentUid = searchTeiModel.selectedEnrollment.uid(), - online = searchTeiModel.isOnline, + teiUid = searchTeiModel.tei.uid, + enrollmentUid = searchTeiModel.selectedEnrollment.uid, + online = searchTeiModel.tei.isOnline, ) } } @@ -1425,7 +1524,7 @@ class SearchTEIViewModel( val updatedItems = searchParametersUiState.items.map { if (it.focused) { - (it as FieldUiModelImpl).copy(focused = false) + it.copy(focused = false) } else { it } @@ -1438,11 +1537,10 @@ class SearchTEIViewModel( searchParametersUiState.items .filter { !it.value.isNullOrEmpty() } .forEach { item -> - if (SimprintsIntentUtils.isIdentifyCallout(item.customIntent)) { - val isSimprintsPossibleDuplicatesSearch = _isSimprintsPossibleDuplicatesSearch.value == true + if (SimprintsIntentUtils.isIdentifyCallout(customIntentByFieldUid[item.uid])) { map[item.uid] = resourceManager.getString( - if (isSimprintsPossibleDuplicatesSearch) { + if (_isSimprintsPossibleDuplicatesSearch.value == true) { R.string.simprints_possible_duplicates } else { R.string.simprints_biometric_search @@ -1452,35 +1550,37 @@ class SearchTEIViewModel( } when (item.valueType) { - ValueType.ORGANISATION_UNIT, ValueType.MULTI_TEXT -> { + TrackerInputType.ORGANISATION_UNIT, TrackerInputType.MULTI_SELECTION -> { map[item.uid] = (item.displayName ?: "") } - ValueType.DATE, ValueType.AGE -> { + TrackerInputType.DATE, TrackerInputType.AGE -> { item.value?.let { map[item.uid] = it.toFriendlyDate() } } - ValueType.DATETIME -> { + TrackerInputType.DATE_TIME -> { item.value?.let { map[item.uid] = it.toFriendlyDateTime() } } - ValueType.BOOLEAN -> { - map[item.uid] = "${item.label}: ${item.value}" - } - - ValueType.TRUE_ONLY -> { + TrackerInputType.YES_ONLY_SWITCH, + TrackerInputType.YES_ONLY_CHECKBOX, + TrackerInputType.HORIZONTAL_RADIOBUTTONS, + TrackerInputType.VERTICAL_RADIOBUTTONS, + TrackerInputType.HORIZONTAL_CHECKBOXES, + TrackerInputType.VERTICAL_CHECKBOXES, + -> { item.value?.let { - if (it == "true") { - map[item.uid] = item.label + if (it == "true" || it == "false") { + map[item.uid] = "${item.label}: $it" } } } - ValueType.PERCENTAGE -> { + TrackerInputType.PERCENTAGE -> { item.value?.let { map[item.uid] = it.toPercentage() } @@ -1506,4 +1606,232 @@ class SearchTEIViewModel( this.layersVisibility = layersVisibility fetchMapResults() } + + fun launchCustomIntent( + fieldUid: FieldUid, + customIntentUid: CustomIntentUid, + ) { + viewModelScope.launch { + searchRepositoryKt.getCustomIntent(fieldUid)?.let { customIntentModel -> + _searchActions.send( + TrackerInputAction.LaunchCustomIntent( + fieldUid = fieldUid, + customIntentModel = customIntentModel, + ), + ) + } + } + } + + fun launchScan( + fieldUid: String, + optionSet: String?, + renderType: TrackerInputType, + ) { + val scanType = + if (renderType == TrackerInputType.QR_CODE) { + TrackerInputType.QR_CODE + } else { + TrackerInputType.BAR_CODE + } + + viewModelScope.launch { + _searchActions.send( + TrackerInputAction.Scan( + fieldUid = fieldUid, + optionSet = optionSet, + renderType = scanType, + ), + ) + } + } + + fun onValueChange( + fieldUid: String, + value: String?, + ) { + updateQuery( + fieldUid, + value?.split(","), + ) + } + + fun onItemClick(fieldUid: FieldUid) { + customIntentByFieldUid[fieldUid] + ?.takeIf(SimprintsIntentUtils::isIdentifyCallout) + ?.let { customIntentModel -> + viewModelScope.launch { + _searchActions.send( + TrackerInputAction.LaunchCustomIntent( + fieldUid = fieldUid, + customIntentModel = customIntentModel, + ), + ) + } + return + } + + searchParametersUiState + .copy( + items = + searchParametersUiState.items.map { + if (it.uid == fieldUid) { + it.copy(focused = true) + } else { + it.copy(focused = false) + } + }, + ).let { + searchParametersUiState = it + } + } + + fun handleCustomIntentResult(customIntentResult: CustomIntentResult) { + when (customIntentResult) { + is CustomIntentResult.Error -> { + updateSearchParameters( + customIntentResult.fieldUid, + null, + resourceManager.getString(R.string.custom_intent_error), + ) + } + + is CustomIntentResult.Success -> { + onSearchParameterSaved( + fieldUid = customIntentResult.fieldUid, + value = customIntentResult.value, + ) + } + + is CustomIntentResult.PossibleDuplicates -> { + updateSearchParameters( + customIntentResult.fieldUid, + null, + resourceManager.getString(R.string.custom_intent_error), + ) + } + } + } + + private fun onSearchParameterSaved( + fieldUid: String, + value: String?, + ) { + updateQuery( + fieldUid, + value?.split(","), + ) + viewModelScope.launch { + simprintsSearchViewModel + .onSimprintsParameterSaved( + uid = fieldUid, + value = value, + searchItems = simprintsSearchItems(), + initialProgramUid = initialProgramUid, + queryData = queryDataAsMap(), + )?.let { navigation -> + onNavigationPageChanged(NavigationPage.LIST_VIEW) + setListScreen() + clearQueryData() + onTeiClick( + teiUid = navigation.teiUid, + enrollmentUid = navigation.enrollmentUid, + online = navigation.isOnline, + ) + } + } + } + + fun handleScanResult( + fieldUid: String, + value: String?, + ) { + onQrCodeScanned( + uid = fieldUid, + value = value, + ) + value?.let { + updateSearchParameters( + uid = fieldUid, + values = listOf(value), + ) + } + } + + /** + * + * Converts the internal queryDataList to a map representation. + * + * Since the previous map representation of queryData (Map>) is used outside + * of the SearchTEIViewModel, this function helps to represent the new and refactor. + * + * QueryData list into the Map> on those places. + * We will continue to refactor the map from other places it was used in the future. And these + * methods will help us do a smooth refactor without breaking changes. + * + * After all QueryData maps are refactored, this function will be removed. + * + * @return A mutableMap with attribute IDs as keys and their corresponding value lists + * + */ + fun queryDataAsMap() = queryDataList.toMap() + + private fun simprintsSearchItems(): List = + searchParametersUiState.items.map { item -> + FieldUiModelImpl( + uid = item.uid, + label = item.label, + value = item.value, + focused = item.focused, + error = item.error, + editable = item.editable, + warning = item.warning, + mandatory = item.mandatory, + description = item.description, + valueType = + searchRepositoryKt.trackerValueTypeToSDKValueType(item.valueType) + ?: ValueType.TEXT, + optionSet = item.optionSet, + displayName = item.displayName, + optionSetConfiguration = null, + autocompleteList = emptyList(), + orgUnitSelectorScope = item.orgUnitSelectorScope, + customIntent = customIntentByFieldUid[item.uid], + ) + } + + /** + * + * Converts the mutableList of [QueryData] to a mutableMap where keys are attribute IDs + * and values are lists of strings. + * + * @return A mutableMap with attribute IDs as keys and their corresponding value lists + * + */ + private fun MutableList.toMap(): MutableMap?> = + this + .associate { queryData -> + val valueList = queryData.values + queryData.attributeId to valueList + }.toMutableMap() + + /** + * + * Converts a mutableMap to a mutableList of [QueryData] where map keys are attribute IDs + * and map values are lists of strings. + * + * @return A mutableList of [QueryData] objects + * + */ + private fun MutableMap?>.toQueryDataList() = + this + .map { (attributeId, valuesList) -> + QueryData( + attributeId = attributeId, + values = valuesList, + searchOperator = null, + ) + }.toMutableList() + + // } 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 8117a68464e..44211e7bf8b 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -3,6 +3,7 @@ import android.content.Context; import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProvider; import org.dhis2.R; import org.dhis2.commons.data.ProgramConfigurationRepository; @@ -32,7 +33,6 @@ import org.dhis2.data.enrollment.EnrollmentUiDataHelper; import org.dhis2.data.forms.dataentry.SearchTEIRepository; import org.dhis2.data.forms.dataentry.SearchTEIRepositoryImpl; -import org.dhis2.data.service.SyncStatusController; import org.dhis2.data.sorting.SearchSortingValueSetter; import org.dhis2.form.data.metadata.FileResourceConfiguration; import org.dhis2.form.data.metadata.OptionSetConfiguration; @@ -63,14 +63,31 @@ import org.dhis2.maps.model.MapScope; import org.dhis2.maps.usecases.MapStyleConfiguration; import org.dhis2.maps.utils.DhisMapUtils; +import org.dhis2.mobile.commons.coroutine.Dispatcher; import org.dhis2.mobile.commons.customintents.CustomIntentRepository; import org.dhis2.mobile.commons.customintents.CustomIntentRepositoryImpl; +import org.dhis2.mobile.commons.error.DomainErrorMapper; +import org.dhis2.mobile.commons.network.NetworkStatusProvider; +import org.dhis2.mobile.commons.network.NetworkStatusProviderImpl; import org.dhis2.mobile.commons.reporting.CrashReportController; +import org.dhis2.mobile.commons.resources.D2ErrorMessageProvider; +import org.dhis2.mobile.commons.resources.D2ErrorMessageProviderImpl; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase; -import org.dhis2.tracker.data.ProfilePictureProvider; -import org.dhis2.ui.ThemeManager; import org.dhis2.simprints.SimprintsResolveSingleBiometricSearchNavigationUseCase; +import org.dhis2.simprints.SimprintsSearchViewModel; import org.dhis2.simprints.di.SimprintsSearchViewModelFactory; +import org.dhis2.tracker.data.ProfilePictureProvider; +import org.dhis2.tracker.search.data.OptionSetRepository; +import org.dhis2.tracker.search.data.OptionSetRepositoryImpl; +import org.dhis2.tracker.search.data.SearchParametersRepository; +import org.dhis2.tracker.search.data.SearchParametersRepositoryImpl; +import org.dhis2.tracker.search.data.SearchTrackedEntityRepository; +import org.dhis2.tracker.search.data.SearchTrackedEntityRepositoryImpl; +import org.dhis2.tracker.search.domain.FetchOptionSetOptions; +import org.dhis2.tracker.search.domain.FetchSearchParameters; +import org.dhis2.tracker.search.domain.SearchTrackedEntities; +import org.dhis2.ui.ThemeManager; import org.dhis2.usescases.events.EventInfoProvider; import org.dhis2.usescases.searchTrackEntity.ui.mapper.TEICardMapper; import org.dhis2.usescases.tracker.TrackedEntityInstanceInfoProvider; @@ -92,23 +109,27 @@ public class SearchTEModule { private final String initialProgram; private final Context moduleContext; private final Map> initialQuery; + private final SyncStatusController syncStatusController; public SearchTEModule(SearchTEContractsModule.View view, String tEType, String initialProgram, Context context, - Map> initialQuery) { + Map> initialQuery, + SyncStatusController syncStatusController + ) { this.view = view; this.teiType = tEType; this.initialProgram = initialProgram; this.moduleContext = context; this.initialQuery = initialQuery; + this.syncStatusController = syncStatusController; } @Provides @PerActivity - SearchTEContractsModule.View provideView(SearchTEActivity searchTEActivity) { - return searchTEActivity; + SearchTEContractsModule.View provideView() { + return view; } @Provides @@ -120,7 +141,6 @@ SearchTEContractsModule.Presenter providePresenter(D2 d2, PreferenceProvider preferenceProvider, FilterRepository filterRepository, MatomoAnalyticsController matomoAnalyticsController, - SyncStatusController syncStatusController, ResourceManager resourceManager, ColorUtils colorUtils) { return new SearchTEPresenter(view, d2, searchRepository, schedulerProvider, @@ -156,33 +176,27 @@ MapTeiEventsToFeatureCollection provideMapTeiEventsToFeatureCollection() { SearchRepository searchRepository(@NonNull D2 d2, FilterPresenter filterPresenter, ResourceManager resources, - SearchSortingValueSetter searchSortingValueSetter, - DhisPeriodUtils periodUtils, Charts charts, CrashReportController crashReportController, - NetworkUtils networkUtils, + NetworkStatusProvider networkStatusProvider, SearchTEIRepository searchTEIRepository, ThemeManager themeManager, - MetadataIconProvider metadataIconProvider, DateUtils dateUtils, - CustomIntentRepository customIntentRepository) { - ProfilePictureProvider profilePictureProvider = new ProfilePictureProvider(d2); + CustomIntentRepository customIntentRepository, + DispatcherProvider dispatcherProvider) { return new SearchRepositoryImpl(teiType, initialProgram, d2, filterPresenter, resources, - searchSortingValueSetter, - periodUtils, charts, crashReportController, - networkUtils, + networkStatusProvider, searchTEIRepository, themeManager, - metadataIconProvider, - profilePictureProvider, dateUtils, - customIntentRepository); + customIntentRepository, + dispatcherProvider); } @Provides @@ -191,11 +205,11 @@ SearchRepositoryKt searchRepositoryKt( SearchRepository searchRepository, D2 d2, DispatcherProvider dispatcherProvider, - FieldViewModelFactory fieldViewModelFactory, MetadataIconProvider metadataIconProvider, ColorUtils colorUtils, DateUtils dateUtils, - CustomIntentRepository customIntentRepository + CustomIntentRepository customIntentRepository, + SearchSortingValueSetter sortingValueSetter ) { ResourceManager resourceManager = new ResourceManager(moduleContext, colorUtils); DateLabelProvider dateLabelProvider = new DateLabelProvider(moduleContext, new ResourceManager(moduleContext, colorUtils)); @@ -205,13 +219,12 @@ SearchRepositoryKt searchRepositoryKt( searchRepository, d2, dispatcherProvider, - fieldViewModelFactory, - metadataIconProvider, new TrackedEntityInstanceInfoProvider( d2, profilePictureProvider, dateLabelProvider, - metadataIconProvider + metadataIconProvider, + sortingValueSetter ), new EventInfoProvider( d2, @@ -369,11 +382,21 @@ SimprintsSearchViewModelFactory provideSimprintsSearchViewModelFactory( ); } + @Provides + @PerActivity + SimprintsSearchViewModel provideSimprintsSearchViewModel( + SimprintsSearchViewModelFactory simprintsSearchViewModelFactory + ) { + return new ViewModelProvider((SearchTEActivity) view, simprintsSearchViewModelFactory) + .get(SimprintsSearchViewModel.class); + } + @Provides @PerActivity SimprintsResolveSingleBiometricSearchNavigationUseCase provideSimprintsResolveSingleBiometricSearchNavigationUseCase( SearchRepository searchRepository, SearchRepositoryKt searchRepositoryKt, + SearchTrackedEntities searchTrackedEntities, NetworkUtils networkUtils, FilterManager filterManager, DispatcherProvider dispatcherProvider @@ -381,6 +404,7 @@ SimprintsResolveSingleBiometricSearchNavigationUseCase provideSimprintsResolveSi return new SimprintsResolveSingleBiometricSearchNavigationUseCase( searchRepository, searchRepositoryKt, + searchTrackedEntities, networkUtils, filterManager, dispatcherProvider.io() @@ -390,13 +414,13 @@ SimprintsResolveSingleBiometricSearchNavigationUseCase provideSimprintsResolveSi @Provides @PerActivity SimprintsLoadBiometricSearchResultsUseCase provideSimprintsLoadBiometricSearchResultsUseCase( - SearchRepository searchRepository, SearchRepositoryKt searchRepositoryKt, + SearchTrackedEntities searchTrackedEntities, SimprintsOrderSearchResultsByIdentifyResponseUseCase orderSearchResultsByIdentifyResponse ) { return new SimprintsLoadBiometricSearchResultsUseCase( - searchRepository, searchRepositoryKt, + searchTrackedEntities, orderSearchResultsByIdentifyResponse ); } @@ -413,7 +437,10 @@ SearchTeiViewModelFactory providesViewModelFactory( DisplayNameProvider displayNameProvider, FilterManager filterManager, ProgramConfigurationRepository programConfigurationRepository, - SimprintsSearchViewModelFactory simprintsSearchViewModelFactory, + SearchTrackedEntities searchTrackedEntities, + FetchSearchParameters fetchSearchParameters, + FetchOptionSetOptions fetchOptionSetOptions, + SimprintsSearchViewModel simprintsSearchViewModel, SimprintsLoadBiometricSearchResultsUseCase loadSimprintsBiometricSearchResultsUseCase ) { return new SearchTeiViewModelFactory( @@ -434,12 +461,96 @@ SearchTeiViewModelFactory providesViewModelFactory( resourceManager, displayNameProvider, filterManager, - (SearchTEActivity) moduleContext, - simprintsSearchViewModelFactory, + searchTrackedEntities, + fetchSearchParameters, + fetchOptionSetOptions, + simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase ); } + @Provides + @PerActivity + FetchSearchParameters provideFetchSearchParametersUseCase( + SearchParametersRepository searchParametersRepository + ) { + return new FetchSearchParameters( + new Dispatcher(), + searchParametersRepository + ); + } + + @Provides + @PerActivity + FetchOptionSetOptions provideFetchOptionSetOptionsUseCase( + OptionSetRepository optionSetRepository + ) { + return new FetchOptionSetOptions( + optionSetRepository + ); + } + + @Provides + @PerActivity + SearchParametersRepository provideSearchParametersRepository( + D2 d2, + CustomIntentRepository customIntentRepository, + DomainErrorMapper domainErrorMapper + ) { + return new SearchParametersRepositoryImpl( + d2, + customIntentRepository, + domainErrorMapper + ); + } + + @Provides + @PerActivity + OptionSetRepository provideOptionSetRepository( + D2 d2, + DomainErrorMapper domainErrorMapper + ) { + return new OptionSetRepositoryImpl( + d2, + domainErrorMapper + ); + } + + + + @Provides + @PerActivity + SearchTrackedEntities provideLoadSearchResultsUseCase( + SearchTrackedEntityRepository searchTrackedEntityRepository, + CustomIntentRepository customIntentRepository + ) { + return new SearchTrackedEntities( + searchTrackedEntityRepository, + customIntentRepository, + this.teiType + ); + } + + @Provides + @PerActivity + SearchTrackedEntityRepository provideLoadSearchResultsRepository( + D2 d2, + FilterPresenter filterPresenter, + ProfilePictureProvider profilePictureProvider + ) { + return new SearchTrackedEntityRepositoryImpl( + d2, + filterPresenter, + profilePictureProvider + ); + } + + @Provides + @PerActivity + ProfilePictureProvider provideProfilePictureProvider(D2 d2) { + return new ProfilePictureProvider(d2); + } + @Provides @PerActivity DateUtils provideDateUtils( @@ -508,4 +619,29 @@ WorkingListViewModelFactory provideWorkingListViewModelFactory( ) { return new WorkingListViewModelFactory(initialProgram, filterRepository); } + + @Provides + @PerActivity + DomainErrorMapper provideDomainErrorMapper( + D2ErrorMessageProvider d2ErrorMessageProvider, + NetworkStatusProvider networkStatusProvider + ) { + return new DomainErrorMapper( + d2ErrorMessageProvider, + networkStatusProvider + ); + } + + @Provides + @PerActivity + D2ErrorMessageProvider provideD2ErrorMessageProvider() { + return new D2ErrorMessageProviderImpl(); + } + + @Provides + @PerActivity + NetworkStatusProvider provideNetworkStatusProvider() { + + return new NetworkStatusProviderImpl(moduleContext); + } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java index ac7cef1d4e2..f1fc82c79a4 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenter.java @@ -27,7 +27,6 @@ import org.dhis2.commons.filters.data.FilterRepository; import org.dhis2.commons.matomo.MatomoAnalyticsController; import org.dhis2.commons.orgunitselector.OUTreeFragment; -import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope; import org.dhis2.commons.prefs.Preference; import org.dhis2.commons.prefs.PreferenceProvider; import org.dhis2.commons.resources.ColorUtils; @@ -36,8 +35,9 @@ import org.dhis2.commons.schedulers.SchedulerProvider; import org.dhis2.commons.schedulers.SingleEventEnforcer; import org.dhis2.commons.schedulers.SingleEventEnforcerImpl; -import org.dhis2.data.service.SyncStatusController; import org.dhis2.maps.model.StageStyle; +import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.utils.analytics.AnalyticsHelper; import org.hisp.dhis.android.core.D2; import org.hisp.dhis.android.core.common.FeatureType; @@ -84,7 +84,6 @@ public class SearchTEPresenter implements SearchTEContractsModule.Presenter { private final DisableHomeFiltersFromSettingsApp disableHomeFilters; private final MatomoAnalyticsController matomoAnalyticsController; private final SyncStatusController syncStatusController; - private final ColorUtils colorUtils; public SearchTEPresenter(SearchTEContractsModule.View view, @@ -320,7 +319,7 @@ public void enroll(String programUid, String uid, HashMap> ); } - private void enrollInOrgUnit(String orgUnitUid, String programUid, String uid, HashMap> queryData) { + private void enrollInOrgUnit(String orgUnitUid, String programUid, String uid, HashMap> queryData) { compositeDisposable.add( searchRepository.saveToEnroll(trackedEntity.uid(), orgUnitUid, programUid, uid, queryData, view.fromRelationshipTEI()) .subscribeOn(schedulerProvider.computation()) 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 c1cd2674bc3..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,55 +1,28 @@ package org.dhis2.usescases.searchTrackEntity; -import org.dhis2.commons.data.CarouselItemModel; -import org.dhis2.tracker.relationships.model.RelationshipModel; import org.dhis2.mobile.commons.model.MetadataIconData; -import org.hisp.dhis.android.core.enrollment.Enrollment; +import org.dhis2.tracker.search.model.DomainEnrollment; +import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain; +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult; import org.hisp.dhis.android.core.maintenance.D2ErrorCode; -import org.hisp.dhis.android.core.program.Program; -import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue; -import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; -import java.util.List; -import java.util.Objects; -import kotlin.Triple; +public class SearchTeiModel{ -public class SearchTeiModel implements CarouselItemModel { + private LinkedHashMap attributeValues; + private LinkedHashMap textAttributeValues; - private LinkedHashMap attributeValues; - private LinkedHashMap textAttributeValues; - - private List> enrollmentsInfo; - private List programInfo; private HashMap metadataIconDataMap; - private boolean hasOverdue; - private boolean isOnline; - - private TrackedEntityInstance tei; - private String profilePicturePath; - private String defaultTypeIcon; - private String header; - - private Enrollment selectedEnrollment; - private List enrollments; - private Date overdueDate; - private List relationships; + + private TrackedEntitySearchItemResult tei; + private boolean openedAttributeList = false; private String sortingKey; private String sortingValue; - private String teTypeName; - private String enrolledOrgUnit; - private String ownerOrgUnit; - private Boolean displayOrgUnit; - private boolean showNavigationButton = false; @Nullable public String onlineErrorMessage; @Nullable @@ -57,140 +30,73 @@ public class SearchTeiModel implements CarouselItemModel { public SearchTeiModel() { this.tei = null; - this.selectedEnrollment = null; this.attributeValues = new LinkedHashMap<>(); this.textAttributeValues = new LinkedHashMap<>(); - this.enrollmentsInfo = new ArrayList<>(); - this.programInfo = new ArrayList<>(); - this.isOnline = true; - this.enrollments = new ArrayList<>(); - this.relationships = new ArrayList<>(); this.sortingKey = null; this.sortingValue = null; - this.enrolledOrgUnit = null; this.onlineErrorMessage = null; this.metadataIconDataMap = new HashMap<>(); - this.ownerOrgUnit = null; } - public void addEnrollmentInfo(Triple enrollmentInfo) { - enrollmentsInfo.add(enrollmentInfo); - } - public void addProgramInfo(Program program, MetadataIconData metadataIconData) { - if (!programInfo.contains(program)) { - programInfo.add(program); - metadataIconDataMap.put(program.uid(), metadataIconData); - } - } - public boolean isHasOverdue() { - return hasOverdue; - } + public void addProgramInfo(String programUid, MetadataIconData metadataIconData) { - public void setHasOverdue(boolean hasOverdue) { - this.hasOverdue = hasOverdue; - } + metadataIconDataMap.put(programUid, metadataIconData); - public boolean isOnline() { - return isOnline; } - public void setOnline(boolean online) { - isOnline = online; - this.attributeValues.clear(); - } - public LinkedHashMap getAttributeValues() { + public LinkedHashMap getAttributeValues() { return attributeValues; } - public LinkedHashMap getTextAttributeValues() { + public LinkedHashMap getTextAttributeValues() { return textAttributeValues; } - public void addAttributeValue(String attributeName, TrackedEntityAttributeValue attributeValues) { + public void addAttributeValue(String attributeName, TrackedEntitySearchItemAttributeDomain attributeValues) { this.attributeValues.put(attributeName, attributeValues); } - public void addTextAttribute(String attributeName, TrackedEntityAttributeValue attributeValue) { + public void addTextAttribute(String attributeName, TrackedEntitySearchItemAttributeDomain attributeValue) { this.textAttributeValues.put(attributeName, attributeValue); } - public void resetEnrollments() { - this.enrollments.clear(); - this.enrollmentsInfo.clear(); - } - - public void setAttributeValues(LinkedHashMap attributeValues) { + public void setAttributeValues(LinkedHashMap attributeValues) { this.attributeValues = attributeValues; } - public void setTei(TrackedEntityInstance tei) { + public void setTei(TrackedEntitySearchItemResult tei) { this.tei = tei; } - public TrackedEntityInstance getTei() { + public TrackedEntitySearchItemResult getTei() { return tei; } - public void setProfilePicture(String profilePicturePath) { - this.profilePicturePath = profilePicturePath; - } public String getProfilePicturePath() { - return profilePicturePath != null ? profilePicturePath : ""; + return this.tei.getProfilePicture() != null ? this.tei.getProfilePicture() : ""; } - public void setDefaultTypeIcon(String defaultTypeIcon) { - this.defaultTypeIcon = defaultTypeIcon; - } + @Nullable public String getDefaultTypeIcon() { - return defaultTypeIcon; - } - - public void setHeader(String header) { - this.header = header; + return this.tei.getDefaultTypeIcon(); } @Nullable public String getHeader() { - return header; - } - - public void setCurrentEnrollment(Enrollment enrollment) { - this.selectedEnrollment = enrollment; + return this.tei.getHeader(); } - public Enrollment getSelectedEnrollment() { - return this.selectedEnrollment; - } - - public void addEnrollment(Enrollment enrollment) { - this.enrollments.add(enrollment); - } - public List getEnrollments() { - return enrollments; - } - - public List getProgramInfo() { - Collections.sort(programInfo, (program1, program2) -> program1.displayName().compareToIgnoreCase(program2.displayName())); - if (selectedEnrollment != null) { - List programs = new ArrayList<>(); - for (Program program : programInfo) { - if (!Objects.equals(selectedEnrollment.program(), program.uid())) { - programs.add(program); - } - } - return programs; - } else { - return programInfo; - } + public DomainEnrollment getSelectedEnrollment() { + return this.tei.getSelectedEnrollment(); } public MetadataIconData getMetadataIconData(@Nullable String programUid) { @@ -211,22 +117,6 @@ public Boolean isMetadataIconDataAvailable(@Nullable String programUid) { } } - public void setOverdueDate(Date dateToShow) { - this.overdueDate = dateToShow; - } - - public Date getOverdueDate() { - return overdueDate; - } - - public List getRelationships() { - return relationships; - } - - public void setRelationships(List relationships) { - this.relationships = relationships; - } - public void toggleAttributeList() { this.openedAttributeList = !this.openedAttributeList; } @@ -250,49 +140,5 @@ public String getSortingValue() { return sortingValue; } - public void setTEType(String teTypeName) { - this.teTypeName = teTypeName; - } - - public String getTeTypeName() { - return teTypeName; - } - - @NotNull - @Override - public String uid() { - return tei.uid(); - } - - public void setEnrolledOrgUnit(String orgUnit) { - enrolledOrgUnit = orgUnit; - } - - public String getEnrolledOrgUnit() { - return enrolledOrgUnit; - } - - public void setOwnerOrgUnit(String orgUnit) { - ownerOrgUnit = orgUnit; - } - public String getOwnerOrgUnit() { - return ownerOrgUnit; - } - - public void setDisplayOrgUnit(Boolean display) { - displayOrgUnit = display; - } - - public Boolean getDisplayOrgUnit() { - return displayOrgUnit; - } - - public void setShowNavigationButton(boolean showNavigationButton) { - this.showNavigationButton = showNavigationButton; - } - - public boolean shouldShowNavigationButton() { - return showNavigationButton; - } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt index 90968c923ac..c1372c56ab6 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt @@ -10,7 +10,9 @@ import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase import org.dhis2.simprints.SimprintsSearchViewModel -import org.dhis2.simprints.di.SimprintsSearchViewModelFactory +import org.dhis2.tracker.search.domain.FetchOptionSetOptions +import org.dhis2.tracker.search.domain.FetchSearchParameters +import org.dhis2.tracker.search.domain.SearchTrackedEntities class SearchTeiViewModelFactory( private val searchRepository: SearchRepository, @@ -25,8 +27,10 @@ class SearchTeiViewModelFactory( private val resourceManager: ResourceManager, private val displayNameProvider: DisplayNameProvider, private val filterManager: FilterManager, - private val searchActivity: SearchTEActivity, - private val simprintsSearchViewModelFactory: SimprintsSearchViewModelFactory, + private val searchTrackedEntities: SearchTrackedEntities, + private val fetchSearchParameters: FetchSearchParameters, + private val fetchOptionSetOptions: FetchOptionSetOptions, + private val simprintsSearchViewModel: SimprintsSearchViewModel, private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = @@ -43,7 +47,10 @@ class SearchTeiViewModelFactory( resourceManager, displayNameProvider, filterManager, - ViewModelProvider(searchActivity, simprintsSearchViewModelFactory)[SimprintsSearchViewModel::class.java], + searchTrackedEntities, + fetchSearchParameters, + fetchOptionSetOptions, + simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase, ) as T } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/BaseTeiViewHolder.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/BaseTeiViewHolder.kt index d38456551d0..75ba63d16fe 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/BaseTeiViewHolder.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/BaseTeiViewHolder.kt @@ -3,15 +3,13 @@ package org.dhis2.usescases.searchTrackEntity.adapters import android.view.View import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.recyclerview.widget.RecyclerView -import org.dhis2.commons.bindings.getEnrollmentIconsData -import org.dhis2.commons.bindings.hasFollowUp import org.dhis2.commons.bindings.paintAllEnrollmentIcons -import org.dhis2.commons.bindings.setAttributeList -import org.dhis2.commons.bindings.setStatusText import org.dhis2.commons.data.EnrollmentIconData import org.dhis2.commons.date.toDateSpan import org.dhis2.commons.resources.ColorUtils import org.dhis2.databinding.ItemSearchTrackedEntityBinding +import org.dhis2.mobile.commons.extensions.toJavaDate +import org.dhis2.tracker.search.data.toSDKState import org.dhis2.usescases.searchTrackEntity.SearchTeiModel abstract class BaseTeiViewHolder( @@ -43,32 +41,32 @@ abstract class BaseTeiViewHolder( } binding.apply { - overdue = teiModel.isHasOverdue - isOnline = teiModel.isOnline - orgUnit = teiModel.enrolledOrgUnit - teiSyncState = teiModel.tei.state() + overdue = teiModel.tei.overDueDate?.toJavaDate() != null + isOnline = teiModel.tei.isOnline + orgUnit = teiModel.tei.enrollmentOrgUnit + teiSyncState = teiModel.tei.aggregatedSyncState?.toSDKState() attribute = teiModel.attributeValues.values.toList() attributeNames = teiModel.attributeValues.keys attributeListOpened = teiModel.isAttributeListOpen - lastUpdated.text = teiModel.tei.lastUpdated().toDateSpan(itemView.context) + lastUpdated.text = teiModel.tei.lastUpdated?.toJavaDate()?.toDateSpan(itemView.context) sortingValue = teiModel.sortingValue } teiModel.apply { - binding.setFollowUp(enrollments.hasFollowUp()) - val enrollmentIconDataList: List = - programInfo.getEnrollmentIconsData( - if (selectedEnrollment != null) selectedEnrollment.program() else null, + binding.setFollowUp(tei.enrollments?.hasFollowUp()) + val enrollmentIconDataList: List? = + tei.enrolledPrograms?.getEnrollmentIconsData( + if (selectedEnrollment != null) selectedEnrollment.program else null, ) { programUid -> getMetadataIconData(programUid) } - enrollmentIconDataList.paintAllEnrollmentIcons( + enrollmentIconDataList?.paintAllEnrollmentIcons( binding.composeProgramList, ) if (selectedEnrollment != null) { selectedEnrollment.setStatusText( itemView.context, binding.enrollmentStatus, - isHasOverdue, - overdueDate, + teiModel.tei.overDueDate != null, + teiModel.tei.overDueDate?.toJavaDate(), ) } setTeiImage( @@ -85,7 +83,7 @@ abstract class BaseTeiViewHolder( teiModel.isAttributeListOpen, teiModel.sortingKey, teiModel.sortingValue, - teiModel.enrolledOrgUnit, + teiModel.tei.enrollmentOrgUnit, ) { attributeVisibilityCallback() } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchAdapterDiffCallback.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchAdapterDiffCallback.kt index a2c66cac0fe..4b02a4f7c01 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchAdapterDiffCallback.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchAdapterDiffCallback.kt @@ -7,21 +7,15 @@ class SearchAdapterDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: SearchTeiModel, newItem: SearchTeiModel, - ): Boolean = oldItem.tei?.uid() == newItem.tei?.uid() + ): Boolean = oldItem.tei?.uid == newItem.tei?.uid override fun areContentsTheSame( oldItem: SearchTeiModel, - newItem: SearchTeiModel, - ): Boolean = - oldItem.tei?.uid() == newItem.tei?.uid() && - oldItem.tei?.state() == newItem.tei?.state() && - oldItem.attributeValues == newItem.attributeValues && - oldItem.enrollments == newItem.enrollments && - oldItem.profilePicturePath == newItem.profilePicturePath && - oldItem.isAttributeListOpen == newItem.isAttributeListOpen && - oldItem.sortingKey == newItem.sortingKey && - oldItem.sortingValue == newItem.sortingValue && - oldItem.enrolledOrgUnit == newItem.enrolledOrgUnit + newItem: SearchTeiModel + ): Boolean { + return oldItem.tei == newItem.tei + } + override fun getChangePayload( oldItem: SearchTeiModel, diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchRelationshipViewHolder.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchRelationshipViewHolder.kt index d6195b0c073..a0c9c0dd43f 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchRelationshipViewHolder.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchRelationshipViewHolder.kt @@ -21,7 +21,7 @@ class SearchRelationshipViewHolder( override fun itemViewClick() { itemView.setOnClickListener { - onAddRelationship(teiModel.tei.uid(), null, teiModel.isOnline) + onAddRelationship(teiModel.tei.uid, null, teiModel.tei.isOnline) } } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTEViewHolder.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTEViewHolder.kt index 330961652fb..b4ec44dfff8 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTEViewHolder.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTEViewHolder.kt @@ -19,9 +19,8 @@ class SearchTEViewHolder( override fun itemViewClick() { binding.syncState.setOnClickListener { - if (teiModel.tei.deleted()!! || - teiModel.selectedEnrollment != null && - teiModel.selectedEnrollment.deleted()!! + if (teiModel.tei.deleted || + teiModel.tei.selectedEnrollment == null ) { Toast .makeText( @@ -30,28 +29,28 @@ class SearchTEViewHolder( Toast.LENGTH_SHORT, ).show() } else { - onSyncIconClick(teiModel.selectedEnrollment.uid()) + onSyncIconClick(teiModel.selectedEnrollment.uid) } } binding.download.setOnClickListener { onDownloadTei( - teiModel.tei.uid(), - teiModel.selectedEnrollment?.uid(), + teiModel.tei.uid, + teiModel.selectedEnrollment?.uid, ) } binding.cardView.setOnClickListener { - if (teiModel.isOnline) { + if (teiModel.tei.isOnline) { onDownloadTei( - teiModel.tei.uid(), - teiModel.selectedEnrollment?.uid(), + teiModel.tei.uid, + teiModel.selectedEnrollment?.uid, ) } else { onTeiClick( - teiModel.tei.uid(), - teiModel.selectedEnrollment?.uid(), - teiModel.isOnline, + teiModel.tei.uid, + teiModel.selectedEnrollment?.uid, + teiModel.tei.isOnline, ) } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt index dfcc27b0d80..ff7f6f75061 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiLiveAdapter.kt @@ -98,19 +98,19 @@ class SearchTeiLiveAdapter( cardMapper.map( searchTEIModel = it, onSyncIconClick = { - onSyncIconClick.invoke(it.selectedEnrollment.uid()) + onSyncIconClick.invoke(it.selectedEnrollment.uid) }, onCardClick = { - if (it.isOnline) { + if (it.tei.isOnline) { onDownloadTei.invoke( - it.tei.uid(), - it.selectedEnrollment?.uid(), + it.tei.uid, + it.selectedEnrollment?.uid, ) } else { onTeiClick.invoke( - it.tei.uid(), - it.selectedEnrollment?.uid(), - it.isOnline, + it.tei.uid, + it.selectedEnrollment?.uid, + it.tei.isOnline, ) } }, diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiModelExtensions.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiModelExtensions.kt index 4574129cbfe..7d013ed988c 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiModelExtensions.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/adapters/SearchTeiModelExtensions.kt @@ -3,19 +3,34 @@ package org.dhis2.usescases.searchTrackEntity.adapters import android.content.Context import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater import android.view.View import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.toColorInt import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import org.dhis2.commons.R +import org.dhis2.commons.bindings.getRemainingEnrollmentsForTei +import org.dhis2.commons.data.EnrollmentIconData +import org.dhis2.commons.databinding.ItemFieldValueBinding +import org.dhis2.commons.date.toUiText import org.dhis2.commons.resources.ColorType import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager +import org.dhis2.mobile.commons.model.MetadataIconData +import org.dhis2.tracker.search.model.DomainEnrollment +import org.dhis2.tracker.search.model.DomainProgram +import org.dhis2.tracker.search.model.EnrollmentStatus +import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import java.io.File +import java.util.Date fun SearchTeiModel.setTeiImage( context: Context, @@ -58,16 +73,16 @@ fun SearchTeiModel.setTeiImage( textAttributeValues != null && textAttributeValues.values.isNotEmpty() && - ArrayList(textAttributeValues.values)[0].value() != "-" -> { + ArrayList(textAttributeValues.values)[0].value != "-" -> { teiImageView.setImageDrawable(null) teiTextImageView.visibility = View.VISIBLE val valueToShow = ArrayList(textAttributeValues.values) - if (valueToShow[0]?.value()?.isEmpty() != false) { + if (valueToShow[0]?.value?.isEmpty() != false) { teiTextImageView.text = "?" } else { teiTextImageView.text = valueToShow[0] - .value() + .value ?.first() .toString() .uppercase() @@ -82,9 +97,9 @@ fun SearchTeiModel.setTeiImage( ) } - isOnline && + tei.isOnline && attributeValues.isNotEmpty() && - ArrayList(attributeValues.values).firstOrNull()?.value()?.isNotEmpty() == true -> { + ArrayList(attributeValues.values).firstOrNull()?.value?.isNotEmpty() == true -> { teiImageView.setImageDrawable(null) teiTextImageView.visibility = View.VISIBLE val valueToShow = ArrayList(attributeValues.values) @@ -93,7 +108,7 @@ fun SearchTeiModel.setTeiImage( } else { teiTextImageView.text = valueToShow[0] - .value() + .value ?.first() .toString() .uppercase() @@ -143,3 +158,147 @@ fun SearchTeiModel.setTeiImage( } } } + +fun LinkedHashMap.setAttributeList( + parentLayout: LinearLayout, + showAttributesButton: ImageView, + adapterPosition: Int, + listIsOpen: Boolean, + sortingKey: String?, + sortingValue: String?, + orgUnit: String?, + showList: () -> Unit, +) { + parentLayout.removeAllViews() + if (size > 3) { + for (pos in 1 until size) { + val fieldName = + keys.toTypedArray()[pos] + val fieldValue = this[fieldName]?.value + val itemFieldValueBinding = + ItemFieldValueBinding.inflate(LayoutInflater.from(parentLayout.context)) + itemFieldValueBinding.name = fieldName + itemFieldValueBinding.value = fieldValue + itemFieldValueBinding.root.tag = adapterPosition.toString() + "_" + fieldName + parentLayout.addView(itemFieldValueBinding.root) + } + val orgUnitKey = parentLayout.context.getString(R.string.enrolled_in) + val itemFieldValueBinding = + ItemFieldValueBinding.inflate(LayoutInflater.from(parentLayout.context)) + itemFieldValueBinding.name = orgUnitKey + itemFieldValueBinding.value = orgUnit + itemFieldValueBinding.root.tag = adapterPosition.toString() + "_" + orgUnitKey + parentLayout.addView(itemFieldValueBinding.root) + if (sortingKey != null) { + val binding = + ItemFieldValueBinding.inflate(LayoutInflater.from(parentLayout.context)) + binding.name = sortingKey + binding.fieldName.setTextColor( + ResourcesCompat.getColor( + binding.fieldName.context.resources, + R.color.sorting_attribute_key_color, + null, + ), + ) + binding.value = sortingValue + binding.fieldValue.setTextColor( + ResourcesCompat.getColor( + binding.fieldValue.context.resources, + R.color.sorting_attribute_value_color, + null, + ), + ) + binding.root.tag = adapterPosition.toString() + "_" + sortingValue + parentLayout.addView(binding.root) + } + showAttributesButton.scaleY = if (listIsOpen) -1F else 1F + showAttributesButton.setOnClickListener { + showList() + } + } else { + showAttributesButton.setOnClickListener(null) + } +} + + + +fun DomainEnrollment.setStatusText( + context: Context, + statusTextView: TextView, + isOverdue: Boolean, + dueDate: Date?, +) { + var textToShow: String? = null + var color = -1 + when { + isOverdue -> { + textToShow = dueDate.toUiText(context) + color = "#E91E63".toColorInt() + } + + status == EnrollmentStatus.CANCELLED -> { + textToShow = context.getString(R.string.cancelled) + color = "#E91E63".toColorInt() + } + + status == EnrollmentStatus.COMPLETED -> { + textToShow = context.getString(R.string.enrollment_status_completed) + color = "#8A333333".toColorInt() + } + } + statusTextView.visibility = if (textToShow == null) View.GONE else View.VISIBLE + statusTextView.text = textToShow + statusTextView.setTextColor(color) + val bgDrawable = + AppCompatResources.getDrawable( + context, + R.drawable.round_border_box_2, + ) as GradientDrawable? + bgDrawable!!.setStroke(2, color) + statusTextView.background = bgDrawable +} + + +fun List.hasFollowUp(): Boolean = + firstOrNull { enrollment -> + enrollment.followUp + }?.followUp ?: false + +fun List.getEnrollmentIconsData( + currentProgram: String?, + provideMetadataIconData: (String) -> MetadataIconData, +): List { + val enrollmentIconDataList: MutableList = mutableListOf() + + val filteredList = this.filter { it.uid != currentProgram } + this.filter { it.uid != currentProgram } + .forEachIndexed { index, program -> + filteredList.size.let { + if (it <= 4) { + enrollmentIconDataList.add( + EnrollmentIconData(0, 0, true, 0, + provideMetadataIconData(program.uid)), + ) + } else { + if (index in 0..2) { + enrollmentIconDataList.add( + EnrollmentIconData(0, 0, true, 0, provideMetadataIconData(program.uid)), + ) + } + if (index == 3) { + enrollmentIconDataList.add( + EnrollmentIconData( + 0, + 0, + false, + getRemainingEnrollmentsForTei(filteredList.size), + provideMetadataIconData(program.uid), + ), + ) + } + } + } + } + return enrollmentIconDataList +} + 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 new file mode 100644 index 00000000000..c65013229b9 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/di/SearchTEKoinModule.kt @@ -0,0 +1,120 @@ +package org.dhis2.usescases.searchTrackEntity.di + +import org.dhis2.commons.simprints.repository.SimprintsD2Repository +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase +import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase +import org.dhis2.simprints.SimprintsResolveSingleBiometricSearchNavigationUseCase +import org.dhis2.simprints.SimprintsSearchViewModel +import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel +import org.dhis2.usescases.searchTrackEntity.SearchTeiViewModelFactory +import org.koin.core.module.dsl.viewModel +import org.koin.core.parameter.parametersOf +import org.koin.dsl.module + +/** + * Koin module for Search Tracked Entity feature. + * This module provides dependencies for SearchTEIViewModel and related components. + * + * Note: This module coexists with the legacy Dagger SearchTEModule during the migration. + */ +val searchTEKoinModule = + module { + factory { SimprintsSessionRepository(get()) } + factory { SimprintsD2Repository(get()) } + factory { SimprintsResolveConfirmIdentityCalloutUseCase(get()) } + factory { SimprintsOrderSearchResultsByIdentifyResponseUseCase(get()) } + factory { params -> + val teType: String = params.get() + SimprintsResolveSingleBiometricSearchNavigationUseCase( + searchRepository = get(), + searchRepositoryKt = get(), + searchTrackedEntities = get { parametersOf(teType) }, + networkUtils = get(), + filterManager = get(), + ioDispatcher = get().io(), + ) + } + factory { params -> + val teType: String = params.get() + SimprintsLoadBiometricSearchResultsUseCase( + searchRepositoryKt = get(), + searchTrackedEntities = get { parametersOf(teType) }, + orderSearchResultsByIdentifyResponse = get(), + ) + } + factory { params -> + val teType: String = params.get() + SimprintsSearchViewModel( + resolveConfirmIdentityCallout = get(), + sessionRepository = get(), + resolveSingleBiometricSearchNavigation = get { parametersOf(teType) }, + ) + } + + // SearchTeiViewModelFactory + // Factory for creating SearchTEIViewModel instances with SearchTrackedEntities + factory { params -> + val initialProgramUid: String? = params.getOrNull() + val initialQuery: MutableMap?>? = params.getOrNull() + val teType: String = params.get() // teType is required + + // Get SearchRepository from Dagger to access teType + val searchRepository: SearchRepository = get() + + SearchTeiViewModelFactory( + searchRepository = searchRepository, + searchRepositoryKt = get(), + searchNavPageConfigurator = get(), + initialProgramUid = initialProgramUid, + initialQuery = initialQuery, + mapDataRepository = get(), + networkUtils = get(), + dispatchers = get(), + mapStyleConfig = get(), + resourceManager = get(), + displayNameProvider = get(), + filterManager = get(), + searchTrackedEntities = get { parametersOf(teType) }, + fetchSearchParameters = get(), + fetchOptionSetOptions = get(), + simprintsSearchViewModel = get { parametersOf(teType) }, + loadSimprintsBiometricSearchResultsUseCase = get { parametersOf(teType) }, + ) + } + + // SearchTEIViewModel + // Note: This ViewModel requires parameters (initialProgramUid, initialQuery, teType) + // Use: viewModel { parametersOf(programUid, queryMap, teType) } + viewModel { params -> + val initialProgramUid: String? = params.getOrNull() + val initialQuery: MutableMap?>? = params.getOrNull() + val teType: String = params.get() // teType is required + + // Get SearchRepository from Dagger + val searchRepository: SearchRepository = get() + + SearchTEIViewModel( + initialProgramUid = initialProgramUid, + initialQuery = initialQuery, + searchRepository = searchRepository, + searchRepositoryKt = get(), + searchNavPageConfigurator = get(), + mapDataRepository = get(), + networkUtils = get(), + dispatchers = get(), + mapStyleConfig = get(), + resourceManager = get(), + displayNameProvider = get(), + filterManager = get(), + searchTrackedEntities = get { parametersOf(teType) }, + fetchSearchParameters = get(), + fetchOptionSetOptions = get(), + simprintsSearchViewModel = get { parametersOf(teType) }, + loadSimprintsBiometricSearchResultsUseCase = get { parametersOf(teType) }, + ) + } + } 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 2fa18b89414..142bcf72b39 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 @@ -113,6 +113,8 @@ class SearchTEList : FragmentGlobalAbstract() { arguments?.getBoolean(ARG_FROM_RELATIONSHIP) ?: false } + private var pagesUpdatedListener: (() -> Unit)? = null + companion object { fun get(fromRelationships: Boolean): SearchTEList = SearchTEList().apply { @@ -152,6 +154,12 @@ class SearchTEList : FragmentGlobalAbstract() { observeNewData() }.root + override fun onDestroyView() { + pagesUpdatedListener?.let { liveAdapter.removeOnPagesUpdatedListener(it) } + pagesUpdatedListener = null + super.onDestroyView() + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val layoutManager = recycler.layoutManager as? LinearLayoutManager @@ -284,7 +292,7 @@ class SearchTEList : FragmentGlobalAbstract() { val teTypeName by viewModel.teTypeName.observeAsState() val hasQueryData = remember(viewModel.searchParametersUiState) { - viewModel.queryData.isNotEmpty() + viewModel.queryDataList.isNotEmpty() } val isSimprintsUseLastBiometricsLabel by viewModel .isSimprintsUseLastBiometricsLabel @@ -351,7 +359,7 @@ class SearchTEList : FragmentGlobalAbstract() { viewModel.dataResult.observe(viewLifecycleOwner) { initLoading(emptyList()) it.firstOrNull()?.let { searchResult -> - if (searchResult.shouldClearProgramData()) { + if (searchResult.shouldClearProgramData() && liveAdapter.itemCount > 0) { liveAdapter.refresh() } if (searchResult.shouldClearGlobalData()) { @@ -411,13 +419,16 @@ class SearchTEList : FragmentGlobalAbstract() { private fun initData() { displayLoadingData() + val listener: () -> Unit = { + onInitDataLoaded() + CoroutineTracker.decrement() + } + pagesUpdatedListener = listener + liveAdapter.addOnPagesUpdatedListener(listener) + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.searchPagingData.collect { data -> - liveAdapter.addOnPagesUpdatedListener { - onInitDataLoaded() - CoroutineTracker.decrement() - } + viewModel.searchPagingData.collectLatest { data -> liveAdapter.submitData(lifecycle, data) } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt index 27b88c4f36e..9430ca5cf8e 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt @@ -12,9 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ComposeView @@ -132,9 +130,7 @@ class SearchTEMap : FragmentGlobalAbstract() { val listState = rememberLazyListState() val trackerMapData by viewModel.mapResults.collectAsState(initial = null) - val items by remember { - derivedStateOf { trackerMapData?.mapItems ?: emptyList() } - } + val items = trackerMapData?.mapItems ?: emptyList() val clickedItem by viewModel.mapItemClicked.collectAsState(initial = null) @@ -150,17 +146,21 @@ class SearchTEMap : FragmentGlobalAbstract() { } } - LaunchedEffect(key1 = items) { + LaunchedEffect(key1 = trackerMapData) { trackerMapData?.let { data -> teiMapManager ?.takeIf { it.isMapReady() } - ?.update( - data.teiFeatures, - data.eventFeatures, - data.dataElementFeaturess, - data.teiBoundingBox, - ).also { - viewModel.mapManager = teiMapManager + ?.also { manager -> + manager.update( + data.teiFeatures, + data.eventFeatures, + data.dataElementFeaturess, + data.teiBoundingBox, + ) + if (manager.autoSelectFirstLayerWithData()) { + viewModel.filterVisibleMapItems(manager.mapLayerManager.mapLayers) + } + viewModel.mapManager = manager } } } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt deleted file mode 100644 index 9a865c8539a..00000000000 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt +++ /dev/null @@ -1,579 +0,0 @@ -package org.dhis2.usescases.searchTrackEntity.searchparameters - -import android.app.Activity.RESULT_OK -import android.content.Intent -import android.content.res.Configuration -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Cancel -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.journeyapps.barcodescanner.ScanOptions -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.dhis2.R -import org.dhis2.commons.Constants -import org.dhis2.commons.resources.ColorUtils -import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase -import org.dhis2.commons.simprints.utils.SimprintsIntentUtils -import org.dhis2.form.data.scan.ScanContract -import org.dhis2.form.di.Injector -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.FieldUiModelImpl -import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract -import org.dhis2.form.ui.event.RecyclerViewUiEvents -import org.dhis2.form.ui.intent.FormIntent -import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel -import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope -import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel -import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState -import org.dhis2.usescases.searchTrackEntity.searchparameters.provider.provideParameterSelectorItem -import org.hisp.dhis.android.core.common.ValueType -import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItemColor -import org.hisp.dhis.mobile.ui.designsystem.component.Button -import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle -import org.hisp.dhis.mobile.ui.designsystem.component.InfoBar -import org.hisp.dhis.mobile.ui.designsystem.component.parameter.ParameterSelectorItem -import org.hisp.dhis.mobile.ui.designsystem.theme.Radius -import org.hisp.dhis.mobile.ui.designsystem.theme.Shape -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor -import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor -import timber.log.Timber - -@Composable -fun SearchParametersScreen( - resourceManager: ResourceManager, - uiState: SearchParametersUiState, - intentHandler: (FormIntent) -> Unit, - onSimprintsBiometricIdentificationResult: (String, String?, Boolean) -> Unit, - onSimprintsBiometricNoMatches: () -> Unit, - onShowOrgUnit: ( - uid: String, - preselectedOrgUnits: List, - orgUnitScope: OrgUnitSelectorScope, - label: String, - ) -> Unit, - onSearch: () -> Unit, - onClear: () -> Unit, - onClose: () -> Unit, -) { - val snackBarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - val context = LocalContext.current.applicationContext - val configuration = LocalConfiguration.current - var showSimprintsBiometricNoMatchesMessage by rememberSaveable { mutableStateOf(false) } - var pendingSimprintsFieldUid by rememberSaveable { mutableStateOf(null) } - var pendingSimprintsValueTypeName by rememberSaveable { mutableStateOf(null) } - var pendingSimprintsResponseDataJson by rememberSaveable { mutableStateOf(null) } - var pendingSimprintsCapturesSessionId by rememberSaveable { mutableStateOf(false) } - - val simprintsSessionRepository = - remember(context) { - Injector.provideSimprintsSessionRepository(context) - } - val simprintsHasAutoOpenEligibleIdentificationUseCase = - remember { - SimprintsHasAutoOpenEligibleIdentificationUseCase() - } - val simprintsIdentifyLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val uid = pendingSimprintsFieldUid - val valueType = - pendingSimprintsValueTypeName - ?.let(ValueType::valueOf) - val returnedValue = - mapPendingSimprintsSearchResult( - responseDataJson = pendingSimprintsResponseDataJson, - resultCode = result.resultCode, - data = result.data, - capturesSessionId = pendingSimprintsCapturesSessionId, - sessionRepository = simprintsSessionRepository, - ) - - pendingSimprintsFieldUid = null - pendingSimprintsValueTypeName = null - pendingSimprintsResponseDataJson = null - pendingSimprintsCapturesSessionId = false - - val shouldShowNoMatchesMessage = - shouldShowSimprintsBiometricNoMatchesMessage( - resultCode = result.resultCode, - returnedValue = returnedValue, - ) - if (uid != null && result.resultCode == RESULT_OK && returnedValue != null) { - showSimprintsBiometricNoMatchesMessage = false - onSimprintsBiometricIdentificationResult( - uid, - returnedValue, - simprintsHasAutoOpenEligibleIdentificationUseCase(result.data?.extras), - ) - intentHandler( - FormIntent.OnSave( - uid = uid, - value = returnedValue, - valueType = valueType, - ), - ) - } else { - if (shouldShowNoMatchesMessage) { - onSimprintsBiometricNoMatches() - } - showSimprintsBiometricNoMatchesMessage = shouldShowNoMatchesMessage - } - } - - val scanContract = remember { ScanContract() } - val qrScanLauncher = - rememberLauncherForActivityResult( - contract = scanContract, - ) { result -> - result.contents?.let { qrData -> - val intent = - FormIntent.OnQrCodeScanned( - uid = result.originalIntent.getStringExtra(Constants.UID)!!, - value = qrData, - valueType = ValueType.TEXT, - ) - intentHandler(intent) - } - } - - val callback = - remember { - object : FieldUiModel.Callback { - override fun intent(intent: FormIntent) { - intentHandler.invoke(intent) - } - - override fun recyclerViewUiEvents(uiEvent: RecyclerViewUiEvents) { - when (uiEvent) { - is RecyclerViewUiEvents.OpenOrgUnitDialog -> - onShowOrgUnit( - uiEvent.uid, - uiEvent.value?.let { listOf(it) } ?: emptyList(), - uiEvent.orgUnitSelectorScope ?: OrgUnitSelectorScope.UserSearchScope(), - uiEvent.label, - ) - - is RecyclerViewUiEvents.ScanQRCode -> { - qrScanLauncher.launch( - ScanOptions().apply { - setDesiredBarcodeFormats() - setPrompt("") - setBeepEnabled(true) - setBarcodeImageEnabled(false) - addExtra(Constants.UID, uiEvent.uid) - uiEvent.optionSet?.let { - addExtra( - Constants.OPTION_SET, - uiEvent.optionSet, - ) - } - addExtra(Constants.SCAN_RENDERING_TYPE, uiEvent.renderingType) - }, - ) - } - - else -> { - // no-op - } - } - } - } - } - - uiState.minAttributesMessage?.let { message -> - coroutineScope.launch { - uiState.shouldShowMinAttributeWarning.collectLatest { - if (it) { - snackBarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) - uiState.updateMinAttributeWarning(false) - } - } - } - } - - LaunchedEffect(uiState.isOnBackPressed) { - uiState.isOnBackPressed.collectLatest { - if (it) { - showSimprintsBiometricNoMatchesMessage = false - focusManager.clearFocus() - onClose() - } - } - } - - val backgroundShape = - when (configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> - RoundedCornerShape( - topStart = CornerSize(Radius.L), - topEnd = CornerSize(Radius.NoRounding), - bottomEnd = CornerSize(Radius.NoRounding), - bottomStart = CornerSize(Radius.NoRounding), - ) - - else -> Shape.LargeTop - } - - Scaffold( - containerColor = Color.Transparent, - snackbarHost = { - SnackbarHost( - hostState = snackBarHostState, - modifier = - Modifier.padding( - start = 8.dp, - top = 8.dp, - end = 8.dp, - bottom = 48.dp, - ), - ) - }, - ) { paddingValues -> - val layoutDirection = LocalLayoutDirection.current - Column( - modifier = - Modifier - .fillMaxSize() - .background(color = Color.White, shape = backgroundShape) - .padding( - top = 0.dp, - bottom = paddingValues.calculateBottomPadding(), - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - ), - ) { - LazyColumn( - modifier = - Modifier - .weight(1F), - ) { - if (uiState.items.isEmpty()) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - InfoBar( - modifier = Modifier.testTag("EMPTY_SEARCH_ATTRIBUTES_TEXT_TAG"), - text = resourceManager.getString(R.string.empty_search_attributes_message), - icon = { - Icon( - imageVector = Icons.Outlined.ErrorOutline, - contentDescription = "warning", - tint = AdditionalInfoItemColor.WARNING.color, - ) - }, - textColor = AdditionalInfoItemColor.WARNING.color, - backgroundColor = AdditionalInfoItemColor.WARNING.color.copy(alpha = 0.1f), - ) - } - } - } else { - itemsIndexed( - items = uiState.items, - key = { _, fieldUiModel -> - fieldUiModel.uid - }, - ) { index, fieldUiModel -> - fieldUiModel.setCallback(callback) - ParameterSelectorItem( - modifier = - Modifier - .testTag("SEARCH_PARAM_ITEM"), - model = - provideParameterSelectorItem( - resources = resourceManager, - focusManager = focusManager, - fieldUiModel = fieldUiModel, - callback = callback, - onSimprintsBiometricIdentificationLaunch = { - uid, - valueType, - responseDataJson, - capturesSessionId, - launchIntent, - -> - showSimprintsBiometricNoMatchesMessage = false - pendingSimprintsFieldUid = uid - pendingSimprintsValueTypeName = valueType?.name - pendingSimprintsResponseDataJson = responseDataJson - pendingSimprintsCapturesSessionId = capturesSessionId - simprintsIdentifyLauncher.launch(launchIntent) - }, - onNextClicked = { - val nextIndex = index + 1 - if (nextIndex < uiState.items.size) { - uiState.items[nextIndex].onItemClick() - } - }, - ), - ) - } - - if (showSimprintsBiometricNoMatchesMessage) { - item { - Text( - text = resourceManager.getString(R.string.simprints_biometric_search_no_matches), - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), - color = Color.Black.copy(alpha = 0.6f), - ) - } - } - } - - if (uiState.clearSearchEnabled) { - item { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Button( - modifier = Modifier.padding(16.dp, 24.dp, 16.dp, 8.dp), - style = ButtonStyle.TEXT, - text = resourceManager.getString(R.string.clear_search), - icon = { - Icon( - imageVector = Icons.Outlined.Cancel, - contentDescription = resourceManager.getString(R.string.clear_search), - tint = SurfaceColor.Primary, - ) - }, - ) { - focusManager.clearFocus() - showSimprintsBiometricNoMatchesMessage = false - onClear() - } - } - } - } - } - - Button( - enabled = uiState.searchEnabled, - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp, 8.dp, 16.dp, 8.dp) - .testTag("SEARCH_BUTTON"), - style = ButtonStyle.FILLED, - text = resourceManager.getString(R.string.search), - icon = { - val iconTint = - if (uiState.searchEnabled) { - TextColor.OnPrimary - } else { - TextColor.OnDisabledSurface - } - - Icon( - painter = painterResource(id = R.drawable.ic_search), - contentDescription = null, - tint = iconTint, - ) - }, - ) { - showSimprintsBiometricNoMatchesMessage = false - focusManager.clearFocus() - onSearch() - } - } - } -} - -@Preview(showBackground = true) -@Composable -fun SearchFormPreview() { - SearchParametersScreen( - resourceManager = ResourceManager(LocalContext.current, ColorUtils()), - uiState = - SearchParametersUiState( - items = - buildList { - repeat(times = 20) { index -> - add( - FieldUiModelImpl( - uid = "uid$index", - label = "Label $index", - autocompleteList = emptyList(), - optionSetConfiguration = null, - valueType = ValueType.TEXT, - ), - ) - } - }, - ), - intentHandler = {}, - onSimprintsBiometricIdentificationResult = { _, _, _ -> }, - onSimprintsBiometricNoMatches = {}, - onShowOrgUnit = { _, _, _, _ -> }, - onSearch = {}, - onClear = {}, - onClose = {}, - ) -} - -private fun mapPendingSimprintsSearchResult( - responseDataJson: String?, - resultCode: Int, - data: Intent?, - capturesSessionId: Boolean, - sessionRepository: org.dhis2.commons.simprints.repository.SimprintsSessionRepository, -): String? { - if (resultCode != RESULT_OK) { - return null - } - - val responseData = - responseDataJson - ?.let { - try { - Gson().fromJson>( - it, - object : TypeToken>() {}.type, - ) - } catch (e: Exception) { - Timber.e(e, "Failed to parse CustomIntentResponseDataModel") - null - } - } ?: return null - - val returnedValue = - CustomIntentActivityResultContract() - .mapIntentResponseData(responseData, data) - ?.takeUnless(List::isEmpty) - ?.joinToString(separator = ",") ?: return null - - if (capturesSessionId) { - SimprintsIntentUtils.extractSessionId(data?.extras)?.let(sessionRepository::save) - } - - return returnedValue -} - -internal fun shouldShowSimprintsBiometricNoMatchesMessage( - resultCode: Int, - returnedValue: String?, -): Boolean = resultCode == RESULT_OK && returnedValue == null - -@Preview(showBackground = true) -@Composable -fun SearchFormPreviewWithClear() { - SearchParametersScreen( - resourceManager = ResourceManager(LocalContext.current, ColorUtils()), - uiState = - SearchParametersUiState( - items = - buildList { - repeat(times = 20) { index -> - add( - FieldUiModelImpl( - uid = "uid$index", - label = "Label $index", - value = "test value", - autocompleteList = emptyList(), - optionSetConfiguration = null, - valueType = ValueType.TEXT, - ), - ) - } - }, - ), - intentHandler = {}, - onSimprintsBiometricIdentificationResult = { _, _, _ -> }, - onSimprintsBiometricNoMatches = {}, - onShowOrgUnit = { _, _, _, _ -> }, - onSearch = {}, - onClear = {}, - onClose = {}, - ) -} - -fun initSearchScreen( - composeView: ComposeView, - viewModel: SearchTEIViewModel, - program: String?, - teiType: String, - resources: ResourceManager, - onShowOrgUnit: ( - uid: String, - preselectedOrgUnits: List, - orgUnitScope: OrgUnitSelectorScope, - label: String, - ) -> Unit, - onClear: () -> Unit, -) { - viewModel.fetchSearchParameters( - programUid = program, - teiTypeUid = teiType, - ) - composeView.setContent { - SearchParametersScreen( - resourceManager = resources, - uiState = viewModel.searchParametersUiState, - onSearch = viewModel::onSearch, - intentHandler = viewModel::onParameterIntent, - onSimprintsBiometricIdentificationResult = viewModel::onSimprintsBiometricIdentificationResult, - onSimprintsBiometricNoMatches = viewModel::onSimprintsBiometricNoMatches, - onShowOrgUnit = onShowOrgUnit, - onClear = { - onClear() - viewModel.clearQueryData() - viewModel.clearFocus() - }, - onClose = { - viewModel.clearSimprintsBiometricNoMatchesState() - viewModel.clearFocus() - }, - ) - } -} diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt new file mode 100644 index 00000000000..01abf6f2bcf --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt @@ -0,0 +1,302 @@ +package org.dhis2.usescases.searchTrackEntity.searchparameters + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.content.res.Configuration +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import com.journeyapps.barcodescanner.ScanOptions +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.dhis2.commons.Constants +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.form.R +import org.dhis2.form.data.scan.ScanContract +import org.dhis2.form.di.Injector +import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract +import org.dhis2.form.ui.customintent.CustomIntentInput +import org.dhis2.form.ui.customintent.CustomIntentResult +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.extensions.ObserveAsEvents +import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope +import org.dhis2.tracker.input.ui.action.TrackerInputAction +import org.dhis2.tracker.input.ui.action.TrackerInputUiEvent +import org.dhis2.tracker.search.ui.action.SearchScreenUiEvent +import org.dhis2.tracker.search.ui.screen.SearchParametersScreen +import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel +import timber.log.Timber + +fun provideSearchScreen( + composeView: ComposeView, + viewModel: SearchTEIViewModel, + program: String?, + teiType: String, + resources: ResourceManager, + onShowOrgUnit: ( + uid: String, + preselectedOrgUnits: List, + orgUnitScope: OrgUnitSelectorScope, + label: String, + ) -> Unit, + onClear: () -> Unit, +) { + viewModel.fetchSearchParameters( + programUid = program, + teiTypeUid = teiType, + ) + composeView.setContent { + val context = LocalContext.current.applicationContext + var pendingSimprintsFieldUid by rememberSaveable { mutableStateOf(null) } + var pendingSimprintsResponseDataJson by rememberSaveable { mutableStateOf(null) } + var pendingSimprintsCapturesSessionId by rememberSaveable { mutableStateOf(false) } + val simprintsSessionRepository = + remember(context) { + Injector.provideSimprintsSessionRepository(context) + } + val simprintsHasAutoOpenEligibleIdentificationUseCase = + remember { + SimprintsHasAutoOpenEligibleIdentificationUseCase() + } + val customIntentActivityResultContract = remember { CustomIntentActivityResultContract() } + val customIntentLauncher = + rememberLauncherForActivityResult( + contract = customIntentActivityResultContract, + onResult = viewModel::handleCustomIntentResult, + ) + val simprintsIdentifyLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val uid = pendingSimprintsFieldUid + val returnedValue = + mapPendingSimprintsSearchResult( + responseDataJson = pendingSimprintsResponseDataJson, + resultCode = result.resultCode, + data = result.data, + capturesSessionId = pendingSimprintsCapturesSessionId, + sessionRepository = simprintsSessionRepository, + ) + + pendingSimprintsFieldUid = null + pendingSimprintsResponseDataJson = null + pendingSimprintsCapturesSessionId = false + + if (uid != null && result.resultCode == RESULT_OK && returnedValue != null) { + viewModel.onSimprintsBiometricIdentificationResult( + uid = uid, + value = returnedValue, + hasAutoOpenEligibleSimprintsIdentification = + simprintsHasAutoOpenEligibleIdentificationUseCase(result.data?.extras), + ) + viewModel.handleCustomIntentResult( + CustomIntentResult.Success( + fieldUid = uid, + value = returnedValue, + action = null, + extras = result.data?.extras, + ), + ) + } else if (shouldShowSimprintsBiometricNoMatchesMessage(result.resultCode, returnedValue)) { + viewModel.onSimprintsBiometricNoMatches() + } + } + + val scanContract = remember { ScanContract() } + + val qrScanLauncher = + rememberLauncherForActivityResult( + contract = scanContract, + ) { result -> + result.contents?.let { qrData -> + viewModel.handleScanResult( + fieldUid = result.originalIntent.getStringExtra(Constants.UID)!!, + value = qrData, + ) + } + } + + ObserveAsEvents( + flow = viewModel.searchActions, + ) { action -> + when (action) { + is TrackerInputAction.LaunchCustomIntent -> { + if (SimprintsIntentUtils.isIdentifyCallout(action.customIntentModel)) { + val preparedCallout = SimprintsIntentUtils.prepareCallout(action.customIntentModel) + pendingSimprintsFieldUid = action.fieldUid + pendingSimprintsResponseDataJson = Gson().toJson(preparedCallout.responseData) + pendingSimprintsCapturesSessionId = true + try { + simprintsIdentifyLauncher.launch(preparedCallout.launchIntent) + } catch (e: Exception) { + Timber.e(e) + pendingSimprintsFieldUid = null + pendingSimprintsResponseDataJson = null + pendingSimprintsCapturesSessionId = false + viewModel.handleCustomIntentResult( + CustomIntentResult.Error(fieldUid = action.fieldUid), + ) + } + return@ObserveAsEvents + } + + customIntentLauncher.launch( + with(action) { + CustomIntentInput( + fieldUid = fieldUid, + customIntent = customIntentModel, + defaultTitle = + customIntentModel.name + ?: resources.getString(R.string.select_app_intent), + ) + }, + ) + } + + is TrackerInputAction.Scan -> { + with(action) { + qrScanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats() + setPrompt("") + setBeepEnabled(true) + setBarcodeImageEnabled(false) + addExtra(Constants.UID, fieldUid) + optionSet?.let { + addExtra( + Constants.OPTION_SET, + it, + ) + } + addExtra( + Constants.SCAN_RENDERING_TYPE, + renderType, + ) + }, + ) + } + } + + is TrackerInputAction.ValueChanged -> { + viewModel.onValueChange( + fieldUid = action.fieldUid, + value = action.value, + ) + } + } + } + + val configuration = LocalConfiguration.current + + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + SearchParametersScreen( + externalUiState = viewModel.searchParametersUiState, + onSearchScreenUiEvent = { + when (it) { + is SearchScreenUiEvent.OnSearchButtonClicked -> + viewModel.onSearch() + + is SearchScreenUiEvent.OnClearSearchButtonClicked -> { + onClear() + viewModel.clearQueryData() + viewModel.clearFocus() + } + + is SearchScreenUiEvent.OnCloseClicked -> viewModel.clearFocus() + } + }, + isLandscape = isLandscape, + onTrackerInputUiEvent = { + when (it) { + is TrackerInputUiEvent.OnScanButtonClicked -> + viewModel.launchScan( + it.uid, + it.optionSet, + it.renderType, + ) + + is TrackerInputUiEvent.OnOrgUnitButtonClicked -> + onShowOrgUnit( + it.uid, + it.value?.let { listOf(it) } + ?: emptyList(), + it.orgUnitSelectorScope + ?: OrgUnitSelectorScope.UserSearchScope(), + it.label, + ) + + is TrackerInputUiEvent.OnLaunchCustomIntent -> + viewModel.launchCustomIntent( + it.uid, + it.customIntentUid, + ) + + is TrackerInputUiEvent.OnItemClick -> viewModel.onItemClick(it.uid) + + is TrackerInputUiEvent.OnValueChange -> + viewModel.onValueChange( + fieldUid = it.uid, + value = it.value, + ) + } + }, + getOptionSetFlow = { fieldUid, optionSetUid -> + viewModel.getOptionSetFlow(fieldUid, optionSetUid) + }, + onOptionSetSearch = { fieldUid, query -> + viewModel.onOptionSetSearch(fieldUid, query) + }, + ) + } +} + +private fun mapPendingSimprintsSearchResult( + responseDataJson: String?, + resultCode: Int, + data: Intent?, + capturesSessionId: Boolean, + sessionRepository: SimprintsSessionRepository, +): String? { + if (resultCode != RESULT_OK) { + return null + } + + val responseData = + responseDataJson + ?.let { + try { + Gson().fromJson>( + it, + object : TypeToken>() {}.type, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse custom intent response data") + null + } + } ?: return null + + val returnedValue = + CustomIntentActivityResultContract() + .mapIntentResponseData(responseData, data) + ?.takeUnless(List::isEmpty) + ?.joinToString(separator = ",") ?: return null + + if (capturesSessionId) { + SimprintsIntentUtils.extractSessionId(data?.extras)?.let(sessionRepository::save) + } + + return returnedValue +} + +internal fun shouldShowSimprintsBiometricNoMatchesMessage( + resultCode: Int, + returnedValue: String?, +): Boolean = resultCode == RESULT_OK && returnedValue == null diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt deleted file mode 100644 index e83a3dc0ee9..00000000000 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt +++ /dev/null @@ -1,173 +0,0 @@ -package org.dhis2.usescases.searchTrackEntity.searchparameters.provider - -import android.content.Intent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.material.icons.outlined.QrCode2 -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusManager -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import com.google.gson.Gson -import org.dhis2.R -import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.simprints.utils.SimprintsIntentUtils -import org.dhis2.form.di.Injector -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.UiRenderType -import org.dhis2.form.simprints.rememberSimprintsCustomIntentFormPresenter -import org.dhis2.form.ui.event.RecyclerViewUiEvents -import org.dhis2.form.ui.provider.inputfield.FieldProvider -import org.hisp.dhis.android.core.common.ValueType -import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle -import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel -import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor - -@Composable -fun provideParameterSelectorItem( - resources: ResourceManager, - focusManager: FocusManager, - fieldUiModel: FieldUiModel, - callback: FieldUiModel.Callback, - onNextClicked: () -> Unit, - onSimprintsBiometricIdentificationLaunch: (String, ValueType?, String?, Boolean, Intent) -> Unit, -): ParameterSelectorItemModel { - val focusRequester = remember { FocusRequester() } - val context = LocalContext.current.applicationContext - val simprintsSessionRepository = - remember(context) { - Injector.provideSimprintsSessionRepository(context) - } - val simprintsCustomIntentFormPresenter = - rememberSimprintsCustomIntentFormPresenter( - fieldUiModel = fieldUiModel, - resources = resources, - sessionRepository = simprintsSessionRepository, - ) - - val status = - if (fieldUiModel.focused) { - ParameterSelectorItemModel.Status.FOCUSED - } else if (fieldUiModel.value.isNullOrEmpty()) { - ParameterSelectorItemModel.Status.CLOSED - } else { - ParameterSelectorItemModel.Status.UNFOCUSED - } - - LaunchedEffect(key1 = status) { - if (status == ParameterSelectorItemModel.Status.FOCUSED) { - focusRequester.requestFocus() - } - } - - return ParameterSelectorItemModel( - icon = { ProvideIcon(fieldUiModel.valueType, fieldUiModel.renderingType) }, - label = fieldUiModel.label, - helper = resources.getString(R.string.optional), - inputField = { - FieldProvider( - modifier = - Modifier - .focusRequester(focusRequester), - inputStyle = InputStyle.ParameterInputStyle(), - fieldUiModel = fieldUiModel, - uiEventHandler = callback::recyclerViewUiEvents, - intentHandler = callback::intent, - resources = resources, - focusManager = focusManager, - onNextClicked = onNextClicked, - onFileSelected = { - // Not supported for search - }, - reEvaluateCustomIntentRequestParameters = false, - ) - }, - status = status, - onExpand = { - if (SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent)) { - if (!simprintsCustomIntentFormPresenter.hasPendingValue) { - simprintsCustomIntentFormPresenter.prepareLaunch() - simprintsCustomIntentFormPresenter - .createLaunchIntent() - ?.let { launchIntent -> - onSimprintsBiometricIdentificationLaunch( - fieldUiModel.uid, - fieldUiModel.valueType, - fieldUiModel.customIntent?.customIntentResponse?.let(Gson()::toJson), - SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent), - launchIntent, - ) - } - } - return@ParameterSelectorItemModel - } - performOnExpandActions(fieldUiModel, callback) - }, - ) -} - -private fun performOnExpandActions( - fieldUiModel: FieldUiModel, - callback: FieldUiModel.Callback, -) { - fieldUiModel.onItemClick() - - if (fieldUiModel.renderingType == UiRenderType.QR_CODE || - fieldUiModel.renderingType == UiRenderType.BAR_CODE - ) { - callback.recyclerViewUiEvents( - RecyclerViewUiEvents.ScanQRCode( - uid = fieldUiModel.uid, - optionSet = fieldUiModel.optionSet, - renderingType = fieldUiModel.renderingType, - ), - ) - } -} - -@Composable -private fun ProvideIcon( - valueType: ValueType?, - renderingType: UiRenderType?, -) = when (valueType) { - ValueType.TEXT -> { - when (renderingType) { - UiRenderType.QR_CODE, UiRenderType.GS1_DATAMATRIX -> { - Icon( - imageVector = Icons.Outlined.QrCode2, - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) - } - - UiRenderType.BAR_CODE -> { - Icon( - painter = provideDHIS2Icon("material_barcode_scanner"), - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) - } - - else -> { - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) - } - } - } - - else -> - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = "Icon Button", - tint = SurfaceColor.Primary, - ) -} diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt index 56eda9bcf90..858eed924ae 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 @@ -48,6 +48,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.toLowerCase +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -159,7 +160,8 @@ fun AddNewButton( modifier = Modifier .requiredHeight(44.dp) - .then(modifier), + .then(modifier) + .testTag("ADD_NEW_BUTTON"), colors = ButtonDefaults.buttonColors( containerColor = SurfaceColor.PrimaryContainer, @@ -686,7 +688,7 @@ fun CreateNewButton( ) { if (it) { ExtendedFAB( - modifier = modifier, + modifier = modifier.testTag("ADD_NEW_BUTTON"), onClick = onClick, text = stringResource( if (isSimprintsUseLastBiometricsLabel) { @@ -701,7 +703,7 @@ fun CreateNewButton( ) } else { FAB( - modifier = modifier, + modifier = modifier.testTag("ADD_NEW_BUTTON"), onClick = onClick, icon = icon, style = FABStyle.SECONDARY, diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt index 9f62422136f..f3a39d95a6c 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SimprintsSearchTeUiComponents.kt @@ -71,4 +71,3 @@ fun SimprintsNoneOfTheAboveButton( style = FABStyle.SECONDARY, ) } - 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 0ff1b4721e9..c5d520c6d0b 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,17 +17,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import org.dhis2.R -import org.dhis2.commons.bindings.hasFollowUp import org.dhis2.commons.bindings.isFilePathValid 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.tracker.search.model.DomainEnrollment +import org.dhis2.tracker.search.model.DomainProgram +import org.dhis2.tracker.search.model.EnrollmentStatus +import org.dhis2.tracker.search.model.SyncState import org.dhis2.usescases.searchTrackEntity.SearchTeiModel -import org.hisp.dhis.android.core.common.State -import org.hisp.dhis.android.core.enrollment.Enrollment -import org.hisp.dhis.android.core.enrollment.EnrollmentStatus -import org.hisp.dhis.android.core.program.Program +import org.dhis2.usescases.searchTrackEntity.adapters.hasFollowUp import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItemColor import org.hisp.dhis.mobile.ui.designsystem.component.Avatar @@ -53,7 +54,7 @@ class TEICardMapper( ListCardUiModel( avatar = { ProvideAvatar(searchTEIModel, onImageClick) }, title = getTitle(searchTEIModel), - lastUpdated = searchTEIModel.tei.lastUpdated().toDateSpan(context), + lastUpdated = searchTEIModel.tei.lastUpdated?.toJavaDate().toDateSpan(context), additionalInfo = getAdditionalInfoList(searchTEIModel), actionButton = { ProvideSyncButton(searchTEIModel, onSyncIconClick) }, expandLabelText = resourceManager.getString(R.string.show_more), @@ -68,7 +69,7 @@ class TEICardMapper( ) { val programUid: String? = if (item.selectedEnrollment != null) { - item.selectedEnrollment.program().toString() + item.selectedEnrollment.program } else { null } @@ -103,7 +104,7 @@ class TEICardMapper( item.header?.firstOrNull() ?: item.attributeValues.values .firstOrNull() - ?.value() + ?.value ?.firstOrNull() return when (firstLetter) { @@ -119,17 +120,17 @@ class TEICardMapper( val value = item.attributeValues.values .firstOrNull() - ?.value() ?: "-" + ?.value ?: "-" "$key: $value" } private fun getAdditionalInfoList(searchTEIModel: SearchTeiModel): List { val attributeList = - searchTEIModel.attributeValues + searchTEIModel.tei.attributeValues .map { AdditionalInfoItem( - key = it.key, - value = it.value.value() ?: "-", + key = it.displayName, + value = it.value ?: "-", ) }.toMutableList() @@ -139,82 +140,84 @@ class TEICardMapper( attributeList.removeIf { it.value.isEmpty() || it.value == "-" } return attributeList.also { list -> - if ( - !searchTEIModel.ownerOrgUnit.isNullOrEmpty() && - searchTEIModel.ownerOrgUnit != searchTEIModel.enrolledOrgUnit - ) { - addOwnedBy( - list = list, - ownerOrgUnit = searchTEIModel.ownerOrgUnit, - ) + searchTEIModel.tei.ownerOrgUnit?.let { + if (it != searchTEIModel.tei.enrollmentOrgUnit) { + addOwnedBy( + list = list, + ownerOrgUnit = it, + ) + } } - if (searchTEIModel.displayOrgUnit) { + if (searchTEIModel.tei.shouldDisplayOrgUnit) { checkEnrolledIn( list = list, - enrolledOrgUnit = searchTEIModel.enrolledOrgUnit, + enrolledOrgUnit = searchTEIModel.tei.enrollmentOrgUnit, ) } checkEnrolledPrograms( list = list, - enrolledPrograms = searchTEIModel.programInfo, + enrolledPrograms = searchTEIModel.tei.enrolledPrograms, ) val programUid: String? = if (searchTEIModel.selectedEnrollment != null) { - searchTEIModel.selectedEnrollment.program().toString() + searchTEIModel.selectedEnrollment.program } else { null } checkEnrollmentStatus( programUid = programUid, list = list, - status = searchTEIModel.selectedEnrollment?.status(), + status = searchTEIModel.selectedEnrollment?.status?: EnrollmentStatus.ACTIVE, ) checkOverdue( list = list, - hasOverdue = searchTEIModel.isHasOverdue, - overdueDate = searchTEIModel.overdueDate, + hasOverdue = searchTEIModel.tei.overDueDate != null, + overdueDate = searchTEIModel.tei.overDueDate?.toJavaDate(), ) checkFollowUp( list = list, - enrollments = searchTEIModel.enrollments, + enrollments = searchTEIModel.tei.enrollments, ) checkSyncStatus( list = list, - state = searchTEIModel.tei.aggregatedSyncState(), + state = searchTEIModel.tei.syncState, ) } } private fun checkFollowUp( list: MutableList, - enrollments: List, + enrollments: List?, ) { - if (enrollments.hasFollowUp()) { - list.add( - AdditionalInfoItem( - icon = { - Icon( - imageVector = Icons.Outlined.Flag, - contentDescription = resourceManager.getString(R.string.marked_follow_up), - tint = AdditionalInfoItemColor.WARNING.color, - ) - }, - value = resourceManager.getString(R.string.marked_follow_up), - isConstantItem = true, - color = AdditionalInfoItemColor.WARNING.color, - ), - ) + enrollments?.let { + if (enrollments.hasFollowUp()) { + list.add( + AdditionalInfoItem( + icon = { + Icon( + imageVector = Icons.Outlined.Flag, + contentDescription = resourceManager.getString(R.string.marked_follow_up), + tint = AdditionalInfoItemColor.WARNING.color, + ) + }, + value = resourceManager.getString(R.string.marked_follow_up), + isConstantItem = true, + color = AdditionalInfoItemColor.WARNING.color, + ), + ) + } } + } private fun checkEnrollmentStatus( programUid: String?, list: MutableList, - status: EnrollmentStatus?, + status: EnrollmentStatus, ) { val item = when (status) { @@ -268,11 +271,11 @@ class TEICardMapper( private fun checkEnrolledPrograms( list: MutableList, - enrolledPrograms: List, + enrolledPrograms: List?, ) { - val programNames = enrolledPrograms.map { it.name() } + val programNames = enrolledPrograms?.map { it.displayName } - if (programNames.isNotEmpty()) { + programNames?.let { list.add( AdditionalInfoItem( key = resourceManager.getString(R.string.programs), @@ -285,15 +288,18 @@ class TEICardMapper( private fun checkEnrolledIn( list: MutableList, - enrolledOrgUnit: String, + enrolledOrgUnit: String?, ) { - list.add( - AdditionalInfoItem( - key = resourceManager.getString(R.string.enrolledIn), - value = enrolledOrgUnit, - isConstantItem = true, - ), - ) + enrolledOrgUnit?.let { + list.add( + AdditionalInfoItem( + key = resourceManager.getString(R.string.enrolledIn), + value = it, + isConstantItem = true, + ), + ) + } + } private fun addOwnedBy( @@ -315,16 +321,16 @@ class TEICardMapper( onSyncIconClick: () -> Unit, ) { val buttonText = - when (searchTEIModel.tei.aggregatedSyncState()) { - State.TO_POST, - State.TO_UPDATE, - -> { + when (searchTEIModel.tei.syncState) { + SyncState.TO_POST, + SyncState.TO_UPDATE, + -> { resourceManager.getString(R.string.sync) } - State.ERROR, - State.WARNING, - -> { + SyncState.ERROR, + SyncState.WARNING, + -> { resourceManager.getString(R.string.sync_retry) } @@ -373,13 +379,13 @@ class TEICardMapper( private fun checkSyncStatus( list: MutableList, - state: State?, + state: SyncState?, ) { val item = when (state) { - State.TO_POST, - State.TO_UPDATE, - -> { + SyncState.TO_POST, + SyncState.TO_UPDATE, + -> { AdditionalInfoItem( icon = { Icon( @@ -394,7 +400,7 @@ class TEICardMapper( ) } - State.UPLOADING -> { + SyncState.UPLOADING -> { AdditionalInfoItem( icon = { Icon( @@ -409,7 +415,7 @@ class TEICardMapper( ) } - State.ERROR -> { + SyncState.ERROR -> { AdditionalInfoItem( icon = { Icon( @@ -424,7 +430,7 @@ class TEICardMapper( ) } - State.WARNING -> { + SyncState.WARNING -> { AdditionalInfoItem( icon = { Icon( diff --git a/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt b/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt index 42f9f2f0762..1d8bbcf8856 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/DeleteUserData.kt @@ -1,17 +1,22 @@ package org.dhis2.usescases.settings +import kotlinx.coroutines.withContext import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.workManager.WorkManagerController import java.io.File -class DeleteUserData( +class DeleteUserData( private val workManagerController: WorkManagerController, private val filterManager: FilterManager, private val preferencesProvider: PreferenceProvider, + private val dispatcherProvider: DispatcherProvider, ) { - fun wipeCacheAndPreferences(file: File?) { - filterManager.clearAllFilters() + suspend fun wipeCacheAndPreferences(file: File?) { + withContext(dispatcherProvider.ui()) { + filterManager.clearAllFilters() + } workManagerController.cancelAllWork() workManagerController.pruneWork() if (file != null) { diff --git a/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt b/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt index 6c620134340..e3206c93de6 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SettingsRepository.kt @@ -1,5 +1,6 @@ package org.dhis2.usescases.settings +import android.text.format.DateFormat import io.reactivex.Single import org.dhis2.BuildConfig import org.dhis2.bindings.toSeconds @@ -15,6 +16,7 @@ import org.dhis2.commons.prefs.Preference.Companion.TIME_DAILY import org.dhis2.commons.prefs.Preference.Companion.TIME_WEEKLY import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.service.SyncResult +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.settings.models.DataSettingsViewModel import org.dhis2.usescases.settings.models.MetadataSettingsViewModel import org.dhis2.usescases.settings.models.ReservedValueSettingsViewModel @@ -29,10 +31,13 @@ import org.hisp.dhis.android.core.settings.SynchronizationSettings import org.hisp.dhis.android.core.sms.domain.interactor.ConfigCase import timber.log.Timber +private const val dateTimeFormat = "dd/MM/yyyy HH:mm" + class SettingsRepository( val d2: D2, val prefs: PreferenceProvider, val featureConfigRepository: FeatureConfigRepository, + private val syncBackgroundJobAction: SyncBackgroundJobAction, ) { private val syncSettings: SynchronizationSettings? get() = @@ -49,12 +54,7 @@ class SettingsRepository( null } private val programSettings: ProgramSettings? - get() = - if (d2.settingModule().programSetting().blockingExists()) { - d2.settingModule().programSetting().blockingGet() - } else { - null - } + get() = syncSettings?.programSettings() private val smsConfig: ConfigCase.SmsConfig get() = d2 @@ -68,6 +68,9 @@ class SettingsRepository( DataSettingsViewModel( dataSyncPeriod = dataPeriod(), lastDataSync = prefs.getString(Constants.LAST_DATA_SYNC, "-")!!, + nextDataSync = syncBackgroundJobAction.getNextDataSync()?.let { + DateFormat.format(dateTimeFormat, it).toString() + }, syncHasErrors = !prefs.getBoolean(Constants.LAST_DATA_SYNC_STATUS, true), dataHasErrors = dataHasErrors(), dataHasWarnings = dataHasWarning(), @@ -85,6 +88,12 @@ class SettingsRepository( MetadataSettingsViewModel( metadataSyncPeriod = metadataPeriod(), lastMetadataSync = prefs.getString(Constants.LAST_META_SYNC, "-")!!, + nextMetadataSync = syncBackgroundJobAction.getNextMetadataSync()?.let { + DateFormat.format(dateTimeFormat, it).toString() + }, + nextSettingsSync = syncBackgroundJobAction.getNextSettingsSync()?.let { + DateFormat.format(dateTimeFormat, it).toString() + }, hasErrors = !prefs.getBoolean(Constants.LAST_META_SYNC_STATUS, true), canEdit = syncSettings?.metadataSync() == null, syncInProgress = false, @@ -179,13 +188,13 @@ class SettingsRepository( .isNotEmpty() private fun metadataPeriod(): Int = - generalSettings?.metadataSync()?.toSeconds() ?: prefs.getInt( + syncSettings?.metadataSync()?.toSeconds() ?: prefs.getInt( Preference.TIME_META, TIME_WEEKLY, ) private fun dataPeriod(): Int = - generalSettings?.dataSync()?.toSeconds() ?: prefs.getInt( + syncSettings?.dataSync()?.toSeconds() ?: prefs.getInt( Preference.TIME_DATA, TIME_DAILY, ) @@ -231,14 +240,11 @@ class SettingsRepository( private fun getLimitedScopeFromPreferences(): LimitScope { val byOrgUnit = prefs.getBoolean(Constants.LIMIT_BY_ORG_UNIT, false) val byProgram = prefs.getBoolean(Constants.LIMIT_BY_PROGRAM, false) - return if (byOrgUnit && !byProgram) { - LimitScope.PER_ORG_UNIT - } else if (!byOrgUnit && byProgram) { - LimitScope.PER_PROGRAM - } else if (byOrgUnit && byProgram) { - LimitScope.PER_OU_AND_PROGRAM - } else { - LimitScope.GLOBAL + return when { + byOrgUnit && byProgram -> LimitScope.PER_OU_AND_PROGRAM + byOrgUnit && !byProgram -> LimitScope.PER_ORG_UNIT + !byOrgUnit && byProgram -> LimitScope.PER_PROGRAM + else -> LimitScope.GLOBAL } } @@ -253,6 +259,7 @@ class SettingsRepository( suspend fun saveLimitScope(limitScope: LimitScope) { when (limitScope) { LimitScope.ALL_ORG_UNITS -> { + // Do nothing } LimitScope.GLOBAL -> { @@ -289,7 +296,7 @@ class SettingsRepository( .setGatewayNumber(gatewayNumber) .blockingAwait() } catch (e: Exception) { - Timber.d(e.message) + Timber.d(e) } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt index e744c147084..77922793602 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerFragment.kt @@ -17,12 +17,14 @@ import org.dhis2.commons.data.FormFileProvider import org.dhis2.commons.data.FormFileProvider.init import org.dhis2.commons.resources.ColorUtils import org.dhis2.mobile.login.authentication.TwoFASettingsActivity +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.general.FragmentGlobalAbstract import org.dhis2.usescases.reservedValue.ReservedValueActivity import org.dhis2.usescases.settings.models.ErrorViewModel import org.dhis2.usescases.settings.ui.SettingsScreen import org.dhis2.usescases.settingsprogram.SettingsProgramActivity import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme +import org.koin.android.ext.android.inject import timber.log.Timber import java.io.File import javax.inject.Inject @@ -32,6 +34,7 @@ class SyncManagerFragment : FragmentGlobalAbstract() { lateinit var settingsViewModelFactory: SettingsViewModelFactory private val presenter: SyncManagerPresenter by viewModels { settingsViewModelFactory } + private val syncBackgroundJobAction: SyncBackgroundJobAction by inject() @JvmField @Inject @@ -41,7 +44,7 @@ class SyncManagerFragment : FragmentGlobalAbstract() { super.onAttach(context) app() .userComponent() - ?.plus(SyncManagerModule()) + ?.plus(SyncManagerModule(syncBackgroundJobAction)) ?.inject(this) } diff --git a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt index 2ca357252eb..c1819f82b8e 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerModule.kt @@ -10,8 +10,8 @@ import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.VersionRepository -import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.mobile.commons.files.FileHandlerImpl +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.settings.domain.CheckVersionUpdate import org.dhis2.usescases.settings.domain.DeleteLocalData import org.dhis2.usescases.settings.domain.ExportDatabase @@ -27,7 +27,9 @@ import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @Module -class SyncManagerModule { +class SyncManagerModule( + private val syncBackgroundJobAction: SyncBackgroundJobAction, +) { @Provides @PerFragment fun provideViewModelFactory( @@ -150,11 +152,10 @@ class SyncManagerModule { @Provides @PerFragment fun provideLaunchSync( - workManagerController: WorkManagerController, preferenceProvider: PreferenceProvider, analyticsHelper: AnalyticsHelper, ) = LaunchSync( - workManagerController, + syncBackgroundJobAction, preferenceProvider, analyticsHelper, ) @@ -170,6 +171,7 @@ class SyncManagerModule { d2, preferenceProvider, featureConfigRepository, + syncBackgroundJobAction, ) @Provides diff --git a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt index ae5c98a3f97..d33719c18ed 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/SyncManagerPresenter.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.dhis2.commons.Constants import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.usescases.settings.domain.CheckVersionUpdate @@ -28,6 +27,7 @@ import org.dhis2.usescases.settings.domain.UpdateSyncSettings import org.dhis2.usescases.settings.models.DeleteDataState import org.dhis2.usescases.settings.models.ErrorViewModel import org.dhis2.usescases.settings.models.SettingsState +import org.dhis2.usescases.settings.models.SyncStateInput import org.hisp.dhis.android.core.settings.LimitScope import java.io.File @@ -114,14 +114,21 @@ class SyncManagerPresenter( } private suspend fun loadData() { - val settingsState = - getSettingsState( + getSettingsState( + SyncStateInput( openedItem = _settingsState.value?.openedItem, hasConnection = _settingsState.value?.hasConnection == true, metadataSyncInProgress = syncWorkInfo.value.metadataSyncProgress == LaunchSync.SyncStatus.InProgress, dataSyncInProgress = syncWorkInfo.value.dataSyncProgress == LaunchSync.SyncStatus.InProgress, - ) - _settingsState.update { settingsState } + ), + ).fold( + onSuccess = { settingsState -> + _settingsState.update { settingsState } + }, + onFailure = { + // do nothing + }, + ) } fun onItemClick(settingsItem: SettingItem) { @@ -361,18 +368,14 @@ class SyncManagerPresenter( fun onSyncDataPeriodChanged(period: Int) { viewModelScope.launch(dispatcherProvider.io()) { launchSync(LaunchSync.SyncAction.UpdateSyncDataPeriod(period)) - if (period == Constants.TIME_MANUAL) { - loadData() - } + loadData() } } fun onSyncMetaPeriodChanged(period: Int) { viewModelScope.launch(dispatcherProvider.io()) { launchSync(LaunchSync.SyncAction.UpdateSyncMetadataPeriod(period)) - if (period == Constants.TIME_MANUAL) { - loadData() - } + loadData() } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt b/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt index 106706ebb74..cb5f700318f 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/domain/GetSettingsState.kt @@ -1,40 +1,42 @@ package org.dhis2.usescases.settings.domain +import org.dhis2.mobile.commons.domain.UseCase import org.dhis2.usescases.settings.GatewayValidator -import org.dhis2.usescases.settings.SettingItem import org.dhis2.usescases.settings.SettingsRepository import org.dhis2.usescases.settings.models.SettingsState +import org.dhis2.usescases.settings.models.SyncStateInput class GetSettingsState( private val settingsRepository: SettingsRepository, private val gatewayValidator: GatewayValidator, -) { - suspend operator fun invoke( - openedItem: SettingItem?, - hasConnection: Boolean, - metadataSyncInProgress: Boolean, - dataSyncInProgress: Boolean, - ): SettingsState = - SettingsState( - openedItem = openedItem, - hasConnection = hasConnection, - metadataSettingsViewModel = - settingsRepository.metaSync().blockingGet().copy( - syncInProgress = metadataSyncInProgress, - ), - dataSettingsViewModel = - settingsRepository.dataSync().blockingGet().copy( - syncInProgress = dataSyncInProgress, - ), - syncParametersViewModel = settingsRepository.syncParameters().blockingGet(), - reservedValueSettingsViewModel = settingsRepository.reservedValues().blockingGet(), - smsSettingsViewModel = - with(settingsRepository.sms().blockingGet()) { - copy( - gatewayValidationResult = gatewayValidator(this.gatewayNumber), - ) - }, - isTwoFAConfigured = settingsRepository.isTwoFAConfigured(), - versionName = settingsRepository.getVersionName(), - ) +) : UseCase { + override suspend fun invoke(input: SyncStateInput) = + try { + val state = + SettingsState( + openedItem = input.openedItem, + hasConnection = input.hasConnection, + metadataSettingsViewModel = + settingsRepository.metaSync().blockingGet().copy( + syncInProgress = input.metadataSyncInProgress, + ), + dataSettingsViewModel = + settingsRepository.dataSync().blockingGet().copy( + syncInProgress = input.dataSyncInProgress, + ), + syncParametersViewModel = settingsRepository.syncParameters().blockingGet(), + reservedValueSettingsViewModel = settingsRepository.reservedValues().blockingGet(), + smsSettingsViewModel = + with(settingsRepository.sms().blockingGet()) { + copy( + gatewayValidationResult = gatewayValidator(this.gatewayNumber), + ) + }, + isTwoFAConfigured = settingsRepository.isTwoFAConfigured(), + versionName = settingsRepository.getVersionName(), + ) + Result.success(state) + } catch (e: Exception) { + Result.failure(e) + } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt b/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt index 11b0e0286c3..20d213fb8c1 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/domain/LaunchSync.kt @@ -1,27 +1,25 @@ package org.dhis2.usescases.settings.domain -import androidx.lifecycle.asFlow -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.WorkInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import org.dhis2.commons.Constants import org.dhis2.commons.matomo.Actions import org.dhis2.commons.matomo.Categories import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem -import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.commons.providers.TIME_DATA +import org.dhis2.mobile.commons.providers.TIME_META +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.CLICK import org.dhis2.utils.analytics.SYNC_DATA_NOW import org.dhis2.utils.analytics.SYNC_METADATA_NOW +import org.dhis2.mobile.sync.model.SyncStatus as Status class LaunchSync( - private val workManagerController: WorkManagerController, + private val syncBackgroundJobAction: SyncBackgroundJobAction, private val preferenceProvider: PreferenceProvider, private val analyticsHelper: AnalyticsHelper, ) { @@ -34,21 +32,19 @@ class LaunchSync( ) private val metadataWorkInfo = - workManagerController - .getWorkInfosByTagLiveData(Constants.META_NOW) - .asFlow() + syncBackgroundJobAction + .observeMetadataJob() .map { workStatuses -> - var workState: WorkInfo.State? = workStatuses.getOrNull(0)?.state - onWorkStatusesUpdate(workState, Constants.META_NOW) + val currentSyncStatus = combinedStatus(workStatuses) + syncStatus.updateAndGet { it.copy(metadataSyncProgress = currentSyncStatus) } } private val dataWorkInfo = - workManagerController - .getWorkInfosByTagLiveData(Constants.DATA_NOW) - .asFlow() + syncBackgroundJobAction + .observeDataJob() .map { workStatuses -> - var workState: WorkInfo.State? = workStatuses.getOrNull(0)?.state - onWorkStatusesUpdate(workState, Constants.DATA_NOW) + val currentSyncStatus = combinedStatus(workStatuses) + syncStatus.updateAndGet { it.copy(dataSyncProgress = currentSyncStatus) } } val syncWorkInfo = merge(metadataWorkInfo, dataWorkInfo) @@ -85,9 +81,9 @@ class LaunchSync( metadataWasRunning: Boolean, dataWasRunning: Boolean, ) = metadataSyncProgress == SyncStatus.Finished && - metadataWasRunning || - dataSyncProgress == SyncStatus.Finished && - dataWasRunning + metadataWasRunning || + dataSyncProgress == SyncStatus.Finished && + dataWasRunning } suspend operator fun invoke(syncAction: SyncAction) { @@ -99,131 +95,50 @@ class LaunchSync( } } - private fun onWorkStatusesUpdate( - workState: WorkInfo.State?, - workerTag: String, - ): SyncStatusProgress { - if (workState != null) { - when (workState) { - WorkInfo.State.CANCELLED -> - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.Cancelled) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.Cancelled) } - else -> syncStatus - } - - WorkInfo.State.ENQUEUED, - WorkInfo.State.RUNNING, - WorkInfo.State.BLOCKED, - -> - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.InProgress) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.InProgress) } - else -> syncStatus - } - - else -> - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.Finished) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.Finished) } - else -> syncStatus - } - } - } else { - when (workerTag) { - Constants.META_NOW -> syncStatus.update { it.copy(metadataSyncProgress = SyncStatus.Finished) } - Constants.DATA_NOW -> syncStatus.update { it.copy(dataSyncProgress = SyncStatus.Finished) } - else -> syncStatus - } - } - - return syncStatus.value - } - private fun syncData() { analyticsHelper.trackMatomoEvent(Categories.SETTINGS, Actions.SYNC_CONFIG, CLICK) analyticsHelper.setEvent(SYNC_DATA_NOW, CLICK, SYNC_DATA_NOW) - val workerItem = - WorkerItem( - Constants.DATA_NOW, - WorkerType.DATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) - workManagerController.syncDataForWorker(workerItem) + syncBackgroundJobAction.launchDataSync(0) } private fun syncMeta() { analyticsHelper.setEvent(SYNC_METADATA_NOW, CLICK, SYNC_METADATA_NOW) - val workerItem = - WorkerItem( - Constants.META_NOW, - WorkerType.METADATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) - workManagerController.syncDataForWorker(workerItem) + syncBackgroundJobAction.launchMetadataSync(0) } - private fun updateSyncDataPeriod(seconds: Int) { + private suspend fun updateSyncDataPeriod(seconds: Int) { if (seconds != Constants.TIME_MANUAL) { syncData(seconds) } else { - cancelPendingWork(Constants.DATA) + preferenceProvider.setValue(TIME_DATA, 0) + syncBackgroundJobAction.cancelDataSync() } } - private fun updateSyncMetadataPeriod(seconds: Int) { + private suspend fun updateSyncMetadataPeriod(seconds: Int) { if (seconds != Constants.TIME_MANUAL) { syncMeta(seconds) } else { - cancelPendingWork(Constants.META) + preferenceProvider.setValue(TIME_META, 0) + syncBackgroundJobAction.cancelMetadataSync() } } private fun syncMeta(seconds: Int) { analyticsHelper.trackMatomoEvent(Categories.SETTINGS, Actions.SYNC_DATA, CLICK) - preferenceProvider.setValue(Constants.TIME_META, seconds) - workManagerController.cancelUniqueWork(Constants.META) - val workerItem = - WorkerItem( - Constants.META, - WorkerType.METADATA, - seconds.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) - workManagerController.enqueuePeriodicWork(workerItem) + preferenceProvider.setValue(TIME_META, seconds) + syncBackgroundJobAction.launchMetadataSync(seconds.toLong()) } private fun syncData(seconds: Int) { - preferenceProvider.setValue(Constants.TIME_DATA, seconds) - workManagerController.cancelUniqueWork(Constants.DATA) - val workerItem = - WorkerItem( - Constants.DATA, - WorkerType.DATA, - seconds.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) - workManagerController.enqueuePeriodicWork(workerItem) + preferenceProvider.setValue(TIME_DATA, seconds) + syncBackgroundJobAction.launchDataSync(seconds.toLong()) } - private fun cancelPendingWork(tag: String) { - preferenceProvider.setValue( - when (tag) { - Constants.DATA -> Constants.TIME_DATA - else -> Constants.TIME_META - }, - 0, - ) - workManagerController.cancelUniqueWork(tag) + private fun combinedStatus(workStatuses: List) = when { + workStatuses.any { (it.status is Status.Running) or (it.status is Status.Blocked) } -> SyncStatus.InProgress + workStatuses.all { it.status is Status.Enqueue } -> SyncStatus.None + workStatuses.all { it.status is Status.Cancelled } -> SyncStatus.Cancelled + else -> SyncStatus.Finished } } diff --git a/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt b/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt index 2b33dc87b92..b5da929d972 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/models/DataSettingsViewModel.kt @@ -5,6 +5,7 @@ import org.dhis2.data.service.SyncResult data class DataSettingsViewModel( val dataSyncPeriod: Int, val lastDataSync: String, + val nextDataSync: String?, val syncHasErrors: Boolean, val dataHasErrors: Boolean, val dataHasWarnings: Boolean, diff --git a/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt b/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt index ab89e43b8ee..ebe42e80c1e 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/models/MetadataSettingsViewModel.kt @@ -3,6 +3,8 @@ package org.dhis2.usescases.settings.models data class MetadataSettingsViewModel( val metadataSyncPeriod: Int, val lastMetadataSync: String, + val nextMetadataSync: String?, + val nextSettingsSync: String?, val hasErrors: Boolean, val canEdit: Boolean, val syncInProgress: Boolean, diff --git a/app/src/main/java/org/dhis2/usescases/settings/models/SyncStateInput.kt b/app/src/main/java/org/dhis2/usescases/settings/models/SyncStateInput.kt new file mode 100644 index 00000000000..e9a6d045b28 --- /dev/null +++ b/app/src/main/java/org/dhis2/usescases/settings/models/SyncStateInput.kt @@ -0,0 +1,10 @@ +package org.dhis2.usescases.settings.models + +import org.dhis2.usescases.settings.SettingItem + +data class SyncStateInput( + val openedItem: SettingItem?, + val hasConnection: Boolean, + val metadataSyncInProgress: Boolean, + val dataSyncInProgress: Boolean, +) diff --git a/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt b/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt index 2feb5e357de..92338a93be1 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/ui/SettingsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDefaults @@ -22,7 +21,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import org.dhis2.commons.resources.ColorUtils import org.dhis2.usescases.settings.SettingItem import org.dhis2.usescases.settings.SyncManagerPresenter import org.dhis2.usescases.settings.models.DeleteDataState @@ -30,8 +31,6 @@ import org.dhis2.usescases.settings.models.ErrorViewModel import org.dhis2.usescases.settings.models.SettingsState import org.dhis2.usescases.settings.models.SettingsUiAction import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor -import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import org.hisp.dhis.mobile.ui.designsystem.theme.dropShadow import java.io.File @@ -75,8 +74,6 @@ fun SettingsScreen( Snackbar( modifier = Modifier.dropShadow(shape = SnackbarDefaults.shape), snackbarData = data, - containerColor = SurfaceColor.SurfaceBright, - contentColor = TextColor.OnSurface, ) } }, @@ -172,12 +169,17 @@ private fun SettingItemList( exportingDatabase: Boolean, onSettingsUiAction: (SettingsUiAction) -> Unit, ) { + val context = LocalContext.current + val primaryColor = remember(context) { + ColorUtils().getThemePrimaryColor(context) + } + LazyColumn( modifier = modifier .fillMaxSize() .imePadding() - .background(MaterialTheme.colorScheme.primary) + .background(primaryColor) .background(Color.White, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), contentPadding = PaddingValues(8.dp), diff --git a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt index b1711f42252..b3851633d18 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncDataSettingItem.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.intl.Locale +import org.dhis2.BuildConfig import org.dhis2.R import org.dhis2.bindings.EVERY_12_HOUR import org.dhis2.bindings.EVERY_24_HOUR @@ -76,7 +77,12 @@ internal fun SyncDataSettingItem( } else -> { - provideDefaultInfoItems(dataSettings.dataSyncPeriod, dataSettings.lastDataSync, context) + provideDefaultInfoItems( + dataSettings.dataSyncPeriod, + dataSettings.lastDataSync, + dataSettings.nextDataSync, + context + ) } } @@ -95,13 +101,13 @@ internal fun SyncDataSettingItem( ) { if (dataSettings.canEdit) { var selectedItem by - remember { - mutableStateOf( - DropdownItem( - label = syncPeriodLabel(dataSettings.dataSyncPeriod, context), - ), - ) - } + remember { + mutableStateOf( + DropdownItem( + label = syncPeriodLabel(dataSettings.dataSyncPeriod, context), + ), + ) + } val dataSyncPeriods = listOf( stringResource(R.string.thirty_minutes), @@ -173,19 +179,33 @@ internal fun SyncDataSettingItem( private fun provideDefaultInfoItems( dataSyncPeriod: Int, lastDataSync: String, + nextDataSync: String?, context: Context, ): List = - listOf( - AdditionalInfoItem( - key = stringResource(R.string.settings_sync_period_v2), - value = syncPeriodLabel(dataSyncPeriod, context), - ), - AdditionalInfoItem( - key = stringResource(R.string.last_data_sync), - value = lastDataSync, - color = TextColor.OnSurface, - ), - ) + buildList { + add( + AdditionalInfoItem( + key = stringResource(R.string.settings_sync_period_v2), + value = syncPeriodLabel(dataSyncPeriod, context), + ) + ) + add( + AdditionalInfoItem( + key = stringResource(R.string.last_data_sync), + value = lastDataSync, + color = TextColor.OnSurface, + ) + ) + nextDataSync?.takeIf { BuildConfig.DEBUG }?.let { + add( + AdditionalInfoItem( + key = "Next Sync On", + value = nextDataSync, + color = TextColor.OnSurface, + ) + ) + } + } @Composable private fun provideSyncErrorInfo( diff --git a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt index 7d78ecc6996..3b983201d0b 100644 --- a/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt +++ b/app/src/main/java/org/dhis2/usescases/settings/ui/SyncMetadataSettingItem.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.settings.ui import android.content.Context +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -20,10 +21,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp +import org.dhis2.BuildConfig import org.dhis2.R +import org.dhis2.bindings.EVERY_12_HOUR import org.dhis2.bindings.EVERY_24_HOUR +import org.dhis2.bindings.EVERY_6_HOUR import org.dhis2.bindings.EVERY_7_DAYS -import org.dhis2.commons.Constants +import org.dhis2.commons.Constants.TIME_MANUAL import org.dhis2.usescases.settings.SettingItem import org.dhis2.usescases.settings.models.MetadataSettingsViewModel import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem @@ -59,6 +63,8 @@ internal fun SyncMetadataSettingItem( provideDefaultInfoItems( metadataSettings.metadataSyncPeriod, metadataSettings.lastMetadataSync, + metadataSettings.nextMetadataSync, + metadataSettings.nextSettingsSync, context, ) } @@ -77,19 +83,29 @@ internal fun SyncMetadataSettingItem( if (metadataSettings.canEdit) { val metaSyncPeriods = listOf( - stringResource(R.string.a_day), - stringResource(R.string.a_week), - stringResource(R.string.Manual), + SyncMetadataPeriods.Every6Hours, + SyncMetadataPeriods.Every12Hours, + SyncMetadataPeriods.Every24Hours, + SyncMetadataPeriods.EveryWeek, + SyncMetadataPeriods.Manual, ) + val dropdownItemLabel = + metaSyncPeriods.map { + stringResource(it.label) + } + var selectedItem by - remember { - mutableStateOf( - DropdownItem( - label = syncPeriodLabel(metadataSettings.metadataSyncPeriod, context), + remember { + mutableStateOf( + DropdownItem( + label = syncPeriodLabel( + metadataSettings.metadataSyncPeriod, + context ), - ) - } + ), + ) + } var inputSyncConfigurationState by remember { mutableStateOf(InputShellState.UNFOCUSED) } @@ -101,21 +117,14 @@ internal fun SyncMetadataSettingItem( itemCount = metaSyncPeriods.size, onSearchOption = {}, fetchItem = { index -> - DropdownItem(metaSyncPeriods[index]) + DropdownItem(dropdownItemLabel[index]) }, selectedItem = selectedItem, onResetButtonClicked = { }, onItemSelected = { index, newItem -> selectedItem = newItem inputSyncConfigurationState = InputShellState.UNFOCUSED - when (index) { - 0 -> onSyncMetaPeriodChanged(EVERY_24_HOUR) - 1 -> onSyncMetaPeriodChanged(EVERY_7_DAYS) - 2 -> onSyncMetaPeriodChanged(Constants.TIME_MANUAL) - else -> { - // do nothing - } - } + onSyncMetaPeriodChanged(metaSyncPeriods[index].syncPeriod) }, showSearchBar = false, loadOptions = {}, @@ -148,18 +157,42 @@ internal fun SyncMetadataSettingItem( private fun provideDefaultInfoItems( metadataSyncPeriod: Int, lastMetadataSync: String, + nextMetadataSync: String?, + nextSettingsSync: String?, context: Context, -) = listOf( - AdditionalInfoItem( - key = stringResource(R.string.settings_sync_period_v2), - value = syncPeriodLabel(metadataSyncPeriod, context), - ), - AdditionalInfoItem( - key = stringResource(R.string.last_data_sync), - value = lastMetadataSync, - color = TextColor.OnSurface, - ), -) +) = buildList { + add( + AdditionalInfoItem( + key = stringResource(R.string.settings_sync_period_v2), + value = syncPeriodLabel(metadataSyncPeriod, context), + ) + ) + add( + AdditionalInfoItem( + key = stringResource(R.string.last_data_sync), + value = lastMetadataSync, + color = TextColor.OnSurface, + ) + ) + nextMetadataSync?.takeIf { BuildConfig.DEBUG }?.let { + add( + AdditionalInfoItem( + key = "Next sync on", + value = it, + color = TextColor.OnSurface, + ) + ) + } + nextSettingsSync?.takeIf { BuildConfig.DEBUG }?.let { + add( + AdditionalInfoItem( + key = "Next settings sync on", + value = it, + color = TextColor.OnSurface, + ) + ) + } +} @Composable private fun provideHasErrorItems( @@ -193,3 +226,18 @@ private fun provideSyncInProgressInfoItems( isConstantItem = true, ), ) + +internal sealed class SyncMetadataPeriods( + @StringRes val label: Int, + val syncPeriod: Int, +) { + data object Every24Hours : SyncMetadataPeriods(R.string.a_day, EVERY_24_HOUR) + + data object Every12Hours : SyncMetadataPeriods(R.string.every_12_hours, EVERY_12_HOUR) + + data object Every6Hours : SyncMetadataPeriods(R.string.every_6_hours, EVERY_6_HOUR) + + data object EveryWeek : SyncMetadataPeriods(R.string.a_week, EVERY_7_DAYS) + + data object Manual : SyncMetadataPeriods(R.string.Manual, TIME_MANUAL) +} diff --git a/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt b/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt index dfe8ad83645..86677cb554b 100644 --- a/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/splash/SplashActivity.kt @@ -55,23 +55,38 @@ class SplashActivity : override fun onResume() { super.onResume() - if (BuildConfig.DEBUG || !RootBeer(this).isRootedWithoutBusyBoxCheck) { - if (!isDebuggerEnable() || !detectDebugger()) { - presenter.init() - } else { - showRootedDialog( - getString(R.string.security_title), - getString(R.string.security_debugger_message), - ) - } - } else { - showRootedDialog( + val isRooted = (!BuildConfig.DEBUG && BuildConfig.FLAVOR != "dhis2Training") && RootBeer(this).isRootedWithBusyBoxCheck + val isDebuggerActive = isDebuggerEnable() && detectDebugger() + + when { + isRooted -> showRootedDialog( getString(R.string.security_title), getString(R.string.security_rooted_message), ) + + isDebuggerActive -> showRootedDialog( + getString(R.string.security_title), + getString(R.string.security_debugger_message), + ) + + else -> presenter.init() } } + private fun isDebuggerEnable(): Boolean = + if (!BuildConfig.DEBUG && BuildConfig.FLAVOR != "dhis2Training") { + context.applicationContext.applicationInfo.flags and FLAG_DEBUGGABLE != 0 + } else { + false + } + + private fun detectDebugger(): Boolean = + if (!BuildConfig.DEBUG && BuildConfig.FLAVOR != "dhis2Training") { + Debug.isDebuggerConnected() + } else { + false + } + override fun onPause() { presenter.destroy() super.onPause() @@ -153,18 +168,4 @@ class SplashActivity : ) } } - - private fun isDebuggerEnable(): Boolean = - if (!BuildConfig.DEBUG) { - context.applicationContext.applicationInfo.flags and FLAG_DEBUGGABLE != 0 - } else { - false - } - - private fun detectDebugger(): Boolean = - if (!BuildConfig.DEBUG) { - Debug.isDebuggerConnected() - } else { - false - } } diff --git a/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt b/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt index 0b0a027a4e4..1cb60bc3cc7 100644 --- a/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/sync/SyncActivity.kt @@ -5,18 +5,23 @@ import android.os.Bundle import android.view.View.GONE import androidx.appcompat.content.res.AppCompatResources import androidx.databinding.DataBindingUtil -import androidx.work.WorkInfo +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.dhis2.App import org.dhis2.R import org.dhis2.bindings.Bindings import org.dhis2.bindings.userComponent import org.dhis2.databinding.ActivitySynchronizationBinding +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.general.ActivityGlobalAbstract import org.dhis2.usescases.login.LoginActivity import org.dhis2.usescases.main.MainActivity import org.dhis2.utils.OnDialogClickListener import org.dhis2.utils.extension.navigateTo import org.dhis2.utils.extension.share +import org.koin.android.ext.android.inject import javax.inject.Inject class SyncActivity : @@ -30,20 +35,23 @@ class SyncActivity : @Inject lateinit var animations: SyncAnimations + private val backgroundJobAction: SyncBackgroundJobAction by inject() + override fun onCreate(savedInstanceState: Bundle?) { val serverComponent = (applicationContext as App).serverComponent() - userComponent()?.plus(SyncModule(this, serverComponent))?.inject(this) ?: finish() + userComponent()?.plus(SyncModule(this, backgroundJobAction, serverComponent))?.inject(this) + ?: finish() super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_synchronization) binding.presenter = presenter - presenter.sync() - } - override fun onResume() { - super.onResume() - presenter.observeSyncProcess().observe(this) { workInfoList: List -> - presenter.handleSyncInfo(workInfoList) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + presenter.observeSyncProcess().collect(presenter::handleSyncInfo) + } } + + presenter.sync() } override fun setMetadataSyncStarted() { diff --git a/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt b/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt index 1abecbbd72e..12e307c465f 100644 --- a/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt +++ b/app/src/main/java/org/dhis2/usescases/sync/SyncInjector.kt @@ -7,7 +7,7 @@ import org.dhis2.commons.di.dagger.PerActivity import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.server.ServerComponent -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction @PerActivity @Subcomponent(modules = [SyncModule::class]) @@ -18,6 +18,7 @@ interface SyncComponent { @Module class SyncModule( private val view: SyncView, + private val backgroundJobAction: SyncBackgroundJobAction, serverComponent: ServerComponent?, ) { private val userManager = serverComponent?.userManager() @@ -26,14 +27,13 @@ class SyncModule( @PerActivity fun providePresenter( schedulerProvider: SchedulerProvider, - workManagerController: WorkManagerController, preferences: PreferenceProvider, ): SyncPresenter = SyncPresenter( view, userManager, schedulerProvider, - workManagerController, + backgroundJobAction, preferences, ) } diff --git a/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt b/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt index 90735348a02..91d7c5e58bc 100644 --- a/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/sync/SyncPresenter.kt @@ -1,15 +1,14 @@ package org.dhis2.usescases.sync -import androidx.lifecycle.LiveData -import androidx.work.WorkInfo import io.reactivex.disposables.CompositeDisposable -import org.dhis2.commons.Constants import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.server.UserManager -import org.dhis2.data.service.METADATA_MESSAGE -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.data.METADATA_SYNC_NOW +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import timber.log.Timber const val WAS_INITIAL_SYNC_DONE = "WasInitialSyncDone" @@ -18,35 +17,35 @@ class SyncPresenter internal constructor( private val view: SyncView, private val userManager: UserManager?, private val schedulerProvider: SchedulerProvider, - private val workManagerController: WorkManagerController, + private val backgroundJobAction: SyncBackgroundJobAction, private val preferences: PreferenceProvider, ) { private val disposable = CompositeDisposable() fun sync() { - workManagerController - .syncMetaDataForWorker(Constants.META_NOW, Constants.INITIAL_SYNC) + backgroundJobAction.launchMetadataSync(0) } - fun observeSyncProcess(): LiveData> = workManagerController.getWorkInfosForUniqueWorkLiveData(Constants.INITIAL_SYNC) + fun observeSyncProcess() = backgroundJobAction.observeMetadataJob() - fun handleSyncInfo(workInfoList: List) { + fun handleSyncInfo(workInfoList: List) { workInfoList.forEach { workInfo -> - if (workInfo.tags.contains(Constants.META_NOW)) { - handleMetaState(workInfo.state, workInfo.outputData.getString(METADATA_MESSAGE)) + if (workInfo.tags.contains(METADATA_SYNC_NOW)) { + handleMetaState(workInfo.status, workInfo.message) } } } private fun handleMetaState( - state: WorkInfo.State, + state: SyncStatus, message: String?, ) { when (state) { - WorkInfo.State.RUNNING -> view.setMetadataSyncStarted() - WorkInfo.State.SUCCEEDED -> view.setMetadataSyncSucceed() - WorkInfo.State.FAILED -> view.showMetadataFailedMessage(message) + SyncStatus.Running -> view.setMetadataSyncStarted() + SyncStatus.Succeed -> view.setMetadataSyncSucceed() + SyncStatus.Failed -> view.showMetadataFailedMessage(message) else -> { + // do nothing } } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt index b9a85cbdcdc..3eee5ec3f20 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt @@ -64,7 +64,7 @@ interface DashboardRepository { fun getTeiActivePrograms( teiUid: String, showOnlyActive: Boolean, - ): Observable>> + ): Observable>> fun getTEIEnrollments(teiUid: String): Observable> @@ -92,14 +92,14 @@ interface DashboardRepository { fun programHasRelationships(): Boolean - fun programHasAnalytics(): Boolean + suspend fun programHasAnalytics(): Boolean fun getTETypeName(): String? fun getAttributesMap( programUid: String, teiUid: String, - ): Observable>> + ): Observable>> fun getDashboardModel(): DashboardModel? diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt index db2f64169c2..63a78db477e 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt @@ -12,6 +12,9 @@ import org.dhis2.commons.featureconfig.data.FeatureConfigRepository import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.MetadataIconProvider +import org.dhis2.data.dhislogic.AUTH_ALL +import org.dhis2.data.dhislogic.AUTH_ENROLLMENT_CASCADE_DELETE +import org.dhis2.data.dhislogic.AUTH_TEI_CASCADE_DELETE import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.utils.ValueUtils import org.hisp.dhis.android.core.D2 @@ -573,7 +576,7 @@ class DashboardRepositoryImpl( override fun getTeiActivePrograms( teiUid: String, showOnlyActive: Boolean, - ): Observable>> { + ): Observable>> { val enrollmentRepo = d2 .enrollmentModule() @@ -643,7 +646,7 @@ class DashboardRepositoryImpl( .userModule() .authorities() .byName() - .eq("F_TEI_CASCADE_DELETE") + .`in`(AUTH_TEI_CASCADE_DELETE, AUTH_ALL) .one() .blockingExists() @@ -671,7 +674,7 @@ class DashboardRepositoryImpl( .userModule() .authorities() .byName() - .eq("F_ENROLLMENT_CASCADE_DELETE") + .`in`(AUTH_ENROLLMENT_CASCADE_DELETE, AUTH_ALL) .one() .blockingExists() @@ -746,7 +749,7 @@ class DashboardRepositoryImpl( } else { Observable.just(StatusChangeResultCode.WRITE_PERMISSION_FAIL) } - } catch (error: D2Error) { + } catch (_: D2Error) { Observable.just(StatusChangeResultCode.FAILED) } @@ -778,7 +781,7 @@ class DashboardRepositoryImpl( false } - override fun programHasAnalytics(): Boolean = + override suspend fun programHasAnalytics(): Boolean = if (!programUid.isNullOrEmpty()) { val enrollmentScopeRulesUids = d2 @@ -887,12 +890,12 @@ class DashboardRepositoryImpl( override fun getAttributesMap( programUid: String, teiUid: String, - ): Observable>> = + ): Observable>> = teiAttributesProvider .getProgramTrackedEntityAttributesByProgram(programUid, teiUid) .toObservable() - .flatMapIterable { list: List>? -> list } - .map { (attribute, attributeValue): kotlin.Pair -> + .flatMapIterable { list: List>? -> list } + .map { (attribute, attributeValue): Pair -> val formattedAttributeValue: TrackedEntityAttributeValue = if (attributeValue != null && attribute!!.valueType() != ValueType.IMAGE) { ValueUtils.transform( diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt index 0fb7b499c36..3cd6968a1ad 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt @@ -120,7 +120,7 @@ class DashboardViewModel( } } - private fun loadNavigationBarItems() { + private suspend fun loadNavigationBarItems() { val enrollmentItems = mutableListOf>() if (isPortrait()) { @@ -134,7 +134,7 @@ class DashboardViewModel( ) } - if (pageConfigurator.displayAnalytics()) { + if (withContext(dispatcher.io()) { repository.programHasAnalytics() }) { enrollmentItems.add( NavigationBarItem( id = TEIDashboardItems.ANALYTICS, @@ -255,7 +255,7 @@ class DashboardViewModel( try { val hasMoreEnrollments = result.await() onSuccess(hasMoreEnrollments) - } catch (e: AuthorityException) { + } catch (_: AuthorityException) { onAuthorityError() } catch (e: Exception) { Timber.e(e) 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 aeb40312665..4620323adc1 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt @@ -3,7 +3,6 @@ package org.dhis2.usescases.teiDashboard import android.app.ActivityOptions import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -630,15 +629,13 @@ class TeiDashboardMobileActivity : } binding.executePendingBindings() setTheme(themeManager.getProgramTheme()) - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) { - val window = window - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - val typedValue = TypedValue() - val a = obtainStyledAttributes(typedValue.data, intArrayOf(R.attr.colorPrimaryDark)) - val colorToReturn = a.getColor(0, 0) - a.recycle() - window.statusBarColor = colorToReturn - } + val window = window + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + val typedValue = TypedValue() + val a = obtainStyledAttributes(typedValue.data, intArrayOf(R.attr.colorPrimaryDark)) + val colorToReturn = a.getColor(0, 0) + a.recycle() + window.statusBarColor = colorToReturn } override fun updateNoteBadge(numberOfNotes: Int) { diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt index 3fbe0c1bacb..4385cf40dfd 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt @@ -8,8 +8,6 @@ class TeiDashboardPageConfigurator( ) : NavigationPageConfigurator { override fun displayDetails(): Boolean = isPortrait - override fun displayAnalytics(): Boolean = dashboardRepository.programHasAnalytics() - override fun displayRelationships(): Boolean = dashboardRepository.programHasRelationships() override fun displayNotes(): Boolean = true diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt index 7c1f1e179af..3b9159c663e 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepository.kt @@ -9,6 +9,7 @@ import dhis2.org.analytics.charts.ui.SectionType import io.reactivex.Flowable import io.reactivex.Observable import org.dhis2.commons.resources.ResourceManager +import org.dhis2.mobileProgramRules.RuleConstants import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.UidGeneratorImpl @@ -111,6 +112,8 @@ abstract class BaseIndicatorRepository( val ruleAction = ruleEffect.ruleAction if (ruleEffect.data?.contains("#{") == false) { if (ruleAction.type == ProgramRuleActionType.DISPLAYKEYVALUEPAIR.name) { + val legendSet = ruleAction.values[RuleConstants.LEGENDSET_LABEL] + val color = getLegendColor(ruleEffect.data, legendSet) val indicator = IndicatorModel( ProgramIndicator @@ -119,7 +122,7 @@ abstract class BaseIndicatorRepository( .displayName((ruleAction).content()) .build(), ruleEffect.data, - null, + color, ruleAction.values["location"] ?: DEFAULT_LOCATION, resourceManager.defaultIndicatorLabel(), ) @@ -131,9 +134,9 @@ abstract class BaseIndicatorRepository( ProgramIndicator .builder() .uid(UidGeneratorImpl().generate()) - .displayName(resourceManager.defaultIndicatorLabel()) + .displayName("${ruleAction.content() ?: ""}${ruleEffect.data}") .build(), - "${ruleAction.content() ?: ""}${ruleEffect.data}", + "", null, ruleAction.values["location"] ?: DEFAULT_LOCATION, resourceManager.defaultIndicatorLabel(), @@ -147,6 +150,29 @@ abstract class BaseIndicatorRepository( return indicators } + private fun getLegendColor( + data: String?, + legendSet: String?, + ): String? { + if (data.isNullOrEmpty() || legendSet.isNullOrEmpty()) return null + + val legendValue = getNumberValue(data) ?: return null + + val legends = + d2 + .legendSetModule() + .legends() + .byStartValue() + .smallerThan(legendValue) + .byEndValue() + .biggerOrEqualTo(legendValue) + .byLegendSet() + .eq(legendSet) + .blockingGet() + + return legends.firstOrNull()?.color() + } + private fun getLegendColorForIndicator( indicator: ProgramIndicator, value: String?, @@ -240,4 +266,16 @@ abstract class BaseIndicatorRepository( } } } + + companion object { + private val NUMBER_REGEX = """-?\b(\d{1,3}(,\d{3})*(\.\d+)?|\d+(\.\d+)?)\b""".toRegex() + + internal fun getNumberValue(data: String): Double? { + return NUMBER_REGEX + .find(data) + ?.value + ?.replace(",", "") + ?.toDoubleOrNull() + } + } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepository.kt index 229ec86cade..ccf47212cf1 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepository.kt @@ -1,7 +1,6 @@ package org.dhis2.usescases.teiDashboard.dashboardfragments.indicators import dhis2.org.analytics.charts.ui.AnalyticsModel -import io.reactivex.Flowable import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 @@ -13,19 +12,19 @@ class EventIndicatorRepository( val eventUid: String, resourceManager: ResourceManager, ) : BaseIndicatorRepository(d2, ruleEngineHelper, programUid, resourceManager) { - override fun fetchData(): Flowable> = - Flowable.zip( - getIndicators { indicatorUid -> - d2 - .programModule() - .programIndicatorEngine() - .getEventProgramIndicatorValue( - eventUid, - indicatorUid, - ) ?: "" - }, - getRulesIndicators(), - ) { indicators, ruleIndicators -> - arrangeSections(indicators, ruleIndicators) - } + override suspend fun fetchData(): List { + val indicators = getIndicators { indicatorUid -> + d2 + .programModule() + .programIndicatorEngine() + .getEventProgramIndicatorValue( + eventUid, + indicatorUid, + ) ?: "" + }.blockingFirst(emptyList()) + + val ruleIndicators = getRulesIndicators().blockingFirst(emptyList()) + + return arrangeSections(indicators, ruleIndicators) + } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorRepository.kt index e2f0f78fb35..65f6562048c 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorRepository.kt @@ -3,12 +3,11 @@ package org.dhis2.usescases.teiDashboard.dashboardfragments.indicators import dhis2.org.analytics.charts.ui.AnalyticsModel import dhis2.org.analytics.charts.ui.ChartModel import dhis2.org.analytics.charts.ui.OrgUnitFilterType -import io.reactivex.Flowable import org.hisp.dhis.android.core.common.RelativePeriod import org.hisp.dhis.android.core.organisationunit.OrganisationUnit interface IndicatorRepository { - fun fetchData(): Flowable> + suspend fun fetchData(): List fun filterByPeriod( chartModel: ChartModel, diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsModule.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsModule.kt index e63ab42534b..b28c187d06d 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsModule.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsModule.kt @@ -5,7 +5,7 @@ import dagger.Provides import dhis2.org.analytics.charts.Charts import org.dhis2.commons.di.dagger.PerFragment import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.schedulers.SchedulerProvider +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 @@ -19,9 +19,9 @@ class IndicatorsModule( @Provides @PerFragment fun providesPresenter( - schedulerProvider: SchedulerProvider, + dispatcherProvider: DispatcherProvider, indicatorRepository: IndicatorRepository, - ): IndicatorsPresenter = IndicatorsPresenter(schedulerProvider, view, indicatorRepository) + ): IndicatorsPresenter = IndicatorsPresenter(dispatcherProvider, view, indicatorRepository) @Provides @PerFragment diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenter.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenter.kt index 407be11ed64..7495dd80bd2 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenter.kt @@ -4,34 +4,42 @@ import dhis2.org.analytics.charts.idling.AnalyticsCountingIdlingResource import dhis2.org.analytics.charts.ui.ChartFilter import dhis2.org.analytics.charts.ui.ChartModel import dhis2.org.analytics.charts.ui.OrgUnitFilterType -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.processors.PublishProcessor -import org.dhis2.commons.schedulers.SchedulerProvider -import org.dhis2.commons.schedulers.defaultSubscribe +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.dhis2.commons.viewmodel.DispatcherProvider import org.hisp.dhis.android.core.common.RelativePeriod import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import timber.log.Timber class IndicatorsPresenter( - val schedulerProvider: SchedulerProvider, + val dispatcherProvider: DispatcherProvider, val view: IndicatorsView, val indicatorRepository: IndicatorRepository, ) { - var compositeDisposable: CompositeDisposable = CompositeDisposable() - val publishProcessor = PublishProcessor.create() + private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io()) fun init() { AnalyticsCountingIdlingResource.increment() + fetchData() + } - compositeDisposable.add( - publishProcessor - .startWith(Unit) - .flatMap { indicatorRepository.fetchData() } - .defaultSubscribe(schedulerProvider, { view.swapAnalytics(it) }, { Timber.d(it) }), - ) + private fun fetchData() { + scope.launch { + try { + val data = indicatorRepository.fetchData() + withContext(dispatcherProvider.ui()) { + view.swapAnalytics(data) + } + } catch (e: Exception) { + Timber.d(e) + } + } } - fun onDettach() = compositeDisposable.clear() + fun onDettach() = scope.cancel() fun displayMessage(message: String) = view.displayMessage(message) @@ -41,7 +49,7 @@ class IndicatorsPresenter( lineListingColumnId: Int?, ) { indicatorRepository.filterByPeriod(chartModel, selectedPeriods, lineListingColumnId) - publishProcessor.onNext(Unit) + fetchData() } fun filterByOrgUnit( @@ -56,7 +64,7 @@ class IndicatorsPresenter( filterType, lineListingColumnId, ) - publishProcessor.onNext(Unit) + fetchData() } fun resetFilter( @@ -87,6 +95,6 @@ class IndicatorsPresenter( ) } } - publishProcessor.onNext(Unit) + fetchData() } } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepository.kt index 00a6d4f12d9..70cc359fe4b 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepository.kt @@ -4,8 +4,6 @@ import dhis2.org.analytics.charts.Charts import dhis2.org.analytics.charts.ui.AnalyticsModel import dhis2.org.analytics.charts.ui.ChartModel import dhis2.org.analytics.charts.ui.OrgUnitFilterType -import io.reactivex.Flowable -import io.reactivex.functions.Function3 import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobileProgramRules.RuleEngineHelper import org.dhis2.utils.DhisTextUtils @@ -37,32 +35,25 @@ class TrackerAnalyticsRepository( enrollmentUid = enrollmentRepository.one().blockingGet()?.uid() ?: "" } - override fun fetchData(): Flowable> = - Flowable.zip< - List?, - List?, - List, - List, - >( - getIndicators( - !DhisTextUtils.isEmpty(enrollmentUid), - ) { indicatorUid -> - d2 - .programModule() - .programIndicatorEngine() - .getEnrollmentProgramIndicatorValue( - enrollmentUid, - indicatorUid, - ) ?: "" - }, - getRulesIndicators(), - Flowable.just( - charts?.geEnrollmentCharts(enrollmentUid)?.map { ChartModel(it) }, - ), - Function3 { indicators, ruleIndicators, charts -> - arrangeSections(indicators, ruleIndicators, charts) - }, - ) + override suspend fun fetchData(): List { + val indicators = getIndicators( + !DhisTextUtils.isEmpty(enrollmentUid), + ) { indicatorUid -> + d2 + .programModule() + .programIndicatorEngine() + .getEnrollmentProgramIndicatorValue( + enrollmentUid, + indicatorUid, + ) ?: "" + }.blockingFirst(emptyList()) + + val ruleIndicators = getRulesIndicators().blockingFirst(emptyList()) + + val chartModels = charts?.geEnrollmentCharts(enrollmentUid)?.map { ChartModel(it) } ?: emptyList() + + return arrangeSections(indicators, ruleIndicators, chartModels) + } override fun filterByPeriod( chartModel: ChartModel, diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipModule.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipModule.java index ef142b8557c..c4274ef465b 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipModule.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipModule.java @@ -2,6 +2,7 @@ import android.content.Context; +import org.dhis2.R; import org.dhis2.commons.data.ProgramConfigurationRepository; import org.dhis2.commons.date.DateLabelProvider; import org.dhis2.commons.date.DateUtils; @@ -11,6 +12,8 @@ import org.dhis2.commons.resources.MetadataIconProvider; import org.dhis2.commons.resources.ResourceManager; import org.dhis2.commons.viewmodel.DispatcherProvider; +import org.dhis2.data.enrollment.EnrollmentUiDataHelper; +import org.dhis2.data.sorting.SearchSortingValueSetter; import org.dhis2.maps.geometry.bound.GetBoundingBox; import org.dhis2.maps.geometry.line.MapLineRelationshipToFeature; import org.dhis2.maps.geometry.mapper.featurecollection.MapRelationshipsToFeatureCollection; @@ -108,7 +111,8 @@ RelationshipMapsRepository providesRepository( ResourceManager resourceManager, MetadataIconProvider metadataIconProvider, DateLabelProvider dateLabelProvider, - DateUtils dateUtils + DateUtils dateUtils, + SearchSortingValueSetter sortingValueSetter ) { RelationshipConfiguration config; if (teiUid != null) { @@ -124,7 +128,8 @@ RelationshipMapsRepository providesRepository( d2, profilePictureProvider, dateLabelProvider, - metadataIconProvider + metadataIconProvider, + sortingValueSetter ), new EventInfoProvider( d2, @@ -137,6 +142,42 @@ RelationshipMapsRepository providesRepository( ); } + @Provides + @PerFragment + SearchSortingValueSetter searchSortingValueSetter( + Context context, + D2 d2, + EnrollmentUiDataHelper enrollmentUiDataHelper, + ResourceManager resourceManager) { + String unknownLabel = context.getString(R.string.unknownValue); + String eventDateLabel = context.getString(R.string.most_recent_event_date); + String enrollmentStatusLabel = resourceManager.formatWithEnrollmentLabel( + null, + R.string.filters_title_enrollment_status, + 1, + false); + String enrollmentDateDefaultLabel = resourceManager.formatWithEnrollmentLabel( + null, + R.string.enrollment_date_V2, + 1, + false); + String uiDateFormat = DateUtils.SIMPLE_DATE_FORMAT; + return new SearchSortingValueSetter(d2, + unknownLabel, + eventDateLabel, + enrollmentStatusLabel, + enrollmentDateDefaultLabel, + uiDateFormat, + enrollmentUiDataHelper); + } + + @Provides + @PerFragment + EnrollmentUiDataHelper enrollmentUiDataHelper(Context context) { + return new EnrollmentUiDataHelper(context); + } + + @Provides @PerFragment MapRelationshipsToFeatureCollection provideMapRelationshipToFeatureCollection() { diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt index 299bd2620de..1f27a1808f1 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataFragment.kt @@ -682,6 +682,7 @@ class TEIDataFragment : eventMode: EventMode, programUid: String, ) { + if (!isAdded) return val intent = EventCaptureActivity.intent( context = requireContext(), @@ -696,6 +697,7 @@ class TEIDataFragment : programUid: String, programStageUid: String, ) { + if (!isAdded) return OUTreeFragment .Builder() .singleSelection() diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt index 8dc501194ac..1beb0ac69e8 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataPresenter.kt @@ -12,6 +12,7 @@ import io.reactivex.disposables.CompositeDisposable import io.reactivex.processors.BehaviorProcessor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.dhis2.commons.Constants import org.dhis2.commons.bindings.canCreateEventInEnrollment import org.dhis2.commons.bindings.enrollment @@ -413,9 +414,15 @@ class TEIDataPresenter( when (eventCreationType) { EventCreationType.ADDNEW -> programUid?.let { program -> - val orgUnitUid = d2.enrollment(enrollmentUid)?.organisationUnit() - orgUnitUid?.let { onNewEventSelected(orgUnitUid, stage.uid()) } - ?: checkOrgUnitCount(program, stage.uid()) + CoroutineScope(dispatcher.io()).launch { + val enrollmentOrgUnitUid = d2.enrollment(enrollmentUid)?.organisationUnit() + val ownerOrgUnit = teiDataRepository.ownerOrgUnit(teiUid) + val eventOrgUnit = ownerOrgUnit ?: enrollmentOrgUnitUid + withContext(dispatcher.ui()) { + eventOrgUnit?.let { onNewEventSelected(eventOrgUnit, stage.uid()) } + ?: checkOrgUnitCount(program, stage.uid()) + } + } } EventCreationType.SCHEDULE -> { @@ -484,7 +491,9 @@ class TEIDataPresenter( if (orgUnits.count() == 1) { onNewEventSelected(orgUnits.first().uid(), programStageUid) } else { - view.displayOrgUnitSelectorForNewEvent(programUid, programStageUid) + withContext(dispatcher.ui()) { + view.displayOrgUnitSelectorForNewEvent(programUid, programStageUid) + } } } } @@ -502,14 +511,18 @@ class TEIDataPresenter( enrollmentUid = enrollmentUid, ).fold( onSuccess = { eventUid -> - view.goToEventDetails( - eventUid = eventUid, - eventMode = EventMode.NEW, - programUid = it, - ) + withContext(dispatcher.ui()) { + view.goToEventDetails( + eventUid = eventUid, + eventMode = EventMode.NEW, + programUid = it, + ) + } }, onFailure = { d2Error -> - view.displayMessage(d2ErrorUtils.getErrorMessage(d2Error)) + withContext(dispatcher.ui()) { + view.displayMessage(d2ErrorUtils.getErrorMessage(d2Error)) + } }, ) } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt index 1079c7bb338..77641689f9b 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepository.kt @@ -32,6 +32,8 @@ interface TeiDataRepository { fun displayOrganisationUnit(programUid: String): Boolean + fun ownerOrgUnit(teiUid: String): String? + fun enrollmentOrgUnitInCaptureScope(enrollmentOrgUnit: String): Boolean fun programOrgListInCaptureScope(programUid: String): List 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 73a5731d2b0..64385dbe08e 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 @@ -544,6 +544,20 @@ class TeiDataRepositoryImpl( .byProgramUids(listOf(programUid)) .blockingCount() > 1 + override fun ownerOrgUnit(teiUid: String): String? { + + return d2 + .trackedEntityModule() + .trackedEntityInstances() + .withProgramOwners() + .uid(teiUid) + .blockingGet() + ?.programOwners() + ?.firstOrNull { + it.trackedEntityInstance() == teiUid + }?.ownerOrgUnit() + } + override fun enrollmentOrgUnitInCaptureScope(enrollmentOrgUnit: String): Boolean = !getOrgUnitCollectionRepositoryByCaptureScope() .byUid() 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 d9535cdf9fd..70df38985c7 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 @@ -34,7 +34,7 @@ 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)).inject(this); + ((App) getApplicationContext()).userComponent().plus(new TeiProgramListModule(this, trackedEntityId, 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/TeiProgramListInteractor.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListInteractor.java index 3f389a6a94a..a2d2f905f16 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListInteractor.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/teiProgramList/TeiProgramListInteractor.java @@ -8,7 +8,7 @@ import org.dhis2.commons.dialogs.calendarpicker.OnDatePickerListener; import org.dhis2.commons.orgunitselector.OUTreeFragment; import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope; -import org.dhis2.data.service.SyncStatusController; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.usescases.main.program.ProgramDownloadState; import org.dhis2.usescases.main.program.ProgramUiModel; import org.hisp.dhis.android.core.organisationunit.OrganisationUnit; 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 91d1b696c16..bd6a5e6c556 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 @@ -4,10 +4,8 @@ import org.dhis2.commons.di.dagger.PerActivity; import org.dhis2.commons.prefs.PreferenceProvider; -import org.dhis2.commons.resources.ColorUtils; import org.dhis2.commons.resources.MetadataIconProvider; -import org.dhis2.commons.resources.ResourceManager; -import org.dhis2.data.service.SyncStatusController; +import org.dhis2.mobile.sync.domain.SyncStatusController; import org.dhis2.usescases.main.program.ProgramViewModelMapper; import org.dhis2.utils.analytics.AnalyticsHelper; import org.hisp.dhis.android.core.D2; @@ -22,9 +20,12 @@ public class TeiProgramListModule { private final TeiProgramListContract.View view; private final String teiUid; - TeiProgramListModule(TeiProgramListContract.View view, String teiUid) { + private final SyncStatusController syncStatusController; + + TeiProgramListModule(TeiProgramListContract.View view, String teiUid, SyncStatusController syncStatusController) { this.view = view; this.teiUid = teiUid; + this.syncStatusController = syncStatusController; } @Provides @@ -45,8 +46,7 @@ TeiProgramListContract.Presenter providesPresenter(TeiProgramListContract.Intera @Provides @PerActivity - TeiProgramListContract.Interactor provideInteractor(@NonNull TeiProgramListRepository teiProgramListRepository, - SyncStatusController syncStatusController) { + TeiProgramListContract.Interactor provideInteractor(@NonNull TeiProgramListRepository teiProgramListRepository) { return new TeiProgramListInteractor(teiProgramListRepository, syncStatusController); } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TopBarIcons.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TopBarIcons.kt index d6ad22584cc..934879270f7 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TopBarIcons.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TopBarIcons.kt @@ -5,11 +5,11 @@ import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource import org.dhis2.R import org.dhis2.tracker.relationships.ui.state.RelationshipTopBarIconState +import org.dhis2.utils.getOnToolbarColor import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle import org.hisp.dhis.mobile.ui.designsystem.component.IconButton @@ -19,13 +19,14 @@ fun ComposeView?.setButtonContent( onButtonClicked: () -> Unit, ) { this?.setContent { + val onToolbarColor = getOnToolbarColor() Button( text = "${stringResource(id = R.string.edit)} ${trackedEntityName.lowercase()}", style = ButtonStyle.TEXT_LIGHT, icon = { Icon( imageVector = Icons.Outlined.Edit, - tint = Color.White, + tint = onToolbarColor, contentDescription = "Edit", ) }, @@ -39,13 +40,14 @@ fun RelationshipTopBarIcon( relationshipTopBarIconState: RelationshipTopBarIconState, onButtonClicked: () -> Unit, ) { + val onToolbarColor = getOnToolbarColor() IconButton( modifier = Modifier, icon = { Icon( imageVector = relationshipTopBarIconState.icon, contentDescription = stringResource(R.string.relationships), - tint = Color.White, + tint = onToolbarColor, ) }, ) { diff --git a/app/src/main/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProvider.kt b/app/src/main/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProvider.kt index 875792b7dfa..35813fe568f 100644 --- a/app/src/main/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProvider.kt +++ b/app/src/main/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProvider.kt @@ -1,14 +1,19 @@ package org.dhis2.usescases.tracker import org.dhis2.commons.date.DateLabelProvider +import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.commons.resources.MetadataIconProvider +import org.dhis2.data.sorting.SearchSortingValueSetter import org.dhis2.maps.model.MapItemModel import org.dhis2.maps.model.RelatedInfo import org.dhis2.maps.model.RelationshipDirection import org.dhis2.mobile.commons.model.AvatarProviderConfiguration import org.dhis2.mobile.commons.model.AvatarProviderConfiguration.Metadata import org.dhis2.mobile.commons.model.AvatarProviderConfiguration.ProfilePic +import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.tracker.data.ProfilePictureProvider +import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain +import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.dhis2.utils.ValueUtils import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope @@ -27,6 +32,7 @@ class TrackedEntityInstanceInfoProvider( private val profilePictureProvider: ProfilePictureProvider, private val dateLabelProvider: DateLabelProvider, private val metadataIconProvider: MetadataIconProvider, + private val sortingValueSetter: SearchSortingValueSetter, ) { fun getAvatar( tei: TrackedEntityInstance, @@ -75,6 +81,10 @@ class TrackedEntityInstanceInfoProvider( } } + fun getMetadataIcon(style: ObjectStyle) : MetadataIconData { + return metadataIconProvider.invoke(style) + } + fun getTeiTitle( header: String?, attributeValues: List, @@ -90,7 +100,7 @@ class TrackedEntityInstanceInfoProvider( fun getTeiLastUpdated(tei: TrackedEntitySearchItem) = dateLabelProvider.span(tei.lastUpdated) - fun getTeiAdditionalInfoList(attributeValues: List): List = + fun getTeiAdditionalInfoListForMap(attributeValues: List): List = attributeValues .filter { attribute -> attribute.displayInList && @@ -105,7 +115,7 @@ class TrackedEntityInstanceInfoProvider( key = attribute.displayFormName, value = if (attribute.value != null) { - ValueUtils.transformValue( + ValueUtils.transformValueLegacy( d2, attribute.value, attribute.valueType, @@ -117,6 +127,19 @@ class TrackedEntityInstanceInfoProvider( ) } + fun getTransformedValue(attr: TrackedEntitySearchItemAttributeDomain) : String? { + return if (attr.value != null) { + ValueUtils.transformValue( + d2, + attr.value, + attr.valueType, + attr.optionSet, + ) + } else { + null + } + } + fun getRelatedInfo( searchItem: TrackedEntitySearchItem, selectedProgram: Program?, @@ -203,4 +226,14 @@ class TrackedEntityInstanceInfoProvider( ), ) } + + fun getUnknownLabel(): String { + return sortingValueSetter.unknownLabel + } + + fun getSortingKeyValue(searchTei: SearchTeiModel, sortingItem: SortingItem): Pair? { + return sortingValueSetter.setSortingItem(searchTei, sortingItem) + } + + } diff --git a/app/src/main/java/org/dhis2/usescases/troubleshooting/TroubleshootingFragment.kt b/app/src/main/java/org/dhis2/usescases/troubleshooting/TroubleshootingFragment.kt index 741f56d073c..b07de6c16c2 100644 --- a/app/src/main/java/org/dhis2/usescases/troubleshooting/TroubleshootingFragment.kt +++ b/app/src/main/java/org/dhis2/usescases/troubleshooting/TroubleshootingFragment.kt @@ -1,7 +1,6 @@ package org.dhis2.usescases.troubleshooting import android.R -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -10,22 +9,19 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.viewModels import org.dhis2.usescases.general.FragmentGlobalAbstract import org.dhis2.usescases.main.MainActivity -import org.dhis2.usescases.main.MainNavigator +import org.dhis2.usescases.main.MainScreenType import org.dhis2.usescases.troubleshooting.ui.TroubleshootingScreen import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme -import javax.inject.Inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf const val OPEN_LANGUAGE_SECTION = "OPEN_LANGUAGE_SECTION" class TroubleshootingFragment : FragmentGlobalAbstract() { - @Inject - lateinit var troubleshootingViewModelFactory: TroubleshootingViewModelFactory - - private val troubleshootingViewModel: TroubleshootingViewModel by viewModels { - troubleshootingViewModelFactory + private val troubleshootingViewModel: TroubleshootingViewModel by viewModel { + parametersOf(arguments?.getBoolean(OPEN_LANGUAGE_SECTION) ?: false) } companion object { @@ -38,18 +34,6 @@ class TroubleshootingFragment : FragmentGlobalAbstract() { } } - override fun onAttach(context: Context) { - super.onAttach(context) - if (context is MainActivity) { - context.mainComponent - .plus( - TroubleshootingModule( - arguments?.getBoolean(OPEN_LANGUAGE_SECTION) ?: false, - ), - ).inject(this) - } - } - @ExperimentalFoundationApi @ExperimentalAnimationApi override fun onCreateView( @@ -74,7 +58,7 @@ class TroubleshootingFragment : FragmentGlobalAbstract() { startActivity( MainActivity.intent( requireContext(), - MainNavigator.MainScreen.TROUBLESHOOTING, + MainScreenType.TroubleShooting, ), ) requireActivity().finish() diff --git a/app/src/main/java/org/dhis2/usescases/troubleshooting/TroubleshootingInjector.kt b/app/src/main/java/org/dhis2/usescases/troubleshooting/TroubleshootingInjector.kt deleted file mode 100644 index f6c37854208..00000000000 --- a/app/src/main/java/org/dhis2/usescases/troubleshooting/TroubleshootingInjector.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.dhis2.usescases.troubleshooting - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.Subcomponent -import org.dhis2.commons.di.dagger.PerFragment -import org.dhis2.commons.resources.LocaleSelector -import org.dhis2.commons.resources.MetadataIconProvider -import org.hisp.dhis.android.core.D2 - -@PerFragment -@Subcomponent(modules = [TroubleshootingModule::class]) -interface TroubleshootingComponent { - fun inject(programFragment: TroubleshootingFragment) -} - -@Module -class TroubleshootingModule( - private val openLanguageSection: Boolean, -) { - @Provides - fun providesViewModelFactory( - localeSelector: LocaleSelector, - repository: TroubleshootingRepository, - ): TroubleshootingViewModelFactory = - TroubleshootingViewModelFactory( - localeSelector, - repository, - openLanguageSection, - ) - - @Provides - fun providesLocaleSelector( - context: Context, - d2: D2, - ) = LocaleSelector(context, d2) - - @Provides - fun provideRepository( - d2: D2, - metadataIconProvider: MetadataIconProvider, - ) = TroubleshootingRepository(d2, metadataIconProvider) -} diff --git a/app/src/main/java/org/dhis2/utils/ThemeColors.kt b/app/src/main/java/org/dhis2/utils/ThemeColors.kt new file mode 100644 index 00000000000..93b1f3e49c6 --- /dev/null +++ b/app/src/main/java/org/dhis2/utils/ThemeColors.kt @@ -0,0 +1,27 @@ +package org.dhis2.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import org.dhis2.R +import org.dhis2.commons.resources.ColorUtils + +@Composable +fun getThemePrimaryColor(): Color { + val context = LocalContext.current + val primaryColor = remember(context) { ColorUtils().getThemePrimaryColor(context) } + return primaryColor +} + +@Composable +fun getOnToolbarColor(): Color { + val context = LocalContext.current + val onToolbarColor = remember(context) { + val ta = context.obtainStyledAttributes(intArrayOf(R.attr.onToolbarColor)) + val color = Color(ta.getColor(0, android.graphics.Color.WHITE)) + ta.recycle() + color + } + return onToolbarColor +} \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/utils/ValueUtils.kt b/app/src/main/java/org/dhis2/utils/ValueUtils.kt index 26c9d9ed39a..8031fba6bc8 100644 --- a/app/src/main/java/org/dhis2/utils/ValueUtils.kt +++ b/app/src/main/java/org/dhis2/utils/ValueUtils.kt @@ -3,6 +3,7 @@ package org.dhis2.utils import org.dhis2.commons.extensions.toFriendlyDate import org.dhis2.commons.extensions.toFriendlyDateTime import org.dhis2.commons.extensions.toPercentage +import org.dhis2.tracker.input.model.TrackerInputType import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue @@ -20,7 +21,7 @@ class ValueUtils private constructor() { optionSetUid: String?, ): TrackedEntityAttributeValue { val transformedValue = - transformValue(d2, attributeValue.value(), valueType, optionSetUid) + transformValueLegacy(d2, attributeValue.value(), valueType, optionSetUid) return if (transformedValue != attributeValue.value()) { attributeValue @@ -35,12 +36,12 @@ class ValueUtils private constructor() { fun transformValue( d2: D2, value: String?, - valueType: ValueType?, + valueType: TrackerInputType?, optionSetUid: String?, ): String? { var teAttrValue = value when (valueType) { - ValueType.ORGANISATION_UNIT -> { + TrackerInputType.ORGANISATION_UNIT -> { if (!d2 .organisationUnitModule() .organisationUnits() @@ -60,6 +61,45 @@ class ValueUtils private constructor() { teAttrValue = orgUnitName } } + TrackerInputType.DATE, TrackerInputType.AGE -> { + teAttrValue = teAttrValue?.toFriendlyDate() + } + TrackerInputType.DATE_TIME -> { + teAttrValue = teAttrValue?.toFriendlyDateTime() + } + + TrackerInputType.PERCENTAGE -> { + teAttrValue = teAttrValue?.toPercentage() + } + else -> { + teAttrValue = transformOptionSet(optionSetUid, d2, value) + } + } + return teAttrValue + } + + @Deprecated("Use transformValue with TrackerInputType instead of ValueType") + fun transformValueLegacy( + d2: D2, + value: String?, + valueType: ValueType?, + optionSetUid: String?, + ): String? { + var teAttrValue = value + when (valueType) { + ValueType.ORGANISATION_UNIT -> { + val orgUnit = + d2 + .organisationUnitModule() + .organisationUnits() + .byUid() + .eq(value) + .one() + .blockingGet() + if (orgUnit != null && orgUnit.displayName() != null) { + teAttrValue = orgUnit.displayName() + } + } ValueType.DATE, ValueType.AGE -> { teAttrValue = teAttrValue?.toFriendlyDate() } diff --git a/app/src/main/java/org/dhis2/utils/WebViewActivity.kt b/app/src/main/java/org/dhis2/utils/WebViewActivity.kt deleted file mode 100644 index 386312314cf..00000000000 --- a/app/src/main/java/org/dhis2/utils/WebViewActivity.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.dhis2.utils - -import android.annotation.SuppressLint -import android.app.Activity -import android.os.Bundle -import android.view.View -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.databinding.DataBindingUtil -import org.dhis2.R -import org.dhis2.databinding.ActivityWebviewBinding - -class WebViewActivity : Activity() { - companion object { - const val WEB_VIEW_URL = "url" - } - - @SuppressLint("SetJavaScriptEnabled") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val binding: ActivityWebviewBinding = - DataBindingUtil.setContentView(this, R.layout.activity_webview) - - val url = intent?.extras?.getString(WEB_VIEW_URL) - - url?.let { - // Avoid the WebView to automatically redirect to a browser - binding.webView.webViewClient = - object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest, - ): Boolean = super.shouldOverrideUrlLoading(view, request) - - // Compatibility with APIs below 24 - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading( - view: WebView?, - url: String?, - ): Boolean = super.shouldOverrideUrlLoading(view, url) - } - - binding.webView.settings.javaScriptEnabled = true - binding.webView.loadUrl(it) - } - } - - fun backToLogin(view: View) { - finish() - } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - finish() - } -} diff --git a/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt b/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt index 3812af80ab1..3300d7e70d1 100644 --- a/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt +++ b/app/src/main/java/org/dhis2/utils/analytics/AnalyticsHelper.kt @@ -2,6 +2,7 @@ package org.dhis2.utils.analytics import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.mobile.commons.reporting.AnalyticActions +import org.dhis2.utils.analytics.matomo.DEFAULT_EXTERNAL_TRACKER_NAME import javax.inject.Inject class AnalyticsHelper @@ -28,12 +29,11 @@ class AnalyticsHelper override fun updateMatomoSecondaryTracker( matomoUrl: String, matomoID: Int, - trackerName: String, ) { matomoAnalyticsController.updateDhisImplementationTracker( matomoUrl, matomoID, - trackerName, + DEFAULT_EXTERNAL_TRACKER_NAME, ) } diff --git a/app/src/main/java/org/dhis2/utils/analytics/matomo/MatomoAnalyticsModule.kt b/app/src/main/java/org/dhis2/utils/analytics/matomo/MatomoAnalyticsModule.kt index bdf72ac4bf2..5e39d3ad262 100644 --- a/app/src/main/java/org/dhis2/utils/analytics/matomo/MatomoAnalyticsModule.kt +++ b/app/src/main/java/org/dhis2/utils/analytics/matomo/MatomoAnalyticsModule.kt @@ -6,6 +6,7 @@ import dagger.Provides import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.mobile.commons.reporting.AnalyticActions import org.dhis2.utils.analytics.AnalyticsHelper +import org.koin.dsl.bind import org.koin.dsl.module import org.matomo.sdk.Matomo import org.matomo.sdk.extra.DownloadTracker @@ -34,5 +35,5 @@ val matomoModule = single { DownloadTracker.Extra.ApkChecksum(get()) } single { Matomo.getInstance(get()) } single { MatomoAnalyticsControllerImpl(get(), get()) } - single { AnalyticsHelper(get()) } + single { AnalyticsHelper(get()) } bind AnalyticActions::class } diff --git a/app/src/main/java/org/dhis2/utils/customviews/MoreMenuView.kt b/app/src/main/java/org/dhis2/utils/customviews/MoreMenuView.kt index f71d73954e1..9c90aaabe24 100644 --- a/app/src/main/java/org/dhis2/utils/customviews/MoreMenuView.kt +++ b/app/src/main/java/org/dhis2/utils/customviews/MoreMenuView.kt @@ -9,11 +9,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset +import org.dhis2.utils.getOnToolbarColor import org.hisp.dhis.mobile.ui.designsystem.component.IconButton import org.hisp.dhis.mobile.ui.designsystem.component.menu.DropDownMenu import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemData import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @Composable fun MoreOptionsWithDropDownMenuButton( @@ -22,12 +22,13 @@ fun MoreOptionsWithDropDownMenuButton( onMenuToggle: (Boolean) -> Unit, onItemClick: (T) -> Unit, ) { + val onToolbarColor = getOnToolbarColor() IconButton( modifier = Modifier, icon = { Icon( imageVector = Icons.Outlined.MoreVert, - tint = SurfaceColor.SurfaceBright, + tint = onToolbarColor, contentDescription = null, ) }, diff --git a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt b/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt index 0448e4f7353..b93a311b621 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncPresenter.kt @@ -34,7 +34,6 @@ import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.WorkInfo import io.reactivex.disposables.CompositeDisposable -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -59,9 +58,17 @@ import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.data.service.workManager.WorkerItem import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.sms.SmsSendingService +import org.dhis2.utils.granularsync.data.GranularSyncRepository +import org.dhis2.utils.granularsync.domain.MissingSyncTargetException +import org.dhis2.utils.granularsync.domain.SyncStatus +import org.dhis2.utils.granularsync.ui.SyncUiState +import org.dhis2.utils.granularsync.ui.SyncUiStateMapper import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import timber.log.Timber class GranularSyncPresenter( @@ -73,7 +80,9 @@ class GranularSyncPresenter( private val syncContext: SyncContext, private val workManagerController: WorkManagerController, private val smsSyncProvider: SMSSyncProvider, -) : ViewModel() { + private val mapper: SyncUiStateMapper, +) : ViewModel(), + KoinComponent { private val workerName: String private var disposable: CompositeDisposable = CompositeDisposable() private lateinit var states: MutableLiveData> @@ -82,23 +91,29 @@ class GranularSyncPresenter( private val _currentState = MutableStateFlow(null) val currentState: StateFlow = _currentState + private val syncBackgroundJobAction: SyncBackgroundJobAction by inject() + init { workerName = workerName() } - private val _serverAvailability = MutableLiveData() - val serverAvailability: LiveData = _serverAvailability + private val _serverAvailability = MutableLiveData() + val serverAvailability: LiveData = _serverAvailability private fun loadSyncInfo(forcedState: State? = null) { viewModelScope.launch(dispatcher.io()) { - val syncState = - async { - repository.getUiState(forcedState) - }.await() - val dismissOnUpdate = refreshing && syncState.syncState == State.SYNCED - refreshing = false - _currentState.update { - syncState.copy(shouldDismissOnUpdate = dismissOnUpdate) + try { + val syncStatusData = repository.getSyncStatus(forcedState) + val dismissOnUpdate = refreshing && syncStatusData.syncState == SyncStatus.SYNCED + refreshing = false + _currentState.update { + mapper.toUiState(syncStatusData, dismissOnUpdate) + } + } catch (e: MissingSyncTargetException) { + refreshing = false + _currentState.update { + mapper.missingTargetUiState(e.recordUid) + } } } } @@ -172,7 +187,7 @@ class GranularSyncPresenter( workManagerController.beginUniqueWork(workerItem) } else { - workManagerController.syncDataForWorker(Constants.DATA_NOW, Constants.INITIAL_SYNC) + syncBackgroundJobAction.launchDataSync(0) } } return observeWorkInfo() @@ -392,7 +407,7 @@ class GranularSyncPresenter( restartSmsSender() } - private suspend fun getDataSetCatOptCombos(): List { + private fun getDataSetCatOptCombos(): List { val dataSet = d2 .dataSetModule() @@ -450,11 +465,12 @@ class GranularSyncPresenter( } fun checkServerAvailability() { + _serverAvailability.value = null viewModelScope.launch { try { repository.checkServerAvailability() _serverAvailability.value = true - } catch (error: RuntimeException) { + } catch (_: RuntimeException) { _serverAvailability.value = false } } diff --git a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncViewModelFactory.kt b/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncViewModelFactory.kt index 643bffcb0ac..981a8803f0d 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncViewModelFactory.kt @@ -6,6 +6,8 @@ import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.utils.granularsync.data.GranularSyncRepository +import org.dhis2.utils.granularsync.ui.SyncUiStateMapper import org.hisp.dhis.android.core.D2 class GranularSyncViewModelFactory( @@ -17,6 +19,7 @@ class GranularSyncViewModelFactory( private val syncContext: SyncContext, private val workManagerController: WorkManagerController, private val smsSyncProvider: SMSSyncProvider, + private val mapper: SyncUiStateMapper, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = GranularSyncPresenter( @@ -28,5 +31,6 @@ class GranularSyncViewModelFactory( syncContext, workManagerController, smsSyncProvider, + mapper, ) as T } diff --git a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt index b37d679c104..0f56430f4f2 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusDialog.kt @@ -21,7 +21,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels -import androidx.work.WorkInfo import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -32,16 +31,14 @@ import org.dhis2.commons.date.toDateSpan import org.dhis2.commons.dialogs.bottomsheet.BottomSheetDialogUi import org.dhis2.commons.dialogs.bottomsheet.BottomSheetDialogUiModel import org.dhis2.commons.dialogs.bottomsheet.DialogButtonStyle -import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.sync.OnDismissListener import org.dhis2.commons.sync.OnNoConnectionListener import org.dhis2.commons.sync.OnSyncNavigationListener import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.ui.icons.SyncStateIcon import org.dhis2.usescases.sms.SmsSendingService -import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.customviews.MessageAmountDialog -import org.hisp.dhis.android.core.common.State +import org.dhis2.utils.granularsync.domain.SyncStatus import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme import javax.inject.Inject @@ -51,18 +48,11 @@ private const val SYNC_CONTEXT = "SYNC_CONTEXT" class SyncStatusDialog : BottomSheetDialogFragment(), GranularSyncContracts.View { - @Inject - lateinit var analyticsHelper: AnalyticsHelper - - @Inject - lateinit var networkUtils: NetworkUtils var dismissListenerDialog: OnDismissListener? = null var syncStatusDialogNavigator: SyncStatusDialogNavigator? = null - var syncProcessListener: (WorkInfo) -> Unit = {} - private var syncing: Boolean = false private var smsSenderHelper: SMSSenderHelper? = null @@ -90,7 +80,7 @@ class SyncStatusDialog : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, org.dhis2.ui.R.style.CustomBottomSheetDialogTheme) + setStyle(STYLE_NORMAL, org.dhis2.commons.R.style.CustomBottomSheetDialogTheme) } override fun onCreateView( @@ -101,7 +91,6 @@ class SyncStatusDialog : viewModel.observeWorkInfo().observe(this) { workInfoList -> workInfoList.firstOrNull()?.let { viewModel.manageWorkInfo(it) - syncProcessListener(it) } } @@ -113,7 +102,7 @@ class SyncStatusDialog : syncState?.let { syncUiState -> when { syncUiState.shouldDismissOnUpdate -> dismiss() - syncing && syncUiState.syncState == State.SYNCED -> { + syncing && syncUiState.syncState == SyncStatus.SYNCED -> { dismiss() Toast .makeText( @@ -149,7 +138,7 @@ class SyncStatusDialog : dismiss() }, icon = { - SyncStateIcon(state = syncUiState.syncState) + SyncUiStatusIcon(status = syncUiState.syncState) }, extraContent = if (syncUiState.content.isNotEmpty()) { @@ -188,7 +177,9 @@ class SyncStatusDialog : } private fun onSyncClick() { + viewModel.serverAvailability.removeObservers(viewLifecycleOwner) viewModel.serverAvailability.observe(viewLifecycleOwner) { isAvailable -> + if (isAvailable == null) return@observe val canSendSMS = viewModel.canSendSMS() val isSMSEnabled = viewModel.isSMSEnabled() @@ -330,7 +321,7 @@ class SyncStatusDialog : SmsSendingService.State.WAITING_RESULT, SmsSendingService.State.RESULT_CONFIRMED, SmsSendingService.State.SENT, - -> + -> syncing = true SmsSendingService.State.ITEM_NOT_READY, @@ -338,7 +329,7 @@ class SyncStatusDialog : SmsSendingService.State.WAITING_RESULT_TIMEOUT, SmsSendingService.State.ERROR, SmsSendingService.State.COMPLETED, - -> + -> viewModel.restartSmsSender() } } @@ -384,7 +375,6 @@ class SyncStatusDialog : private lateinit var syncContext: SyncContext private var dismissListener: OnDismissListener? = null private var noConnectionListener: OnNoConnectionListener? = null - private var syncProcessStatusListener: (WorkInfo) -> Unit = {} fun withContext( context: FragmentActivity, @@ -414,11 +404,6 @@ class SyncStatusDialog : return this } - fun withSyncProcessStatusListener(syncProcessStatusListener: (WorkInfo) -> Unit): Builder { - this.syncProcessStatusListener = syncProcessStatusListener - return this - } - fun withSyncContext(syncContext: SyncContext): Builder { this.syncContext = syncContext return this @@ -443,7 +428,6 @@ class SyncStatusDialog : dismissListenerDialog = dismissListener onNoConnectionListener = noConnectionListener syncStatusDialogNavigator = navigator - syncProcessListener = syncProcessStatusListener } fun show(tag: String) { diff --git a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt index 7d87562e767..03ef9ae08ed 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/SyncStatusItem.kt @@ -18,7 +18,11 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.dhis2.ui.R +import androidx.core.graphics.toColorInt +import org.dhis2.commons.R +import org.dhis2.commons.ui.icons.SyncingIcon +import org.dhis2.utils.granularsync.domain.SyncStatus +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor @Composable fun SyncStatusItem( @@ -59,6 +63,61 @@ fun SyncStatusItem( } } +@Composable +fun SyncUiStatusIcon(status: SyncStatus) { + if (status == SyncStatus.UPLOADING) { + SyncingIcon() + } else { + val (imageVector, tint) = status.toIconData() + Icon( + imageVector = imageVector, + tint = tint, + contentDescription = "sync", + ) + } +} + +@Composable +private fun SyncStatus.toIconData(): Pair { + val imageVector = + when (this) { + SyncStatus.NOT_SYNCED, + SyncStatus.UPLOADING, + -> ImageVector.vectorResource(id = R.drawable.ic_sync_problem_grey) + + SyncStatus.ERROR -> + ImageVector.vectorResource(id = R.drawable.ic_sync_problem_red) + + SyncStatus.WARNING -> + ImageVector.vectorResource(id = R.drawable.ic_sync_warning) + + SyncStatus.SENT_VIA_SMS, + SyncStatus.SYNCED_VIA_SMS, + -> ImageVector.vectorResource(id = R.drawable.ic_sync_sms) + + SyncStatus.SYNCED, + SyncStatus.RELATIONSHIP, + -> ImageVector.vectorResource(id = R.drawable.ic_status_synced) + } + val tint = + when (this) { + SyncStatus.NOT_SYNCED, + SyncStatus.UPLOADING, + -> Color("#333333".toColorInt()) + + SyncStatus.ERROR -> Color("#E91E63".toColorInt()) + SyncStatus.WARNING -> Color("#FF9800".toColorInt()) + SyncStatus.SENT_VIA_SMS, + SyncStatus.SYNCED_VIA_SMS, + -> Color("#03A9F4".toColorInt()) + + SyncStatus.SYNCED, + SyncStatus.RELATIONSHIP, + -> TextColor.OnSurfaceLight + } + return Pair(imageVector, tint) +} + @Preview(showBackground = true) @Composable fun SyncStatusItemPreview() { diff --git a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncRepository.kt b/app/src/main/java/org/dhis2/utils/granularsync/data/GranularSyncRepository.kt similarity index 79% rename from app/src/main/java/org/dhis2/utils/granularsync/GranularSyncRepository.kt rename to app/src/main/java/org/dhis2/utils/granularsync/data/GranularSyncRepository.kt index 31eecdd3c15..cc2bcc13c38 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/GranularSyncRepository.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/data/GranularSyncRepository.kt @@ -1,12 +1,14 @@ -package org.dhis2.utils.granularsync +package org.dhis2.utils.granularsync.data -import io.reactivex.Single +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import org.dhis2.R import org.dhis2.commons.bindings.categoryOptionCombo import org.dhis2.commons.bindings.countEventImportConflicts import org.dhis2.commons.bindings.countTeiImportConflicts import org.dhis2.commons.bindings.dataElement +import org.dhis2.commons.bindings.dataSet import org.dhis2.commons.bindings.dataSetInstanceSummaries import org.dhis2.commons.bindings.dataSetInstancesBy import org.dhis2.commons.bindings.dataSetSummaryBy @@ -19,13 +21,10 @@ import org.dhis2.commons.bindings.event import org.dhis2.commons.bindings.eventImportConflictsBy import org.dhis2.commons.bindings.eventsBy import org.dhis2.commons.bindings.isStockProgram -import org.dhis2.commons.bindings.observeDataSetInstancesBy -import org.dhis2.commons.bindings.observeEvent -import org.dhis2.commons.bindings.observeProgram -import org.dhis2.commons.bindings.observeTei import org.dhis2.commons.bindings.organisationUnit import org.dhis2.commons.bindings.period import org.dhis2.commons.bindings.program +import org.dhis2.commons.bindings.programStage import org.dhis2.commons.bindings.programs import org.dhis2.commons.bindings.stockUseCase import org.dhis2.commons.bindings.tei @@ -44,6 +43,10 @@ import org.dhis2.commons.sync.SyncStatusItem import org.dhis2.commons.sync.SyncStatusType import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisProgramUtils +import org.dhis2.utils.granularsync.domain.MissingSyncTargetException +import org.dhis2.utils.granularsync.SyncDate +import org.dhis2.utils.granularsync.domain.SyncStatus +import org.dhis2.utils.granularsync.domain.SyncStatusData import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.dataset.DataSetInstance @@ -61,246 +64,125 @@ class GranularSyncRepository( private val resourceManager: ResourceManager, private val dispatcher: DispatcherProvider, ) { - fun getUiState(forcedState: State? = null): SyncUiState = - Single - .zip( - getState(), - getLastSynced(), - ) { state, lastSync -> - buildUiState( - forcedState.takeIf { it != null } ?: state, - lastSync, - ) - }.blockingGet() + suspend fun getSyncStatus(forcedState: State? = null): SyncStatusData = + coroutineScope { + val lastSync = async { getLastSynced() } + val state = forcedState ?: getState() + SyncStatusData( + syncState = state.toSyncStatus(), + lastSyncDate = lastSync.await().takeIf { state != State.UPLOADING }?.date, + content = getContent(state), + targetName = getTitle(), + ) + } - private fun getState(): Single = + private fun getState(): State = when (syncContext.conflictType()) { ConflictType.PROGRAM -> d2 - .observeProgram(syncContext.recordUid()) - .map { dhisProgramUtils.getProgramState(it) } + .program(syncContext.recordUid()) + ?.let { dhisProgramUtils.getProgramState(it) } + ?: throw missingSyncTargetException() ConflictType.TEI -> { val enrollment = d2.enrollment(syncContext.recordUid()) d2 - .observeTei(enrollment?.trackedEntityInstance() ?: syncContext.recordUid()) - .map { it.aggregatedSyncState() } + .tei(enrollment?.trackedEntityInstance() ?: syncContext.recordUid()) + ?.aggregatedSyncState() + ?: throw missingSyncTargetException() } ConflictType.EVENT -> - d2.observeEvent(syncContext.recordUid()).map { it.aggregatedSyncState() } + d2.event(syncContext.recordUid())?.aggregatedSyncState() ?: throw missingSyncTargetException() - ConflictType.DATA_SET -> - d2 - .observeDataSetInstancesBy(syncContext.recordUid()) - .map { dataSetInstances -> - getStateFromCandidates(dataSetInstances.map { it.state() }.toMutableList()) - } + ConflictType.DATA_SET -> { + d2.dataSet(syncContext.recordUid()) ?: throw missingSyncTargetException() + getStateFromCandidates( + d2 + .dataSetInstancesBy(syncContext.recordUid()) + .map { it.state() } + .toMutableList(), + ) + } ConflictType.DATA_VALUES -> with(syncContext as SyncContext.DataSetInstance) { - d2 - .observeDataSetInstancesBy( - dataSetUid = dataSetUid, - orgUnitUid = orgUnitUid, - periodId = periodId, - attrOptionComboUid = attributeOptionComboUid, - ).map { dataSetInstance -> - getStateFromCandidates(dataSetInstance.map { it.state() }.toMutableList()) - } + val dataSetInstances = + d2 + .dataSetModule() + .dataSetInstances() + .byDataSetUid() + .eq(dataSetUid) + .byOrganisationUnitUid() + .eq(orgUnitUid) + .byPeriod() + .eq(periodId) + .byAttributeOptionComboUid() + .eq(attributeOptionComboUid) + .blockingGet() + + if (dataSetInstances.isEmpty()) { + throw missingSyncTargetException() + } + + getStateFromCandidates( + dataSetInstances + .map { it.state() } + .toMutableList(), + ) } - ConflictType.ALL -> Single.just(dhisProgramUtils.getServerState()) + ConflictType.ALL -> dhisProgramUtils.getServerState() } - private fun getLastSynced(returnEmpty: Boolean = true): Single = + private fun getLastSynced(returnEmpty: Boolean = true): SyncDate = if (returnEmpty) { - Single.just(SyncDate(null)) + SyncDate(null) } else { when (syncContext.conflictType()) { ConflictType.PROGRAM -> - d2.observeProgram(syncContext.recordUid()).map { SyncDate(it.lastUpdated()) } + SyncDate(d2.program(syncContext.recordUid())?.lastUpdated()) ConflictType.TEI -> { val enrollment = d2.enrollment(syncContext.recordUid()) - d2 - .observeTei(enrollment?.trackedEntityInstance() ?: syncContext.recordUid()) - .map { SyncDate(it.lastUpdated()) } + SyncDate( + d2 + .tei(enrollment?.trackedEntityInstance() ?: syncContext.recordUid()) + ?.lastUpdated(), + ) } ConflictType.EVENT -> - d2.observeEvent(syncContext.recordUid()).map { SyncDate(it.lastUpdated()) } + SyncDate(d2.event(syncContext.recordUid())?.lastUpdated()) ConflictType.DATA_SET -> - d2 - .dataSetModule() - .dataSets() - .uid(syncContext.recordUid()) - .get() - .map { dataSet -> SyncDate(dataSet.lastUpdated()) } + SyncDate(d2.dataSet(syncContext.recordUid())?.lastUpdated()) ConflictType.DATA_VALUES -> with(syncContext as SyncContext.DataSetInstance) { - d2 - .observeDataSetInstancesBy( - dataSetUid = dataSetUid, - orgUnitUid = orgUnitUid, - periodId = periodId, - attrOptionComboUid = attributeOptionComboUid, - ).map { dataSetInstance -> - dataSetInstance.sortedBy { it.lastUpdated() } - SyncDate( - dataSetInstance - .apply { - sortedBy { it.lastUpdated() } - }.first() - .lastUpdated(), - ) - } + SyncDate( + d2 + .dataSetModule() + .dataSetInstances() + .byDataSetUid() + .eq(dataSetUid) + .byOrganisationUnitUid() + .eq(orgUnitUid) + .byPeriod() + .eq(periodId) + .byAttributeOptionComboUid() + .eq(attributeOptionComboUid) + .blockingGet() + .mapNotNull { it.lastUpdated() } + .minOrNull(), + ) } ConflictType.ALL -> - Single.just(SyncDate(preferenceProvider.lastSync())) + SyncDate(preferenceProvider.lastSync()) } } - private fun buildUiState( - state: State, - lastSync: SyncDate, - ): SyncUiState = - SyncUiState( - syncState = state, - title = getTitleForState(state), - lastSyncDate = lastSync.takeIf { state != State.UPLOADING }, - message = getMessageForState(state), - mainActionLabel = getMainActionLabel(state), - secondaryActionLabel = getSecondaryActionLabel(state), - content = getContent(state), - ) - - private fun getTitleForState(state: State): String = - when (state) { - State.TO_POST, - State.TO_UPDATE, - -> resourceManager.getString(R.string.sync_dialog_title_not_synced) - - State.ERROR -> resourceManager.getString(R.string.sync_dialog_title_error) - State.RELATIONSHIP, - State.SYNCED, - -> resourceManager.getString(R.string.sync_dialog_title_synced) - - State.WARNING -> resourceManager.getString(R.string.sync_dialog_title_warning) - State.UPLOADING -> resourceManager.getString(R.string.sync_dialog_title_syncing) - State.SENT_VIA_SMS, - State.SYNCED_VIA_SMS, - -> resourceManager.getString(R.string.sync_dialog_title_sms_syced) - } - - private fun getMessageForState(state: State): String? = - when (state) { - State.TO_POST, - State.TO_UPDATE, - -> getNotSyncedMessage() - - State.SYNCED -> getSyncedMessage() - State.SENT_VIA_SMS, - State.SYNCED_VIA_SMS, - -> getSmsSyncedMessage() - - State.ERROR, - State.WARNING, - State.RELATIONSHIP, - State.UPLOADING, - -> null - } - - private fun getNotSyncedMessage(): String = - when (syncContext.conflictType()) { - ConflictType.ALL -> - resourceManager.getString(R.string.sync_dialog_message_not_synced_all) - - ConflictType.DATA_SET, - ConflictType.DATA_VALUES, - ConflictType.PROGRAM, - -> - resourceManager.getString( - R.string.sync_dialog_message_not_synced_program, - getMessageArgument(), - ) - - ConflictType.TEI, ConflictType.EVENT -> - resourceManager.getString( - R.string.sync_dialog_message_not_synced_tei, - getMessageArgument(), - ) - } - - private fun getSyncedMessage(): String = - when (syncContext.conflictType()) { - ConflictType.ALL -> resourceManager.getString(R.string.sync_dialog_message_synced_all) - ConflictType.DATA_SET, - ConflictType.DATA_VALUES, - ConflictType.PROGRAM, - -> - resourceManager.getString( - R.string.sync_dialog_message_synced_program, - getMessageArgument(), - ) - - ConflictType.TEI, ConflictType.EVENT -> - resourceManager.getString( - R.string.sync_dialog_message_synced_tei, - getMessageArgument(), - ) - } - - private fun getSmsSyncedMessage(): String? = - when (syncContext.conflictType()) { - ConflictType.ALL, - ConflictType.DATA_SET, - ConflictType.PROGRAM, - -> null - - ConflictType.DATA_VALUES, - ConflictType.TEI, - ConflictType.EVENT, - -> resourceManager.getString(R.string.sync_dialog_message_sms_synced) - } - - private fun getMainActionLabel(state: State): String? = - when (state) { - State.TO_POST, - State.SENT_VIA_SMS, - State.SYNCED_VIA_SMS, - State.TO_UPDATE, - -> resourceManager.getString(R.string.sync_dialog_action_send) - - State.ERROR, - State.SYNCED, - State.WARNING, - -> resourceManager.getString(R.string.sync_dialog_action_refresh) - - State.UPLOADING, - State.RELATIONSHIP, - -> null - } - - private fun getSecondaryActionLabel(state: State): String? = - when (state) { - State.UPLOADING, - State.RELATIONSHIP, - -> null - - State.TO_POST, - State.TO_UPDATE, - State.ERROR, - State.SYNCED, - State.SENT_VIA_SMS, - State.SYNCED_VIA_SMS, - State.WARNING, - -> resourceManager.getString(R.string.sync_dialog_action_not_now) - } - private fun getContent(state: State): List = when (state) { State.TO_POST, @@ -1020,61 +902,55 @@ class GranularSyncRepository( } } - private fun getMessageArgument(): String = getTitle().blockingGet() + private fun State.toSyncStatus(): SyncStatus = + when (this) { + State.TO_POST, + State.TO_UPDATE, + -> SyncStatus.NOT_SYNCED + + State.ERROR -> SyncStatus.ERROR + State.WARNING -> SyncStatus.WARNING + State.SYNCED -> SyncStatus.SYNCED + State.RELATIONSHIP -> SyncStatus.RELATIONSHIP + State.UPLOADING -> SyncStatus.UPLOADING + State.SENT_VIA_SMS -> SyncStatus.SENT_VIA_SMS + State.SYNCED_VIA_SMS -> SyncStatus.SYNCED_VIA_SMS + } + + private fun missingSyncTargetException(): MissingSyncTargetException = + MissingSyncTargetException(syncContext.recordUid()) - private fun getTitle(): Single = + private fun getTitle(): String = when (syncContext.conflictType()) { - ConflictType.ALL -> Single.just("") + ConflictType.ALL -> "" ConflictType.PROGRAM -> d2 - .programModule() - .programs() - .uid(syncContext.recordUid()) - .get() - .map { it.displayName() } + .program(syncContext.recordUid()) + ?.displayName() + ?: syncContext.recordUid() ConflictType.TEI -> { val enrollment = - d2 - .enrollmentModule() - .enrollments() - .uid(syncContext.recordUid()) - .blockingGet() + d2.enrollment(syncContext.recordUid()) d2 - .trackedEntityModule() - .trackedEntityTypes() - .uid( - d2 - .trackedEntityModule() - .trackedEntityInstances() - .uid(enrollment?.trackedEntityInstance() ?: syncContext.recordUid()) - .blockingGet() - ?.trackedEntityType(), - ).get() - .map { it.displayName() } + .tei(enrollment?.trackedEntityInstance() ?: syncContext.recordUid()) + ?.trackedEntityType() + ?.let { d2.trackedEntityType(it)?.displayName() } + ?: syncContext.recordUid() } ConflictType.EVENT -> d2 - .programModule() - .programStages() - .uid( - d2 - .eventModule() - .events() - .uid(syncContext.recordUid()) - .blockingGet() - ?.programStage(), - ).get() - .map { it.displayName() } + .event(syncContext.recordUid()) + ?.programStage() + ?.let { d2.programStage(it)?.displayName() } + ?: syncContext.recordUid() ConflictType.DATA_SET, ConflictType.DATA_VALUES -> d2 - .dataSetModule() - .dataSets() - .uid(syncContext.recordUid()) - .get() - .map { it.displayName() } + .dataSet(syncContext.recordUid()) + ?.displayName() + ?: syncContext.recordUid() } suspend fun checkServerAvailability() = diff --git a/app/src/main/java/org/dhis2/utils/granularsync/domain/MissingSyncTargetException.kt b/app/src/main/java/org/dhis2/utils/granularsync/domain/MissingSyncTargetException.kt new file mode 100644 index 00000000000..99966aaee3c --- /dev/null +++ b/app/src/main/java/org/dhis2/utils/granularsync/domain/MissingSyncTargetException.kt @@ -0,0 +1,5 @@ +package org.dhis2.utils.granularsync.domain + +class MissingSyncTargetException( + val recordUid: String, +) : IllegalStateException("Resource not found: $recordUid") \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/utils/granularsync/domain/SyncStatus.kt b/app/src/main/java/org/dhis2/utils/granularsync/domain/SyncStatus.kt new file mode 100644 index 00000000000..81a2f4a0914 --- /dev/null +++ b/app/src/main/java/org/dhis2/utils/granularsync/domain/SyncStatus.kt @@ -0,0 +1,12 @@ +package org.dhis2.utils.granularsync.domain + +enum class SyncStatus { + NOT_SYNCED, + ERROR, + WARNING, + SYNCED, + RELATIONSHIP, + UPLOADING, + SENT_VIA_SMS, + SYNCED_VIA_SMS, +} \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/utils/granularsync/domain/SyncStatusData.kt b/app/src/main/java/org/dhis2/utils/granularsync/domain/SyncStatusData.kt new file mode 100644 index 00000000000..4c7feaf84ca --- /dev/null +++ b/app/src/main/java/org/dhis2/utils/granularsync/domain/SyncStatusData.kt @@ -0,0 +1,11 @@ +package org.dhis2.utils.granularsync.domain + +import org.dhis2.commons.sync.SyncStatusItem +import java.util.Date + +data class SyncStatusData( + val syncState: SyncStatus, + val lastSyncDate: Date?, + val content: List, + val targetName: String, +) diff --git a/app/src/main/java/org/dhis2/utils/granularsync/SyncUiState.kt b/app/src/main/java/org/dhis2/utils/granularsync/ui/SyncUiState.kt similarity index 64% rename from app/src/main/java/org/dhis2/utils/granularsync/SyncUiState.kt rename to app/src/main/java/org/dhis2/utils/granularsync/ui/SyncUiState.kt index 92dbe1e8dd5..553d86bd675 100644 --- a/app/src/main/java/org/dhis2/utils/granularsync/SyncUiState.kt +++ b/app/src/main/java/org/dhis2/utils/granularsync/ui/SyncUiState.kt @@ -1,10 +1,11 @@ -package org.dhis2.utils.granularsync +package org.dhis2.utils.granularsync.ui import org.dhis2.commons.sync.SyncStatusItem -import org.hisp.dhis.android.core.common.State +import org.dhis2.utils.granularsync.SyncDate +import org.dhis2.utils.granularsync.domain.SyncStatus data class SyncUiState( - val syncState: State, + val syncState: SyncStatus, val title: String, val lastSyncDate: SyncDate?, val message: String?, @@ -12,4 +13,4 @@ data class SyncUiState( val secondaryActionLabel: String?, val content: List, val shouldDismissOnUpdate: Boolean = false, -) +) \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/utils/granularsync/ui/SyncUiStateMapper.kt b/app/src/main/java/org/dhis2/utils/granularsync/ui/SyncUiStateMapper.kt new file mode 100644 index 00000000000..34a98ac2b9b --- /dev/null +++ b/app/src/main/java/org/dhis2/utils/granularsync/ui/SyncUiStateMapper.kt @@ -0,0 +1,160 @@ +package org.dhis2.utils.granularsync.ui + +import org.dhis2.R +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.sync.ConflictType +import org.dhis2.commons.sync.SyncContext +import org.dhis2.utils.granularsync.SyncDate +import org.dhis2.utils.granularsync.domain.SyncStatus +import org.dhis2.utils.granularsync.domain.SyncStatusData + +class SyncUiStateMapper( + private val syncContext: SyncContext, + private val resourceManager: ResourceManager, +) { + fun toUiState( + data: SyncStatusData, + shouldDismissOnUpdate: Boolean = false, + ): SyncUiState = + SyncUiState( + syncState = data.syncState, + title = getTitleForState(data.syncState), + lastSyncDate = data.lastSyncDate?.let { SyncDate(it) }, + message = getMessageForState(data.syncState, data.targetName), + mainActionLabel = getMainActionLabel(data.syncState), + secondaryActionLabel = getSecondaryActionLabel(data.syncState), + content = data.content, + shouldDismissOnUpdate = shouldDismissOnUpdate, + ) + + fun missingTargetUiState(recordUid: String): SyncUiState = + SyncUiState( + syncState = SyncStatus.ERROR, + title = getTitleForState(SyncStatus.ERROR), + lastSyncDate = null, + message = resourceManager.getString(R.string.resource_not_found, recordUid), + mainActionLabel = getMainActionLabel(SyncStatus.ERROR), + secondaryActionLabel = getSecondaryActionLabel(SyncStatus.ERROR), + content = emptyList(), + ) + + private fun getTitleForState(status: SyncStatus): String = + when (status) { + SyncStatus.NOT_SYNCED -> + resourceManager.getString(R.string.sync_dialog_title_not_synced) + + SyncStatus.ERROR -> resourceManager.getString(R.string.sync_dialog_title_error) + SyncStatus.RELATIONSHIP, + SyncStatus.SYNCED, + -> resourceManager.getString(R.string.sync_dialog_title_synced) + + SyncStatus.WARNING -> resourceManager.getString(R.string.sync_dialog_title_warning) + SyncStatus.UPLOADING -> resourceManager.getString(R.string.sync_dialog_title_syncing) + SyncStatus.SENT_VIA_SMS, + SyncStatus.SYNCED_VIA_SMS, + -> resourceManager.getString(R.string.sync_dialog_title_sms_syced) + } + + private fun getMessageForState( + status: SyncStatus, + targetName: String, + ): String? = + when (status) { + SyncStatus.NOT_SYNCED -> getNotSyncedMessage(targetName) + SyncStatus.SYNCED -> getSyncedMessage(targetName) + SyncStatus.SENT_VIA_SMS, + SyncStatus.SYNCED_VIA_SMS, + -> getSmsSyncedMessage() + + SyncStatus.ERROR, + SyncStatus.WARNING, + SyncStatus.RELATIONSHIP, + SyncStatus.UPLOADING, + -> null + } + + private fun getNotSyncedMessage(targetName: String): String = + when (syncContext.conflictType()) { + ConflictType.ALL -> + resourceManager.getString(R.string.sync_dialog_message_not_synced_all) + + ConflictType.DATA_SET, + ConflictType.DATA_VALUES, + ConflictType.PROGRAM, + -> + resourceManager.getString( + R.string.sync_dialog_message_not_synced_program, + targetName, + ) + + ConflictType.TEI, ConflictType.EVENT -> + resourceManager.getString( + R.string.sync_dialog_message_not_synced_tei, + targetName, + ) + } + + private fun getSyncedMessage(targetName: String): String = + when (syncContext.conflictType()) { + ConflictType.ALL -> resourceManager.getString(R.string.sync_dialog_message_synced_all) + ConflictType.DATA_SET, + ConflictType.DATA_VALUES, + ConflictType.PROGRAM, + -> + resourceManager.getString( + R.string.sync_dialog_message_synced_program, + targetName, + ) + + ConflictType.TEI, ConflictType.EVENT -> + resourceManager.getString( + R.string.sync_dialog_message_synced_tei, + targetName, + ) + } + + private fun getSmsSyncedMessage(): String? = + when (syncContext.conflictType()) { + ConflictType.ALL, + ConflictType.DATA_SET, + ConflictType.PROGRAM, + -> null + + ConflictType.DATA_VALUES, + ConflictType.TEI, + ConflictType.EVENT, + -> resourceManager.getString(R.string.sync_dialog_message_sms_synced) + } + + private fun getMainActionLabel(status: SyncStatus): String? = + when (status) { + SyncStatus.NOT_SYNCED, + SyncStatus.SENT_VIA_SMS, + SyncStatus.SYNCED_VIA_SMS, + -> resourceManager.getString(R.string.sync_dialog_action_send) + + SyncStatus.ERROR, + SyncStatus.SYNCED, + SyncStatus.WARNING, + -> resourceManager.getString(R.string.sync_dialog_action_refresh) + + SyncStatus.UPLOADING, + SyncStatus.RELATIONSHIP, + -> null + } + + private fun getSecondaryActionLabel(status: SyncStatus): String? = + when (status) { + SyncStatus.UPLOADING, + SyncStatus.RELATIONSHIP, + -> null + + SyncStatus.NOT_SYNCED, + SyncStatus.ERROR, + SyncStatus.SYNCED, + SyncStatus.SENT_VIA_SMS, + SyncStatus.SYNCED_VIA_SMS, + SyncStatus.WARNING, + -> resourceManager.getString(R.string.sync_dialog_action_not_now) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dhis2/utils/session/PinDialog.kt b/app/src/main/java/org/dhis2/utils/session/PinDialog.kt deleted file mode 100644 index caa274c6db8..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinDialog.kt +++ /dev/null @@ -1,142 +0,0 @@ -package org.dhis2.utils.session - -import android.app.Dialog -import android.os.Bundle -import android.os.Handler -import android.os.Process -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.Window -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentManager -import org.dhis2.R -import org.dhis2.bindings.app -import org.dhis2.databinding.DialogPinBinding -import javax.inject.Inject - -const val PIN_DIALOG_TAG: String = "PINDIALOG" - -class PinDialog( - val mode: Mode, - private val canBeClosed: Boolean, - private val unlockCallback: () -> Unit, - private val forgotPinCallback: () -> Unit, -) : DialogFragment(), - PinView { - private lateinit var binding: DialogPinBinding - - @Inject - lateinit var presenter: PinPresenter - - enum class Mode { - SET, - ASK, - } - - private var pinAttempts = 0 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NO_TITLE, android.R.style.Theme_DeviceDefault_Light_NoActionBar) - app().createSessionComponent(PinModule(this)).inject(this) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - dialog.window!!.apply { - requestFeature(Window.FEATURE_NO_TITLE) - setBackgroundDrawableResource(android.R.color.transparent) - setWindowAnimations(R.style.pin_dialog_animation) - isCancelable = false - } - return dialog - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - binding = DialogPinBinding.inflate(layoutInflater, container, false) - binding.closeButton.apply { - visibility = if (canBeClosed) View.VISIBLE else View.GONE - setOnClickListener { closeDialog() } - } - - when (mode) { - Mode.ASK -> { - binding.title.text = getString(R.string.unblock_session) - binding.forgotCode.apply { - visibility = View.VISIBLE - setOnClickListener { recoverPin() } - } - } - Mode.SET -> { - binding.title.text = getString(R.string.set_pin) - binding.forgotCode.visibility = View.GONE - } - } - - binding.pinLockView.attachIndicatorDots(binding.indicatorDots) - binding.pinLockView.onPinSet { - when (mode) { - Mode.SET -> { - presenter.savePin(it) - blockSession() - } - Mode.ASK -> - presenter.unlockSession( - it, - attempts = pinAttempts, - onPinCorrect = unlockCallback, - onError = { - pinAttempts += 1 - Toast - .makeText( - context, - getString(R.string.wrong_pin), - Toast.LENGTH_LONG, - ).show() - binding.pinLockView.resetPinLockView() - }, - onTwoManyAttempts = { recoverPin() }, - ) - } - } - - return binding.root - } - - private fun blockSession() { - Handler().postDelayed( - { Process.killProcess(Process.myPid()) }, - 1500, - ) - } - - override fun closeDialog() { - dismissAllowingStateLoss() - } - - override fun dismiss() { - app().releaseSessionComponent() - dismissAllowingStateLoss() - } - - override fun recoverPin() { - presenter.logOut() - forgotPinCallback.invoke() - dismissAllowingStateLoss() - } - - override fun show( - manager: FragmentManager, - tag: String?, - ) { - if (manager.findFragmentByTag(tag) == null) { - manager.beginTransaction().add(this, tag).commitAllowingStateLoss() - } - } -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinExtensions.kt b/app/src/main/java/org/dhis2/utils/session/PinExtensions.kt deleted file mode 100644 index 68c0f5319f3..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinExtensions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.dhis2.utils.session - -import com.andrognito.pinlockview.PinLockListener -import com.andrognito.pinlockview.PinLockView - -inline fun PinLockView.onPinSet(crossinline continuation: (String) -> Unit) { - setPinLockListener( - object : PinLockListener { - override fun onEmpty() { - } - - override fun onComplete(pin: String) { - continuation(pin) - } - - override fun onPinChange( - pinLength: Int, - intermediatePin: String?, - ) { - } - }, - ) -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinModule.kt b/app/src/main/java/org/dhis2/utils/session/PinModule.kt deleted file mode 100644 index b09629983b3..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinModule.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2004 - 2019, University of Oslo - * All rights reserved. - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * Neither the name of the HISP project nor the names of its contributors may - * be used to endorse or promote products derived from this software without - * specific prior written permission. - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.dhis2.utils.session - -import dagger.Module -import dagger.Provides -import org.dhis2.commons.prefs.PreferenceProvider -import org.hisp.dhis.android.core.D2 - -@Module -class PinModule( - val view: PinView, -) { - @Provides - fun providesPresenter( - d2: D2, - preferenceProvider: PreferenceProvider, - ): PinPresenter = PinPresenter(view, preferenceProvider, d2) -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinPresenter.kt b/app/src/main/java/org/dhis2/utils/session/PinPresenter.kt deleted file mode 100644 index ddbd8e97112..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinPresenter.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.dhis2.utils.session - -import org.dhis2.commons.prefs.Preference -import org.dhis2.commons.prefs.PreferenceProvider -import org.hisp.dhis.android.core.D2 -import timber.log.Timber - -class PinPresenter( - val view: PinView, - val preferenceProvider: PreferenceProvider, - val d2: D2, -) { - fun unlockSession( - pin: String, - attempts: Int, - onPinCorrect: () -> Unit, - onError: () -> Unit, - onTwoManyAttempts: () -> Unit, - ) { - val pinStored = - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value() - when { - pinStored == pin -> { - onPinCorrect() - } - attempts < 2 -> onError() - else -> onTwoManyAttempts() - } - } - - fun savePin(pin: String) { - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingSet(pin) - preferenceProvider.setValue(Preference.SESSION_LOCKED, true) - } - - fun logOut() { - try { - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingDelete() - d2.userModule().blockingLogOut() - preferenceProvider.setValue(Preference.SESSION_LOCKED, false) - } catch (e: Exception) { - Timber.e(e) - } - } -} diff --git a/app/src/main/java/org/dhis2/utils/session/PinView.kt b/app/src/main/java/org/dhis2/utils/session/PinView.kt deleted file mode 100644 index 1f5df61eb7d..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/PinView.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.dhis2.utils.session - -interface PinView { - fun closeDialog() - - fun recoverPin() -} diff --git a/app/src/main/java/org/dhis2/utils/session/SessionComponent.kt b/app/src/main/java/org/dhis2/utils/session/SessionComponent.kt deleted file mode 100644 index ea6b8372ca4..00000000000 --- a/app/src/main/java/org/dhis2/utils/session/SessionComponent.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2004 - 2019, University of Oslo - * All rights reserved. - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * Neither the name of the HISP project nor the names of its contributors may - * be used to endorse or promote products derived from this software without - * specific prior written permission. - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package org.dhis2.utils.session - -import dagger.Subcomponent - -@Subcomponent(modules = [PinModule::class]) -interface SessionComponent { - fun inject(pinDialog: PinDialog) -} diff --git a/app/src/main/res/layout-land/activity_dashboard_mobile.xml b/app/src/main/res/layout-land/activity_dashboard_mobile.xml index dbb50705748..c19252df565 100644 --- a/app/src/main/res/layout-land/activity_dashboard_mobile.xml +++ b/app/src/main/res/layout-land/activity_dashboard_mobile.xml @@ -38,7 +38,8 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_arrow_back" - tools:ignore="ContentDescription" /> + tools:ignore="ContentDescription" + app:tint="?onToolbarColor" /> diff --git a/app/src/main/res/layout-land/activity_search.xml b/app/src/main/res/layout-land/activity_search.xml index c529486d080..1c1398cf4d6 100644 --- a/app/src/main/res/layout-land/activity_search.xml +++ b/app/src/main/res/layout-land/activity_search.xml @@ -62,6 +62,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_arrow_back" + app:tint="?onToolbarColor" tools:ignore="ContentDescription" /> @@ -94,13 +95,13 @@ android:id="@+id/totalFilterCount" android:layout_width="match_parent" android:layout_height="match_parent" + android:backgroundTint="?onToolbarColor" android:background="@drawable/ic_oval_shape_white" android:gravity="center" android:includeFontPadding="false" android:text='@{""+totalFilters}' android:textColor="?colorPrimary" android:textSize="10sp" - android:visibility="@{totalFilters>0?View.VISIBLE:View.GONE}" tools:ignore="SmallSp" tools:text="1" /> @@ -110,6 +111,7 @@ style="@style/ActionIcon" android:onClick="@{()->presenter.showFilterGeneral()}" android:visibility="gone" + app:tint="?onToolbarColor" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -124,7 +126,7 @@ app:layout_constraintEnd_toStartOf="@id/filter" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_sync" - app:tint="?colorOnPrimary" + app:tint="?onToolbarColor" tools:ignore="ContentDescription" /> - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_dashboard_mobile.xml b/app/src/main/res/layout/activity_dashboard_mobile.xml index a787d3dbd12..c2a978209eb 100644 --- a/app/src/main/res/layout/activity_dashboard_mobile.xml +++ b/app/src/main/res/layout/activity_dashboard_mobile.xml @@ -42,6 +42,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_arrow_back" + app:tint="?onToolbarColor" tools:ignore="ContentDescription" /> @@ -88,7 +89,7 @@ app:layout_constraintEnd_toStartOf="@id/moreOptions" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_map" - app:tint="@color/white" + app:tint="?onToolbarColor" tools:ignore="ContentDescription" tools:visibility="gone" /> diff --git a/app/src/main/res/layout/activity_dataset_detail.xml b/app/src/main/res/layout/activity_dataset_detail.xml index ff9ea62e1c7..10aaee1ac5d 100644 --- a/app/src/main/res/layout/activity_dataset_detail.xml +++ b/app/src/main/res/layout/activity_dataset_detail.xml @@ -42,6 +42,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:srcCompat="@drawable/ic_arrow_back" + app:tint="?onToolbarColor" tools:ignore="ContentDescription" /> @@ -101,6 +103,7 @@ android:includeFontPadding="false" android:text='@{""+totalFilters}' android:textColor="?colorPrimary" + android:backgroundTint="?onToolbarColor" android:textSize="10sp" android:visibility="@{totalFilters>0?View.VISIBLE:View.GONE}" tools:text="1" /> diff --git a/app/src/main/res/layout/activity_dataset_initial.xml b/app/src/main/res/layout/activity_dataset_initial.xml index 13ae1610f6f..f3030a387e4 100644 --- a/app/src/main/res/layout/activity_dataset_initial.xml +++ b/app/src/main/res/layout/activity_dataset_initial.xml @@ -39,7 +39,8 @@ android:onClick="@{()->presenter.onBackClick()}" android:layout_marginStart="4dp" app:srcCompat="@drawable/ic_arrow_back" - tools:ignore="ContentDescription" /> + tools:ignore="ContentDescription" + app:tint="?onToolbarColor" /> diff --git a/app/src/main/res/layout/activity_event_capture.xml b/app/src/main/res/layout/activity_event_capture.xml index 01e4321e76b..88bb161e684 100644 --- a/app/src/main/res/layout/activity_event_capture.xml +++ b/app/src/main/res/layout/activity_event_capture.xml @@ -35,7 +35,8 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:srcCompat="@drawable/ic_arrow_back" /> + app:srcCompat="@drawable/ic_arrow_back" + app:tint="?onToolbarColor" /> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c8565416d31..8537db061a2 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -8,22 +8,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - + app:layout_constraintTop_toBottomOf="@id/toolbarProgress" + tools:listitem="@layout/item_header_filter" /> + app:layout_constraintTop_toTopOf="parent" /> - - - @@ -193,11 +88,6 @@ app:itemTextColor="@color/text_black_333" app:menu="@menu/main_menu" /> - - diff --git a/app/src/main/res/layout/activity_program_event_detail.xml b/app/src/main/res/layout/activity_program_event_detail.xml index 72e09e38d90..036801f79bc 100644 --- a/app/src/main/res/layout/activity_program_event_detail.xml +++ b/app/src/main/res/layout/activity_program_event_detail.xml @@ -42,6 +42,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/ic_arrow_back" + app:tint="?onToolbarColor" tools:ignore="ContentDescription" /> + tools:ignore="ContentDescription" + app:tint="?onToolbarColor" /> - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_tracked_entity.xml b/app/src/main/res/layout/item_search_tracked_entity.xml index 06af4e8de80..df9d01217d2 100644 --- a/app/src/main/res/layout/item_search_tracked_entity.xml +++ b/app/src/main/res/layout/item_search_tracked_entity.xml @@ -21,7 +21,7 @@ + type="java.util.List<org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain>" /> - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/spinner_program_item_layout.xml b/app/src/main/res/layout/spinner_program_item_layout.xml new file mode 100644 index 00000000000..8b3cebea964 --- /dev/null +++ b/app/src/main/res/layout/spinner_program_item_layout.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/spinner_program_layout.xml b/app/src/main/res/layout/spinner_program_layout.xml index 2baa2e324a2..0a3571cfb61 100644 --- a/app/src/main/res/layout/spinner_program_layout.xml +++ b/app/src/main/res/layout/spinner_program_layout.xml @@ -17,12 +17,13 @@ android:gravity="center_vertical" android:padding="5dp" android:text="@{program.displayName}" - android:textColor="@color/text_black_333" + android:textColor="?onToolbarColor" android:maxLines="1" android:alpha="@{program.downloading ? 0.5f : 1f}" android:clickable="@{program.downloading}" android:ellipsize="end" - android:textSize="16sp" + android:textSize="20sp" + android:drawableTint="?onToolbarColor" tools:text="nameddddddddddddddddddddddddddddddddddddddddddddddddddddddd" tools:drawableEnd="@drawable/ic_arrow_drop_down_black_24dp"/> \ No newline at end of file diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index f17b3bdf1b5..e1eb40687e4 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -50,6 +50,12 @@ android:icon="@drawable/ic_troubleshooting" android:title="@string/main_menu_troubleshooting"/> + + خطأ - إضافة جديد - اسم المستخدم يومي أسبوعي @@ -12,21 +10,24 @@ مشاركة البحث + + فتح مكتمل تم الإلغاء فعال خطأ + + + - إنشاء تحديث - التاريخ - حدث جديد تاريخ الاستحقاق إلغاء حذف + تعديل الرئيسية قبول @@ -34,44 +35,22 @@ الحالة - تاريخ التسجيل - البحث + + - ‏‏الأيام - الأشهر - السنوات - الأسابيع تاريخ الواقعة - مسح + التالي - حفظ السمات علاقات حول مشاركة - الأعلى - تحذير - خطأ حفظ الرئيسية - علاقات - المؤشرات - التفاصيل - المؤشرات - إنهاء - متأخر - الجدول - قبول تحذير إغلاق - مكتمل - فتح - يومي - أسبوعي - مكتمل - الحالة اليوم الأمس الأسبوع الحالي @@ -84,19 +63,11 @@ تم التخطي حزم البيانات الأمس - التفاصيل - التعليقات - الاسم حالة الحدث - تاريخ التسجيل فتح مكتمل تم الإلغاء - البيانات - هل أنت متأكد؟ التاريخ - بيانات الحدث - إغلاق المخططات البيانية الخريطة البرامج @@ -114,8 +85,6 @@ السنة المالية الماضية التغذية الراجعة المؤشرات - القيمة - متوفر تزامن تم التخطي البرامج diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 88db2cf3a2b..efa22a4b97d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,66 +1,26 @@ Dhis2 - dhis - 2 خطأ - - أخطاء - خطأ - خطأين - أخطاء - أخطاء - أخطاء - - رابط الخادم غير صحيح - خطأ في الإعتمادات - خطأ غير متوقع - خطأ داخلي في الخادم - لاتوجد قيمة لا يوجد المزيد من القيم المحجوزة. اتصل بمسؤول النظام - لا يوجد المزيد من القيم المحجوزة. اتصل بالشبكة لإعادة تعبئتها - أخطاء حدث خطأ ما - مزامنة الآن - مزامنة مع الخادم - تتم المزامنة الآن - البيانات الوصفية الأحداث - الكيانات المتعقبة - - اكتمل - اكتملت المزامنة خطأ في المزامنة حدث خطأ أثناء المزامنة. يرجى اعادة المحاولة. انتهت المزامنة ولكن لم نتلق تأكيدًا من الخادم لجميع السجلات. هذه السجلات لا تزال تحمل علامة \"غير مزامن\" في التطبيق. نوصي بإعادة محاولة المزامنة. - تحذير: تم مزامنة معظم البيانات الخاصة بك ولكن يوجد تعارضات في بعض الحقول. قم بعمل مزامنة للتكوين الخاص بك وحاول مرة أخرى. إذا استمر التحذير ، فتحقق من حالة المزامنة: يتم وضع علامة @ على البرامج و TEI والأحداث المتعارضة. - حدث خطأ ولم يستجب الخادم. تواصل مع مديرك. - - تم العثور على %s نتيجة - إضافة جديد - إضافة - جدولة جديدة إحالة - رابط الخادم - اسم المستخدم - كلمة المرور - تسجيل الدخول - الغاء القفل عن الجلسة مزامنة التكوين الخاص بك سيتم مزامنة بياناتك بعد لحظات مزامنة بياناتك التكوين الخاص بك جاهز بياناتك جاهزة - فشل مزامنة التكوين - فشل مزامنة البيانات استرداد الحساب - يلزم وجود اتصال بالشبكة لاسترداد حسابك. يومي @@ -81,137 +41,83 @@ أدخل نصًا أدخل نصًا طويلاً ادخل رقمًا - أدخل عددًا صحيحًا أدخل عددًا صحيحًا سالبًا أدخل عددًا صحيحًا موجبًا أو صفر أدخل عددًا صحيحًا موجبًا - خيارات التصفية اختر التاريخ - تغيير طريقة عرض التقويم - تم اكمال البرنامج - البرنامج غير نشط مفتوح لم يكتمل الحدث مكتمل للقراءة فقط - انهاء ومكتمل مكتمل تم الإلغاء إعادة فتح - تنشيط فعال حالة المزامنة للإرسال للتحديث - للحذف خطأ - مزامنة - إرسال - مزامنة sms - الإكمال - إنشاء تحديث - التاريخ الوحدة التنظيمية لا توجد أي وحدات تنظيمية مفتوحة للتاريخ المحدد. يرجى تحديد تاريخ آخر أو وحدة تنظيمية أخرى. - خيار الفئة خط العرض خط الطول - حدث جديد - مرة واحدة - دائم الفترة الزمنية للتقرير - خيار فئة - إذا قمت بتعيين قيمة ، فإن الصف بأكمله يحتاج إلى كل القيم لإكمال مجموعة البيانات. - بعض القيم المطلوبة مفقودة. تحتاج إلى إدخال القيم الإلزامية لتتمكن من الإكمال. - هناك حقول إلزامية مفقودة. من فضلك قم بملؤها لإكمال حزمة البيانات. - تم حفظ القيمة! - لا توجد فترات متاحة للاختيار. تاريخ الاستحقاق تجميع فئة - DD/MM/YYYY إلغاء حذف - الإجمالي تاريخ أحدث حدث لا تتوفر خيارات البرامج النشطة - برامج للتسجيل تسجيل - آخر زيارة: - تطبيق - انظر التفاصيل - عرض المزيد تعديل - متابعة - تم تمكين المتابعة - تم ايقاف المتابعة إلغاء التنشيط هذا البرنامج لا يسمح بإضافة أحداث أكثر - لا توجد بيانات الأحداث الرئيسية - معلومات التطبيق تسجيل الخروج - حظر الجلسة عرض %s المزيد... عرض أقل... - 1 دقيقة - 15 دقيقة 30 دقيقة 1 ساعة 6 ساعات 1 يوم (افتراضي) 12 ساعة - المزامنة التلقائية للبيانات كل: يدوياً - المزامنة التلقائية للتكوين كل: 1 أسبوع عوامل المزامنة الحالية: إعادة التعيين إلى الافتراضي + سيتم حذف قاعدة البيانات. سيُطلب منك تسجيل الدخول مرة أخرى. قبول إلغاء مزامنة البيانات الآن مزامنة التكوين الآن الإعدادات - آخر عملية مزامنة في: %s - آخر عملية مزامنة في: %s إعادة ملء - وحدة sms: بوابة sms مرسل نتيجة sms - ثواني - رقم - رقم تمكين إرسال SMS انتظر لرد ونتيجة SMS - - يوجد %s برامج ذات إعدادات محددة - يوجد %s برنامج مع إعدادات محددة - يوجد %s برامج ذات إعدادات محددة - يوجد %s برامج ذات إعدادات محددة - يوجد %s برامج ذات إعدادات محددة - يوجد %s برامج ذات إعدادات محددة - الإعدادات محدودة شامل لكل برنامج @@ -229,19 +135,14 @@ يحتوي هذا السجل على جميع الأخطاء الناتجة عن مزامنة بياناتك والبيانات الوصفية. حذف البيانات المحفوظة محلياً في هذا الجهاز سيتم حذف جميع بيانات التطبيق المخزنة في الجهاز. سيتم فقدان البيانات التي لم يتم مزامنتها مع الخادم. - إعادة تعيين بيانات التطبيق و معلومات التكوين - سيتم حذف بيانات التكوين والتطبيق المخزنة في الجهاز. سيُطلب منك تسجيل الدخول مرة أخرى وستفقد البيانات التي لم يتم مزامنتها مع الخادم. إعدادات SMS فحص وتعديل العوامل المتعلقة ببوابة sms تحديث البرنامج - البرمجيات قاعدة بيانات التصدير قم بتصدير قاعدة البيانات الخاصة بك ومشاركتها مع المسؤول عنك الحالة - تاريخ التسجيل - بحث @@ -263,29 +164,15 @@ إحداثيات لـ - قائمة الأقسام - تم إكمال %s سؤال من %s - إكتمال %s من %s - مكتمل ومغلق - ‏‏الأيام - الأشهر - السنوات - الأسابيع تاريخ وقوع الحدث - النتائج المحفوظة محلياً - النتائج المحفوظة على الإنترنت البرامج الماضية ليس لديك الإذن المطلوب للقيام بهذا الإجراء - إضافة كـ ... - مسح الفترة الزمنية - (%d) وحدة تنظيمية - المستوى %d مرحباً بكم في dhis2 هنا يمكنك رؤية أنشطتك يمكنك تطبيق عامل تصفية... @@ -294,16 +181,11 @@ الق نظرة على الإجراءات الأخرى في القائمة الرئيسية - إنشاء علاقة التالي تحتاج إلى ادخال %d على الأقل من السمات للقيام بالبحث - تحتاج إلى ادخال واحدة على الأقل من السمات للقيام بالبحث - هناك أكثر من %d نتيجة. يرجى إدخال معايير بحث أخرى - ادخل معايير البحث التي تريد إستيراد بيانات من رمز QR - حفظ موافق التعريف السمات @@ -315,32 +197,20 @@ لا وجود لبيانات رمز QR غير صالح هذه السمة لايمكن إضافتها - هذا التسجيل لايمكن إضافته حول التطبيق برامج للتسجيل - تحديد الكل - الغاء التحديد - لا توجد معلومات لعوامل التصفية المحددة. يرجى محاولة تغييرها. مشاركة مشاركة رمز الـ QR الأعلى الحالي TEI TEIs - ضع حداً حسب الوحدة التنظيمية - ضع حداً حسب البرنامج - قائمة البرنامج إعادة تعيين بيانات التطبيق و التكوين - من غير الممكن إضافة هذه العلاقة تحقق من هذا! - تحذير - خطأ حفظ القيم المحجوزة - هل تريد إضافة المزيد من البيانات من رمز QR؟ - إصدار التطبيق: %s إصدار SDK: %s @@ -351,131 +221,43 @@ يتم تطويره وصيانته عبر مشروع DHIS2، وهو جزء من جامعة أوسلو ، قسم المعلوماتية. لمزيد من المعلومات أو الأسئلة حول هذا التطبيق ، يرجى الكتابة إلى android@dhis2.org - كلمة المرور مفقودة. يرجى ملء هذا الحقل. - اسم المستخدم مفقود. يرجى ملء هذا الحقل. - يقوم هذا الخادم بتشغيل إصدار dhis مختلف عن 2.29. قم بتحديث إصدار الخادم الخاص بك أو استخدم بيئة الاختبار للمتابعة. - هذا الخادم يقوم بتشغيل إصدار dhis يختلف عن %s. قم بتحديث إصدار الخادم الخاص بك أو استخدم بيئة الاختبار للمتابعة. - حدث خطأ ما. يرجى التحقق من صحة عنوان url للخادم واسم المستخدم وكلمة المرور وحاول مرة أخرى. - تعذر على الخادم إكمال هذا الطلب. - حدثت مشكلة أثناء محاولة تسجيل الدخول إلى الخادم. - خطأ في تسجيل الدخول - بعض الحقول الإلزامية مفقودة - خطأ غير متوقع أثناء البحث عبر الإنترنت - خطأ أثناء تحليل النتائج - أعاد البحث عبر الإنترنت خطأ - حدث خطأ في الخادم حدد برنامجاً لتمكين التسجيل ليس لديك الإذن المطلوب - هذه الوظيفة ليست جاهزة بعد. - خطأ في الحصول على تسمية العلاقة كل %s هذه ليست صيغة رقم هاتف سليمة - هذه ليست صيغة بريد إلكتروني سليمة - يُسمح فقط بالأرقام السالبة يُسمح فقط بالأرقام الموجبة - يُسمح فقط بالأرقام الموجبة - يُسمح فقط بقيمة بين 0 و 1 - يُسمح فقط بقيمة بين 0 و 100 - حدد التواريخ للتصفية تحديد الخيار - تم الإبلاغ عن المشكلة - توليد حدث جديد - هل تريد إنشاء حدث جديد اكتملت الأحداث اكتمال جميع الأحداث في هذا البرنامج. هل ترغب أيضاً في تعيين البرنامج كمغلق؟ - هل ترغب أيضاً في تعيين هذا التسجيل كمكتمل؟ هل ترغب في إكمال %s أيضاً؟ تحقق الرئيسية مثال - إضافة أداة إظهار المساعدة - مزامنة بياناتك... - ...التكوين الخاص بك... - ...تعيين عدد السجلات التي يتم تحميلها من الخادم ... - التحقق من وإدارة عدد القيم المحجوزة المتبقية - تحقق مما إذا فشل الاتصال بالخادم - حرر بعض المساحة في الذاكرة - ... أو احذف كل شيء للبدء من جديد. - ابحث عن الكيانات الخاصة بك المحفوظة في جهازك وفي خادم الإنترنت. - يمكنك التبديل إلى برامج أخرى - بحث أو إضافة كيانات جديدة. تحتاج إلى البحث قبل أن تتمكن من إضافة أي جديد! - انقر هنا لتنظيف الحقول وإعادة البحث. هذه لوحة معلومات الكيان المسجل الإطلاع على المعلومات الخاصة بالكيانات... - ...مشاركة المعلومات عبر QR أو SMS... - ...أو ضع علامة على الكيان للمتابعة - انقر لإنشاء أو جدولة حدث أو الإحالة إلى وحدة تنظيمية أخرى. هنا سترى جميع الأحداث. انقر لرؤية تفاصيل حدث. تنقل خلال القائمة لإضافة العلاقات أو مشاهدة المؤشرات أو كتابة الملاحظات. - انقر لرؤية جميع التسجيلات أو إنشاء تسجيل جديد. - انقر لحذف هذا الحدث. لا يمكن التراجع عن هذا الإجراء. - إكمال الحدث. - سيتم سرد جميع الأحداث المتعلقة بهذا البرنامج هنا. - انقر لإضافة حدث جديد أدخل جميع البيانات لإنشاء الحدث انقر للتحقق من حالة إكتمال الحدث - انقر لإكمال أو لإعادة فتح حدث - قراءة وتخزين المعلومات من أجهزة أخرى عبر QR - مزامنة بياناتك مع الخادم - حظر العمل برمز مكون من 4 أرقام. لا تنس الرمز الخاص بك! - قم بتسجيل الخروج لتغيير المستخدم أو الخادم. ستتمكن من تسجيل الدخول مرة أخرى حتى إذا لم يكن هناك اتصال بالإنترنت. - %s: العلاقات لا توجد أحداث مسجلة، انقر على \"+\" لإضافة حدث جديد. - لا توجد أحداث مسجلة قيم البيانات معلومات تعذر قراءة رقم التعريف هذا حذف البيانات المحفوظة محلياً في هذا الجهاز نظرة عامة - العلاقات - المؤشرات - التحليلات لا توجد مؤشرات لهذا البرنامج. - الملاحظات - غير قادر على الإكمال - لا يمكن حفظه - هناك تحذيرات في النموذج - ماذا تريد ان تفعل؟ - تحتوي بعض الحقول على أخطاء. من فضلك، تحقق منها حتى تتمكن من الإكمال كل التسجيلات عرض لوحة معلومات بجميع البرامج النشطة - فتح سجل أخطاء المزامنة سجل أخطاء المزامنة مشاركة مع... - فشلت مزامنة التكوين الخاص بك. قد يكون هناك أخطاء في الخادم الخاص بك. انقر هنا للمزيد من المعلومات. - السابق - التالي - التفاصيل - المؤشرات - ملاحظات - - الإكمال لاحقاً - - إنهاء - الحقول التي بها أخطاء - تحتوي بعض الحقول على أخطاء. لم يتم حفظها. يرجى التحقق منها. يجب أن تكون القيمة فريدة، يوجد تعارض تكرار لنفس القيمة. لم يتم الحفظ. يرجى التحقق. - وجد تعارض في حقل ذو قيمة فريدة. لحفظ التسجيل، يرجي التحقق من النموذج. الرجاء الانتظار تخطى - اعادة جدولة لا توجد معلومات عن هذه الشاشة - متأخر - هناك قاعدة برنامج واحدة غير معتمدة. يرجى زيارة التوثيق للتحقق - لا توجد معلومات عن هذا الحقل - إعتمادات فارغة - لتتمكن من تسجيل الدخول باستخدام ماسح بصمة الإصبع، تحتاج أولاً إلى تسجيل الدخول بإستخدام الطريقة الافتراضية - الأمن البيومتري - تسجيل الدخول السريع - هل ترغب في استخدام بصمة إصبعك أو طريقة بيومترية أخرى لتسجيل الدخول؟ - جداول - الجدول - https:// سيتم فقدان البيانات التي لم يتم مزامنتها. هل تريد حقاً المتابعة؟ - قبول تعذر حذف البيانات. يرجى اعادة المحاولة. انتهاء عملية حذف البيانات المحلية بنجاح. يجب تحديد وحدة تنظيمية واحدة على الأقل @@ -498,33 +280,16 @@ رفع العلاقة مرسل عن طريق sms - لا توجد أي تعارضات لهذا البرنامج - اتصال الشبكة غير متاح ، ولكن يبدو أن هناك خدمات هاتفية - يمكنك استخدام الرسائل القصيرة. - اتصال الشبكة غير متوفر. إغلاق الآن يمكنك التحقق من معلومات المزامنة بالضغط هنا - يتم تخزين هذه البيانات في جهازك فقط، ولم يتم مزامنتها مع الخادم. - لديك آخر التحديثات حتى الآن! كل بياناتك تم ارسالها إلى السيرفر لا يمكنك ربط سجل مع نفسه - رفع الحظر عن الجلسة - مكتمل - مفتوح - نوع القيمة غير مدعوم تغيير طريقة عرض التقويم - 15 دقيقة - كل ساعة - يومي - أسبوعي - أسبوع - إعداد المزامنة: %s السجل الذي تحاول الوصول إليه ليس لديه تسجيل. لا يمكنك الوصول إلى لوحة المعلومات. خطأ في التنسيق حذف %s ليس لديك الصلاحية لحذف هذا السجل - هذا ليس عنوان url صالحاً تسوية وضبط النموذج - رقم المستلم هل تقبل بإرسال %d رسالة؟ ارسال %1$d/%2$d مقدار انتظار التأكيد - %d @@ -542,51 +307,29 @@ لم يتم تعيين بوابة عبور sms المستخدم لم يقم بتسجيل الدخول وحدة الرسائل القصيرة غير مفعلة - جاري إرسال رسالة SMS أخرى لم تبدأ وحدة الرسائل القصيرة، لم يتم العثور على البيانات الوصفية المطلوبة - مكتمل لم يتم قبول عدد الرسائل القصيرة بدء إرسال الرسالة القصيرة تم قراءة البيانات وتحويلها. الرقم المعرف للإرسال: %d - خطأ في إرسال الرسالة القصيرة تسجيل وضم TEI عبر SMS إرسال الحدث sms إرسال حزمة البيانات SMS - البدء - قيد العمل - تم الإرسال - فشل - بدء المزامنة مزامنة... - اكتمال عملية المزامنة - حدث خطأ أثناء المزامنة. - حدث خطأ أثناء المزامنة. انقر هنا للتحقق من سجلات الأخطاء - تزامن ملغي. تم النسخ - تم تحديد الصورة - إظهار على الخريطة - إظهار القائمة - %s إلزامي ولكن لم يتم توفيره. - تم التقاط المضلع مضلع - البصمة - من فضلك ، ضع إصبعك في قارئ بصمة الإصبع الخاص بك. الأمان لأسباب أمنية ، لا يُسمح باستخدام هذا الإصدار من التطبيق في الأجهزة الـ rooted. يرجى استخدام النسخة التدريبية. - هناك بعض الأخطاء. يرجى إصلاحها قبل المتابعة. تطبيق عوامل تصفية لم يتم تطبيق عوامل تصفية الفترة الزمنية الوحدة التنظيمية - جار الفرز - الحالة مزامنة تم تعيينه لي اليوم @@ -601,8 +344,6 @@ من - إلى أخرى في أي وقت - فشلت المزامنة. تحقق من قيم البيانات وحاول اصلاح المشكلة ثم حاول مرة أخرى. إذا استمرت المشكلة بالظهور اتصل بمسؤول النظام - تم إرسال هذه البيانات إلى الخادم ولكن ليس مضموناً أنه تم تخزينها المفتوحة المجدولة زمنياً المتأخرة @@ -618,7 +359,6 @@ لايمكن ترك بوابة عبور رسائل sms فارغ لا توجد تسجيلات ليتم رفعها هذه الـ convertTask غير مدعومة - لا تظهر قفل الشاشة مرة أخرى تعيين رمز تعارض غير التاريخ @@ -643,11 +383,7 @@ إختر وحدة تنظيمية أدخل رابط url أدخل قيمة - اختر صورة - مسح الرمز - إختر خياراً القيم المحجوزة التي يتم تنزيلها لكل سمة TEI - القيم المحجوزة التي يتم تنزيلها لكل سمة TEI بقي %s من القيم المحجوزة إدارة القيم المحجوزة الأحداث التي يتم تنزيلها @@ -667,39 +403,19 @@ الغير متزامنة تحوي أخطاء في المزامنة التي تم مزامنتها عبر SMS - التفاصيل - التعليقات تم الحفظ! - هل تريد التحقق من جودة البيانات؟ - كل شيء يبدو جيدا! - هل تريد أيضاً تعيين حزمة البيانات كمكتملة؟ - تعيين كمكتمل - الإسم - الصيغة - إكمال على أية حال %s%s مفقود من %s%s حالة الحدث - تاريخ التسجيل لا توجد بيانات. انقر فوق \"+\" لإضافة سجل جديد لا توجد بيانات. ليس لديك إذن بإضافة سجلات جديدة. لم يتم تعيين وصف أو تعليمات بيانات للمراجعة - محفوظ ومكتمل يوجد مسبقاً تسجيل نشط مفتوح مكتمل تم الإلغاء - بيانات تعذر تنزيل %s المحددة - تم الإكمال من قبل %s:%s - تم التحديث: %s - تم - لم نتمكن من التحقق من جودة البيانات. تواصل مع مديرك - هل أنت متأكد؟ - هل تريد إعادة فتح حزمة البيانات؟ التاريخ - بيانات الحدث - %s لا يملك أي سمات عامة، يرجى تحديد برنامج سياسة الخصوصية إنقر هنا للإطلاع على سياسة الخصوصية الخاصة بنا قيم الإحداثيات غير صالحة @@ -707,15 +423,8 @@ لأسباب أمنية ، لا يُسمح باستخدام هذه النسخة من التطبيق مع adb. الرجاء استخدام نسخة هذا التطبيق المخصصة للتدريب. رجوع - إغلاق التطبيق - تفاصيل أكثر - تفاصيل أكثر - إغلاق نسخ إلى الحافظة - تم النسخ إلى الحافظة - معلومات الخطأ وجدنا خطأ عند المزامنة مع الخادم الخاص بك. شارك الخطأ مع مسؤول النظام إن أمكن. - لم نتمكن من بدء الجلسة مع المستخدم الخاص بك هناك أحداث مخطط لها لن تتم إعادة جدولتها المخططات البيانية عرض @@ -763,41 +472,25 @@ التغذية الراجعة المؤشرات القيمة + تم رفض إذن الكاميرا.\n تحتاج إلى تمكينها لاستخدام هذه الميزة. الرسوم البيانية والمؤشرات التقط صورة - اختر من استوديو الصور - تم الإنتهاء! هذا البرنامج محمي صف السبب هنا - تتم المصادقة - عنوان url للخادم ليس نسخة dhis - خطأ في الإعتمادات - خادم غير معروف - يجب أن تكون القيمة عدداً صحيحاً انتهت الجلسة لقد انتهت جلستك الحالية. لأسباب أمنية ، ستحتاج إلى تقديم بيانات الاعتماد الخاصة بك مرة أخرى - القيم الأسابيع %d %s إلى %s لا تظهر مرة أخرى - لا يمكن تعيين نوع الإحداثيات تمت المتابعة %s - لا يمكن تحليل %s كتاريخ لا يمكن تحديث هذا الحقل. حاول مرة اخرى - متوفر هذه البيانات غير قابلة للتحرير لأنه تم تعليمها على أنها مكتملة هذه البيانات غير قابلة للتعديل لأن وقت إصدارها قد انتهى ليس لديك إذن لتحرير هذه البيانات هذه البيانات غير قابلة للتعديل لأنه تم اغلاق الوحدة التنظيمية ليس لديك إذن لتحرير هذه البيانات - لا يمكنك تحرير البيانات من هذه الوحدة التنظيمية - إعادة البحث + لا يمكنك تحرير البيانات من هذه الوحدة التنظيمية البحث عبر الإتصال بالسيرفر عن طريق الإنترنت ارجع الرسالة التالية: - تحذير قواعد البرنامج - إعادة التجديد - غير معروف - قم بتحديث كافة البيانات حذف الحساب - إدارة الحسابات استكشاف أخطاء التكوين وإصلاحها اللغة انقر هنا لتغيير لغة التطبيق @@ -813,8 +506,6 @@ لا توجد نتائج لهذا البحث نتائج كثيرة جدًا حاول تغيير البحث لتضييق نطاق النتائج. - إنشاء جديد - من الضروري إدخال سمات %s على الأقل للبحث موافق\! ابدأ بحثًا للعثور على أي %s يمكنك البحث أو إنشاء %s جديد @@ -823,7 +514,6 @@ بعض الحقول بها أخطاء ولم يتم حفظها. \nهل تريد مراجعة النموذج؟ هل تريد وضع علامة على هذا النموذج كمكتمل؟ لديك بعض رسائل الخطأ.\nهل تريد مراجعتها؟ - إذا قمت بالخروج الآن سيتم تجاهل التغييرات. أعيد فتحه الحساب معطل تم تعطيل حسابك من قبل المسؤول. @@ -831,18 +521,7 @@ التاريخ الحالي: %s الجهاز: %s إصدار نظام التشغيل: %s - افتح تفاصيل %s مزامنة %s ... - تم إرسال الرسالة القصيرة. في انتظار استجابة الخادم. - تم تأكيد وصول الرسالة القصيرة إلى الخادم. تحديث الحالة - لا يمكننا تأكيد وصول الرسالة القصيرة إلى الخادم. تحديث الحالة - تم إرسال الرسالة القصيرة. تحديث الحالة. - لم يتم إرسال الرسالة القصيرة. - فتح تطبيق الرسائل القصيرة. لا تعدل الرسالة من فضلك. - في انتظار التأكيد اليدوي - مزامنة الأحداث - تمت المزامنة - دبوس خاطئ مسجل في مملوكة من قبل المزامنة مطلوبة @@ -855,27 +534,10 @@ إرسال تحديث ليس الان - - %s الأخطاء - %s الخطأ - %s الأخطاء - %s الأخطاء - %s الأخطاء - %s الأخطاء - - - %s التحذيرات - %s تحذير - %s التحذيرات - %s التحذيرات - %s التحذيرات - %s التحذيرات - تحديث البرنامج في وقت لاحق حمّل الآن تحقق من وجود تحديثات - التحقق من وجود تحديثات لا توجد تحديثات جديدة إصدار التطبيق غير متزامن @@ -897,15 +559,38 @@ مرحلة البرنامج حدث الجدول الزمني - اختياري إظهار المزيد إظهار أقل حالة التزامن - إظهار الحقول - إخفاء الحقول مزامنة ناجحة مزامنة %s ... إظهار الوصف إخفاء الوصف استخدام كلمة المرور - + + %s الأخطاء + %s الخطأ + %s الأخطاء + %s الأخطاء + %s الأخطاء + %s الأخطاء + + + + %s التحذيرات + %s تحذير + %s التحذيرات + %s التحذيرات + %s التحذيرات + %s التحذيرات + + + + يوجد %s برامج ذات إعدادات محددة + يوجد %s برنامج مع إعدادات محددة + يوجد %s برامج ذات إعدادات محددة + يوجد %s برامج ذات إعدادات محددة + يوجد %s برامج ذات إعدادات محددة + يوجد %s برامج ذات إعدادات محددة + + diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 15ac9b5cbaa..1788146de07 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -1,33 +1,16 @@ ভুল - - ভুলগুলো - ভুলগুলো - - ভুল সার্ভার - যোগ করুন সম্পন্ন ভুল - তারিখ + নির্ধারিত তারিখ অবস্থা - - - নিবন্ধনের তারিখ - পরিদর্শনের তারিখ - ভুল - মন্তব্য - মন্তব্য - সম্পন্ন - অবস্থা সম্পন্ন - নিবন্ধনের তারিখ তারিখ মন্তব্য তথ্যের অন্তর্ভুক্তি - অবশ্যই সংখ্যা হতে হবে diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index e8002220ed6..e80cde13ada 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -1,34 +1,13 @@ Dhis2 - dhis - 2 هەڵە - - هەڵە - هەڵە - - بەستەری ڕاژە دروست نییە - زانیارییەکان دروست نین - هەڵەی چاوەڕواننەکراو - هەڵەی ناوەکیی ڕاژە - هیچ نرخێک هیچ نرخێکی دیکەی تەرخانکراو نەما. پەیوەندی بە بەڕێوەبەری سیستەمەکەتەوە بکە - هیچ نرخێکی دیکەی تەرخانکراو نەما. پەیوەندی بە تۆڕەکەوە بکە بۆ دووبارە پڕکردنەوەیان - هەڵە هەڵەیەک ڕووی دا - هاوکاتگەری بکە ئێستا - لەگەڵ ڕاژەکە هاوکاتگەر دەکرێت - هاوکاتگەر دەکرێت - داتا پەیوەندیدارەکان ڕووداوەکان - پێکهاتە چاودێریکراوەکان - - تەواوکرا - هاوکاتگەریکردن تەواو بوو هەڵە لە هاوکاتگەری هەڵەیەک لە کاتی هاوکاتگەری کردن ڕووی دا. تکایە دووبارەی بکەوە. @@ -39,46 +18,35 @@ چاڵاک هەڵە + + + + نويَ كردنةوة - داتا - رووداوي نوي لابردن سرينةوة - كؤي طشتي + دةستكاري ماڵةوة پەسندکردن لابردن ريكخستن پێگە + + طةران - - رۆژەکان - مانگەکان - ساڵەکان - باك ماوة + داهاتوو - خةزن كردن تةواو ڕووداوەکان پەیوەندیەکان دةربارةي - زیاترین - هەڵە خةزن كردن ماڵةوة - پەیوەندیەکان - نیشاندەر - نیشاندەر - تەواکردن - پەسندکردن داخستن - تەواوبکە - تەواوبوو ماوة - پێگە ئەمڕۆ ئەم هەفتەیە هەفتەی پێشووتر @@ -87,18 +55,12 @@ تەواوبوو بؤ دواوة هەڵە لە هاوکاتگەری - کۆمێنتەکان تەواوبکە - تةواو كرا بەروار - بيركاري داتا بؤ دواوة - داخستن بةرنامةكان ڕاووبۆچون نیشاندەر - بةها - زانیارییەکان دروست نین هاوتاکردن بةرنامةكان هەڵە لە هاوکاتگەری diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3adb854eeac..b444ccbb248 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1,69 +1,32 @@ Dhis2 - dhis - 2 Chyba - - Chyba - Chyby - Chyb - Chyb - - Chybné URL serveru - Chybné registrační údaje - Neočekávaná chyba - Interní chyba serveru - žádná hodnota Neexistují žádné další rezervované hodnoty. Obraťte se na správce systému - Neexistují žádné další rezervované hodnoty. Připojte se k síti a doplňte je - chyby Něco se pokazilo - Synchronizovat nyní - Synchronizace se serverem - Probíhá synchronizace - Metadata Události - Trasované entity - - Dokončeno - Synchronizace dokončena Chyba synchronizace Během synchronizace došlo k chybě. Zkuste to prosím znovu. Synchronizace dokončena, ale neobdrželi jsme potvrzení serveru pro všechny záznamy. Tyto záznamy jsou v aplikaci stále označeny jako „offline“. Doporučujeme synchronizaci zopakovat. CHYBA: V procesu synchronizace se něco pokazilo. Pokud máte připojení, zkuste to prosím znovu. Pokud chyba přetrvává, obraťte se na správce. Ve vašich datech jsou rozpory. Synchronizujte prosím svou konfiguraci, zkontrolujte svá data a zkuste synchronizaci provést znovu. - UPOZORNĚNÍ: Většina vašich dat je synchronizována, ale některá pole vykazovala konflikty. Synchronizujte svou konfiguraci a zkuste to znovu. Pokud varování přetrvává, zkontrolujte jejich stav synchronizace: programy, TEI a události s konflikty jsou označeny jako @. Některá vaše data se nepodařilo synchronizovat. - Došlo k chybě a server neodpovídá. Kontaktujte svého správce. - Nalezeno %s výsledků - Přidat nový - Přidat Zadat %s Zadat / přeskočit %s - Naplánovat nový Doporučení - URL serveru - Uživatelské jméno - Heslo - Přihlásit se - Odemkněte relaci Synchronizuje se vaše konfigurace Vaše data budou brzy synchronizována Synchronizace vašich dat Vaše konfigurace je připravena Vaše data jsou připravena - Synchronizace konfigurace selhala - Synchronizace dat selhala Obnovení účtu - K obnovení účtu je potřeba připojení k síti. Denně @@ -84,141 +47,88 @@ Zadejte text Zadejte dlouhý text Zadejte číslo - Zadejte celé číslo Zadejte záporné celé číslo Zadejte kladné celé číslo nebo nulu Zadejte kladné celé číslo - Možnosti filtru Zvolte datum - Změnit kalendář - Program dokončen - Program neaktivní Otevřít Není dokončeno Událost dokončena Pouze ke čtení - Hotovo a dokončit Dokončeno Zrušeno Znovu otevřít - Znovu otevřít formulář - Aktivovat Aktivní Synchronizace Poslat Aktualizovat - Smazat Chyba - SYNCHRONIZOVAT - ODESLAT - SYNCHRONIZOVAT SMS - Dokončení - Vytvořit Aktualizovat - Datum Org. jednotka Pro vybrané datum nejsou k dispozici žádné otevřené organizační jednotky. Vyberte prosím jiné datum nebo jinou organizační jednotku. - Možnosti kategorie Zeměpisná šířka Zeměpisná délka - Nová událost - Jednou - Trvale Období hlášení zpráv - Možnosti kategorie - Pokud nastavíte hodnotu, celý řádek potřebuje k dokončení datové sady všechny hodnoty. - Některé požadované hodnoty chybí. K dokončení datové sady je třeba zadat povinné hodnoty. - Chybí povinná pole. Chcete-li vyplnit soubor dat, vyplňte je prosím. - Hodnota je uložena! - Nejsou k dispozici žádná období k výběru. Poslední termín Další %1$s Kombinace kategorií - DD/MM/RRRR Zrušit Smazat - Celkem Datum poslední události K dispozici nejsou žádné možnosti Seznam zápisů Aktivní programy - Programy k zápisu Zapsat - Poslední návštěva: - použít - Prohlednout detaily - UKÁZAT VÍCE Upravit - Následovat - Sledování povoleno - Sledování zakázáno Deaktivovat Tento program neumožňuje více událostí - Nejsou data události Úvodní strana - Info o aplikaci Odhlášení - Blokovat relaci Zobrazit %s dalších... Zobrazit méně... - 1 minuta - 15 minut 30 minut 1 hodina 6h 1 den (výchozí) 12h - Automatická synchronizace dat každých: Ručně - Konfigurace automatické synchronizace každých: 1 týden Aktuální parametry synchronizace: Obnovit do základního nastavení + Databáze bude smazána.\nBudete požádán o opětovné přihlášení. Přijmout Zrušit SYNCHRONIZOVAT Data teď SYNCHRONIZOVAT Konfiguraci teď Nastavení - Poslední synchronizace: %s Poslední synchronizace v - Poslední synchronizace: %s Doplňte - SMS modul: SMS brána Odesílatel výsledků SMS Časový limit výsledku SMS (s) - vteřin - číslo - číslo Povolit odesílání SMS Počkejte na odpověď na výsledek SMS - - Je tu %s program se specifickým nastavením - Jsou tu %sprogramy se specifickým nastavením - Je tu %s programů se specifickým nastavením - Jsou tu %s programy se specifickým nastavením - Doba synchronizace Nastavení omezeno Globálně @@ -237,12 +147,9 @@ Tento protokol obsahuje všechny chyby způsobené synchronizací vašich dat a metadat. Odstranit místní data Všechna data aplikace Capture uložená ve vašem zařízení budou smazána. Data, která nejsou synchronizována se serverem, budou ztracena. - Resetovat data aplikace a konfiguraci - Vaše konfigurace a data aplikace uložená v zařízení budou smazána. Budete požádáni o opětovné přihlášení a data, která nejsou synchronizována se serverem, budou ztracena. SMS nastavení Zkontrolujte a upravte parametry týkající se brány SMS Aktualizace softwaru - Software Export databáze Export databáze a její sdílení se správcem Dvoufaktorové ověřování @@ -250,8 +157,6 @@ Ověřování můžete povolit nebo zakázat ve dvou krocích. - Datum zápisu - Hledat @@ -273,29 +178,15 @@ Souřadnice pro - Seznam oddílů - %s otázek z %s dokončeno - %s z %s dokončeno - Dokončit a zavřít - Dny - Měsíce - Roky - Týdny Datum incidentu - Místní výsledky - Výsledky online Minulé programy K provedení této akce nemáte požadované oprávnění - Přidat jako… - Vymazat Období - (%d) Org jednotek - Úroveň %d Vítejte na dhis2 Zde můžete vidět své aktivity Můžete použít filtr ... @@ -304,16 +195,11 @@ Zkontrolujte další akce v hlavní nabídce - Vytvořit vztah Další Chcete-li provést vyhledávání, musíte zadat alespoň %d atributy - Chcete-li provést vyhledávání, musíte zadat alespoň jeden z atributů - Je toho víc než %d výsledků. Zadejte prosím další kritéria hledání - Zadejte kritéria hledání Importovat QR data - Uložit OK Identifikace Atributy @@ -325,32 +211,20 @@ žádná data Neplatný QR kód Tento atribut nelze přidat - Tento zápis nelze přidat O nás Programy k zápisu - vybrat vše - vymazat výběr - O vybraných filtrech nejsou žádné informace. Zkuste je změnit. Sdílet Sdílet QR Max stávající TEI TEIs - Omezit podle organizační jednotky - Omezit podle programu - Seznam programů Resetovat data aplikace a konfiguraci - Tento vztah není možné přidat Zkontrolovat to! - Varování - Chyba Uložit Rezervované hodnoty - Chcete přidat více dat z QR? - Verze aplikace: %s Verze SDK: %s @@ -361,131 +235,43 @@ Vyvinuto a udržováno projektem DHIS2, který je součástí University of Oslo, oddělení informatiky. Další informace nebo dotazy týkající se této aplikace získáte na android@dhis2.org - Chybí heslo. Vyplňte prosím toto pole. - Chybí uživatelské jméno. Vyplňte prosím toto pole. - Tento server používá jinou verzi DHIS než 2.29. Aktualizujte verzi serveru nebo pokračujte testovacím prostředím. - Tento server používá jinou verzi dhis než je %s. Aktualizujte verzi serveru nebo pokračujte testovacím prostředím. - Něco se pokazilo. Zkontrolujte správnost adresy URL serveru, uživatelského jména a hesla a zkuste to znovu. - Server nemohl tento požadavek dokončit. - Při pokusu o přihlášení k serveru došlo k problému. - Chyba přihlášení - Chybí povinná pole - Neočekávaná chyba při online vyhledávání - Chyba při analýze výsledků - Hledání online vrátilo chybu - Na serveru se něco pokazilo Vyberte program, který umožní registraci Nemáte požadované povolení - Tato funkce ještě není připravena. - Chyba při získávání štítku vztahu Vše %s Toto není platné telefonní číslo - Toto není platný e-mail - Povolena jsou pouze záporná čísla Povolena pouze kladná čísla nebo nula - Povolena jsou pouze kladná čísla - Povoleny jsou pouze hodnoty od 0 do 1 - Povoleny jsou pouze hodnoty od 0 do 100 - Vyberte data, která chcete filtrovat Vyberte možnost - Problém byl nahlášen - Generovat novou událost - Chcete vytvořit další událost? Události byly dokončeny Všechny události v tomto programu jsou dokončeny. Chcete také ukončit program? - Chcete také dokončit zápis? Chtěli byste také vyplnit stránku %s? Zkontrolovat Úvodní strana PŘÍKLAD - Přidat widget Zobrazit nápovědu - Synchronizujte svá data… - ... vaši konfiguraci ... - ... nastavte, kolik záznamů se má stáhnout ze serveru ... - Zkontrolujte a spravujte, kolik zbývajících rezervovaných hodnot zbývá - Zkontrolujte, zda připojení k serveru neselhalo - Uvolněte místo na disku - … nebo odstraňte vše a začněte znovu. - Najděte své entity ve svém místním zařízení i na online serveru. - Můžete přepnout na jiné programy - Vyhledejte nebo přidejte nové entity. Musíte hledat, než budete moci přidat nový! - Kliknutím sem pole vyčistíte a znovu spustíte vyhledávání. Toto je řídicí panel entity Zobrazte informace o profilu entit ... - … Sdílejte informace prostřednictvím QR kódu nebo SMS… - … Nebo označte entitu pro další sledování - Kliknutím vytvoříte nebo naplánujete událost nebo přejdete na jinou organizační jednotku. Zde uvidíte všechny události. Kliknutím zobrazíte podrobnosti události. V nabídce můžete přidávat vztahy, zobrazovat indikátory nebo psát poznámky. - Kliknutím zobrazíte všechny registrace své entity nebo vytvoříte novou. - Kliknutím tuto událost smažete. Tuto akci nelze vrátit zpět. - Dokončete událost. - Zde budou uvedeny všechny události související s tímto programem. - Kliknutím přidáte novou událost Zadejte všechna data k vytvoření události Klepnutím zkontrolujete stav dokončení události. - Kliknutím událost dokončíte nebo znovu otevřete - Čtěte a ukládejte informace z jiných zařízení pomocí QR kódu - Synchronizujte svá data se serverem - Blokujte relaci pomocí 4místného kódu. Nezapomeňte svůj kód! - Odhlaste se a změňte uživatele nebo server. Budete se moci znovu přihlásit, i když nemáte připojení k internetu. - %s: vztahy Nejsou zaregistrovány žádné události, kliknutím na „+“ přidáte novou. - Nejsou registrovány žádné události. Datové hodnoty Info Toto ID se nepodařilo přečíst Odstranit místní data Přehled - Vztahy - Indikátory - Analytiky Pro tento program neexistují žádné ukazatele. - Poznámky - Nelze dokončit - Nelze uložit - Ve formuláři jsou varování - co chcete dělat? - Některá pole obsahují chyby. Zkontrolujte je prosím, abyste je mohli dokončit Všechny zápisy Zobrazit řídicí panel se všemi aktivními programy - Otevřít protokol chyb synchronizace Protokol chyb synchronizace Sdílet s... - Vaše synchronizace konfigurace se nezdařila. Na vašem serveru mohou být chyby. Další informace získáte kliknutím sem. - ZPĚT - VPŘED - Detaily - Indikátory - Poznámky - - Dokončit později - - Dokončit - Pole s chybami - Některá pole obsahují chyby. Nebyla uložena. Prosím, zkontrolujte je. Hodnota by měla být jedinečná, byla nalezena koincidence. Nebylo uloženo. Prosím zkontrolujte to. - Byla nalezena koincidence v jedinečném poli. Pro uložení zápisu prosím zkontrolujte formulář. Prosím, čekejte Přeskočit - Přeplánovat O této obrazovce nejsou žádné informace - Vypršelo - Existuje jedno programové pravidlo, které není podporováno. Zkontrolujte dokumentaci - Žádné informace pro toto pole - Prázdné pověření - Abyste se mohli přihlásit pomocí snímače otisků prstů, musíte nejprve použít výchozí způsob - Biometrické zabezpečení - Rychlé přihlášení - Chcete se přihlásit pomocí otisku prstu nebo jiné biometrické metody? - Tabulky - Tabulka - https:// Nesynchronizovaná data budou ztracena. Opravdu chcete pokračovat? - Přijmout Data nelze smazat. Zkuste to prosím znovu. Odstranění místních dat bylo úspěšně dokončeno. Musí být vybrána alespoň jedna organizační jednotka @@ -509,33 +295,16 @@ Nahrávám Vztah Odesláno sms - U tohoto programu nejsou žádné konflikty - Síťové připojení není k dispozici, ale vypadá to, že existují telefonní služby - můžete použít SMS. - Síťové připojení není k dispozici. Zavřít Nyní můžete zkontrolovat informace o synchronizaci kliknutím sem - Tato data jsou uložena pouze ve vašem zařízení, nejsou synchronizována se serverem. - Jste aktuální! Všechna vaše data jsou odeslána na server. Nemůžete spojit záznam sám se sebou - Odblokovat relaci - Dokončit - Otevřít - Nepodporovaný typ hodnoty Změnit zobrazení kalendáře - 15 minut - Každou hodinu - Denně - Týdně - Týden - Nastavení synchronizace: %s Záznam, ke kterému se pokoušíte získat přístup, nemá žádnou registraci. K hlavnímu panelu nemáte přístup. Chyba formátování Smazat %s Nemáte oprávnění tento záznam mazat - Toto není platné URL Úprava formuláře… - Číslo příjemce Souhlasíte s odesláním %d zpráv? Odesílání %1$d/%2$d Čeká množství potvrzení - %d @@ -553,51 +322,29 @@ Nenastavena žádná brána Uživatel není přihlášen SMS modul je deaktivován - Probíhá další odesílání SMS SMS modul nebyl spuštěn, nenalezena požadovaná metadata - Dokončeno Počet SMS není přijat Zahájeno odesílání SMS Přečtená a převedená data. ID příspěvku: %d - Chyba při odesílání SMS zprávy SMS TEI registrace a zápis Odeslání události SMS Odeslání SMS datové sady - Začíná - Probíhá - Odesláno - Selhalo - Spouštění synchronizace. Probíhá synchronizace ... - Synchronizace dokončena. - Během synchronizace došlo k chybě. - Během synchronizace došlo k chybě. Kliknutím sem zkontrolujete protokoly chyb - Zrušená synchronizace. Zkopírováno - Obrázek vybrán - Zobrazit na mapě - Zobrazit seznam - %s je povinné, ale nebylo poskytnuto. - Polygon zachycen Polygon - Otisk prstu - Položte prst na čtečku otisků prstů. Zabezpečení Z bezpečnostních důvodů není možné tuto verzi aplikace používat v rootovaných zařízeních. Použijte tréninkovou verzi. - Došlo k několika chybám. Než budete pokračovat, opravte je. Filtry se používají Nejsou použity žádné filtry Období Org. jednotka - Třídění - Status Synchronizace Přiděleno mně Dnes @@ -612,8 +359,6 @@ Od - Do Ostatní Kdykoli - Synchronizace se nezdařila. Zkontrolujte hodnoty dat, pokuste se problém vyřešit a zkuste to znovu. Pokud stále selhává, obraťte se na správce systému - Tato data byla odeslána na server, ale není zaručeno, že budou uložena Otevřít Plán Vypršelo @@ -622,15 +367,12 @@ Přeskočeno Události:%smax:%s%saktuální:%s TEI:%smax:%s%saktuální:%s Omezené nastavení %s - Skrýt údaje o zápisu - Zobrazit údaje o zápisu SMS modul povolen SMS modul deaktivován Stahování dat pro modul SMS. Prosím, čekejte Brána SMS nemůže být prázdná Nejsou žádné zápisy k nahrání Tento convertTask není podporován - Nezobrazovat znovu zamykací obrazovku Nastavit PIN Konflikt Změnit datum @@ -655,11 +397,7 @@ Vyberte organizační jednotku Zadejte url Zadejte hodnotu - Vyberte obrázek - naskenovat kód - Vyberte možnost Stažené rezervované hodnoty podle atributu TEI - Stažené rezervované hodnoty podle atributu TEI zbývá %s rezervovaných hodnot Spravovat vyhrazené hodnoty Události ke stažení @@ -679,55 +417,29 @@ Nesynchronizováno Chyba synchronizace SMS synchronizováno - Detaily - Komentáře Uloženo! - Chcete zkontrolovat kvalitu dat? - Všechno vypadá dobře! - Chcete také vyplnit soubor dat? - Označit jak dokončené - Název - Vzorec - Přesto dokončit Chybí %s %s z %s %s Stav události - Datum zápisu Nejsou k dispozici žádná data. Kliknutím na „+“ přidáte nový záznam Nejsou k dispozici žádná data. Nemáte oprávnění k přidávání nových záznamů. Nebyl poskytnut žádný popis ani instrukce Data ke kontrole - Uloženo a dokončeno! Již tu je aktivní zápis Otevřít Dokončit Zrušeno - Data Vybraný %s nelze stáhnout. - Dokončeno %s : %s - Aktualizováno: %s - Hotovo - Nemohli jsme zkontrolovat kvalitu dat. Kontaktujte svého správce - Jste si jist? - Chcete znovu otevřít soubor dat? Datum - Datum události - %s nemá žádné veřejné atributy. Vyberte program Zásady ochrany osobních údajů Kliknutím sem zobrazíte naše zásady ochrany osobních údajů Hodnoty souřadnic nejsou platné + Do tohoto programu nemůžete zapsat %s z jiných organizačních jednotek, protože je chráněný.\nKontaktujte svého správce. Toto pole je povinné Z bezpečnostních důvodů není možné tuto verzi aplikace používat s adb. Použijte tréninkovou verzi. Zpět - Zavřít aplikaci - Více detailů - Více detailů - Zavřít Zkopírovat do schránky - Zkopírováno do schránky - Informace o chybě Při synchronizaci se serverem jsme našli chybu. Pokud je to možné, sdílejte chybu se správcem systému. - Nemohli jsme zahájit relaci s vaším uživatelem Jsou plánované události, které nebudou znovu naplánovány Grafy Seznam @@ -778,47 +490,26 @@ Zpětná vazba Indikátory Povolení k použití kamery je zamítnuto.\nChcete-li tuto funkci používat, musíte ji povolit. - Hodnota Grafy a indikátory Vyfotit - Vybrat z galerie - Hotovo! Tento program je chráněn Zde popište důvod - Ověření - Zadaná adresa URL serveru není instance dhis - Chybné registrační údaje - Neznámý server - Hodnota musí být celé číslo Platnost relace vypršela Vaše aktuální relace vypršela. Z bezpečnostních důvodů budete muset znovu zadat své přihlašovací údaje - Hodnoty Týden %d %s až %s Znovu nezobrazovat Konfigurace funkcí Zobrazit jako radar - Nelze nastavit typ souřadnic Navázalo na %s - %s nelze analyzovat jako datum Toto pole nelze aktualizovat. Prosím zkuste to znovu - Vydání - Dostupné Tato data nelze upravovat, protože jsou označena jako dokončená Tato data nelze upravovat, protože vypršel čas jejich vydání K úpravě těchto údajů nemáte oprávnění Tato data nelze upravovat, protože organizační jednotka je zavřená K úpravě těchto údajů nemáte oprávnění - Data z této organizační jednotky nemůžete upravovat - Obnovit vyhledávání + Data z této organizační jednotky nemůžete upravovat Online vyhledávání vrátilo následující zprávu: - Upozornění na pravidla programu - Došlo k problému s konfigurací, který způsobuje smyčku v pravidlech. Kontaktujte svého správce. - OBNOVIT - Neznámý - Obnovit všechna data - Dosáhli jste maximálního počtu účtů (%d). Chcete-li přidat nový účet, přihlaste se a smažte jeden ze svých účtů Smazat účet - Spravovat účty Odstraňování problémů s konfigurací Jazyk Klepnutím sem změníte jazyk aplikace @@ -837,9 +528,7 @@ Pro toto hledání nejsou žádné výsledky Příliš mnoho výsledků Zkuste změnit vyhledávání a zúžit výsledky. - Vytvořit nový Nový %s - Pro vyhledávání je nutné zadat alespoň atributy %s Ok Spusťte vyhledávání a najděte jakýkoli %s Můžete vyhledat nebo vytvořit nový %s @@ -848,7 +537,6 @@ Některá pole obsahují chyby a neukládají se. \nChcete formulář zkontrolovat\? Chcete tento formulář označit jako vyplněný? Máte několik chybových hlášení.\nChcete je zkontrolovat\? - Pokud nyní skončíte, změny budou zahozeny. Znovu otevřeno Účet zakázán Váš účet byl zakázán správcem. @@ -858,27 +546,10 @@ Aktuální datum: %s Zařízení: %s Verze OS: %s - Otevřete podrobnosti %s - Podrobnosti %s Synchronizace %s… - SMS odeslána. Čekání na odpověď serveru. - SMS potvrzena pro spojení se serverem. Aktualizace stavu - Nemůžeme potvrdit, že SMS dorazila na server. Aktualizace stavu - SMS odeslána. Aktualizace stavu. - SMS nebyla odeslána. - Otevření aplikace SMS. Zprávu prosím neupravujte. Poslat SMS s Poslal jste sms? - Čeká se na ruční potvrzení - Synchronizace událostí - Synchronizace trasovaných entit - Synchronizace datových sad - Synchronizace zdrojů - Synchronizace dokončena - Synchronizace SMS je k dispozici, ale musí být přístupná pro každý jednotlivý záznam Stahování obrázků... - Popis programu - Špatný pin Zapsáno v Ve vlastnictví Je nutná synchronizace @@ -898,24 +569,11 @@ Obnovit Teď ne Klepnutím sem prozkoumejte - - %s chyba - %s chyby - %s chyb - %s chyb - - - %s varování - %s varování - %s varování - %s varování - Nyní je k dispozici verze aplikace %s . Chcete si ji stáhnout? Aktualizace softwaru Později Stáhnout teď Kontrola aktualizací - Kontrola aktualizací Nejsou žádné nové aktualizace Verze aplikace Něco se pokazilo v konfiguraci programu, tuto událost nelze načíst. Obraťte se prosím na správce systému. @@ -954,18 +612,10 @@ Programová fáze událost Časová osa - Vymazat hledání - Volitelný Databáze stažena - Importování databáze - Nejsou nakonfigurována žádná pole pro vyhledávání.\nObraťte se na správce. Zobrazit více Zobrazit méně Synchronizace - Zobrazit pole - Skrýt pole - Import byl úspěšný - Tato část je špatně nakonfigurovaná.\nObraťte se na správce. Úspěšná synchronizace Synchronizace %s… zdroje souborů @@ -982,4 +632,24 @@ Dokončeno %s Deaktivovat %s Znovu otevřít %s + + %s chyba + %s chyby + %s chyb + %s chyb + + + + %s varování + %s varování + %s varování + %s varování + + + + Je tu %s program se specifickým nastavením + Jsou tu %sprogramy se specifickým nastavením + Je tu %s programů se specifickým nastavením + Jsou tu %s programy se specifickým nastavením + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 3598c37b99f..6eb6eb75ffa 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1,27 +1,29 @@ Error + + Events - Add new - Add - Username - Password + Account recovery + Daily Weekly Monthly Yearly + + Open Read only Active Error + + + - Create Update - Date - Category option Due date Cancel @@ -29,50 +31,31 @@ Cancel Settings Status - - - Enrollment date + + Incident date - Clear + Next - Save OK Events About - Max - Warning - Error Save - Indicators - Details - Indicators Please wait Warning Close - Open - Daily - Weekly - Status Last week Last month Other Open Data sets - Details - Name - Enrollment date Open Date - Close Charts Programs Charts Details Feedback Indicators - Value must be an integer - Available Programs - Optional diff --git a/app/src/main/res/values-en-rUS/strings.xml b/app/src/main/res/values-en-rUS/strings.xml index 6a29a088d9e..51a85ddf68d 100644 --- a/app/src/main/res/values-en-rUS/strings.xml +++ b/app/src/main/res/values-en-rUS/strings.xml @@ -11,11 +11,13 @@ Enter positive integer or zero Enter positive integer Choose date + Error - Date + Org unit Latitude Longitude + Due date Cat combo @@ -27,32 +29,20 @@ Add file Coordinates for the + + - Days - Months - Years Incident date Share Check this! - Error - This server is running a dhis version different than 2.29. Update your server version or use the testing environment to proceed. - Error while parsing results This is not a valid phone number - This is not a valid email - Only negative numbers are allowed Only positive numbers or zero allowed - Only positive numbers are allowed - Only values from 0 to 1 are allowed - Only values from 0 to 100 are allowed The value should be unique, a coincidence has been found. It has not been saved. Please check it. - No info for this field - Accept Close - Complete Formatting error - This is not a valid url Copied - Polygon captured + + Polygon Attributes - %s Select time @@ -66,23 +56,14 @@ Enter value Saved! Complete - Done Date - Coordinates values are not valid This field is mandatory - Close Take Photo - Unknown server - Value must be an integer Do not show again - %s can\'t be parsed as a date This field could not be updated. Please try again - Program rules warning - There is a configuration issue causing a loop in the rules. Contact you administrator. Not saved You have some warning messages. Do you want to mark this form as complete? - %s details Not now Coordinates Show less diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 4224606fe9b..2298920f257 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1,63 +1,26 @@ DHIS 2 - DHIS - 2 Error - - Error - Errores - Errores - - URL incorrecta - Credenciales incorrectas - Error desconocido - Error interno - sin valor No quedan valores reservados. Contacte con el administrador del sistema. - No quedan valores reservados. Conéctese a internet para recargarlos. - errores Algo ha ido mal - Sincronizar ahora - Sincronizando con el servidor - Sincronizando - Metadatos Eventos - Entidades monitoreadas - - Terminado - Sincronizacion completa Error en sincronizacion La sincronizacion ha fallado. Intentelo de nuevo. La sincronización ha terminado pero no se ha recibido confirmación del servidor en algunos rescursos. Éstos estarán marcados como \"no sincronizados\" en la App. Recomendamos reintentar la sincronización. - WARNING: la mayoría de sus datos se han sincronizado, pero algunos generaron conflictos. Sincronice su configuración y pruebe otra vez. Si el error persiste, revise el estado de sincronización: los programas, TEIs y events con conflictos tienen el icono @ - Hubo un error y el serivdor no respondío. Contacte su administrador. - - %s resultados - Agregar nuevo - Añadir - Programar Remitir - URL de servidor - Nombre de usuario - Contraseña - Iniciar sesion - Desbloquear sesion Sincronizando tu configuracion Sus datos estaran disponibles pronto Sincronizando sus datos Su configuracion esta lista Sus datos están disponibles - Error en la sincronizacion de configuracion - Error en la sincronizacion de datos Recuperación de cuenta - Necesita conexión de datos si desea recuperar su cuenta. Diario @@ -75,126 +38,74 @@ Introducir texto Introducir texto largo Introducir numero - Introducir entero Introducir entero negativo Introducir entero o cero Introducir entero positivo - Opciones Introducir fecha - Cambiar calendario - Programa Completado - Programa suspendido Abrir Evento completado Sólo lectura - Acabar y completar Completado Cancelar - Activar Activo Sincronizado Por enviar Para actualizar - Para borrar Error - Sincronizar - ENVIAR - Sincronizar SMS - Completado - Crear Actualizar - Fecha Unidad Organizativa No hay Unidades Organizativas abiertas para la fecha seleccionada. Seleccione otra fecha u otra Unidad Organizativa. - Opcion de categoria Latitud Longitud - Evento nuevo - Una vez - Permanente Periodo de presentacion de informe - Opción de categoría - Si introduce un valor, tendrá que completar toda la fila para poder marcar como Completado el Data Set. - Hay campos obligatorios vacíos. Deben tener valor para poder marcar como Completado el Data Set. - Hay campos obligatorios vacíos. Por favor, rellénelos para poder completar el set de datos. - Guardado! - No hay periodos disponibles. Fecha de vencimiento Cat Combo - DD/MM/YYYY Cancelar Borrar - Total Fecha de evento más reciente Programas activos - Programas disponibles para inscripción Admitir - Ultima visita: - Aplicar - - - Ver detalles - Ver mas - Seguimiento - Seguimiento activado - Seguimiento desactivado + Desactivar Este programa no admite más eventos - No hay datos Eventos Inicio - Información de la App Cerrar Sesion - Bloquear sesion - 1 minuto - 15 minutos 30 minutos 1 hora 6h 1 día (por defecto) 12h - Sincronizacion de datos automatica cada: Manual - Sincronizacion de configuracion automatica cada: Semanal Ajustes actuales de sincronización: Volver a los valores por defecto + Se borrará la base de datos. \nTendrá que volver a iniciar sesión. Aceptar Cancelar Sincronizar datos Ahora Sincronizar configuracion ahora Configuración - Última sincronización: %s - Última sincronización: %s Recargar - Modulo SMS: SMS gateway Emisor del SMS de resultado - segundos - numero - numero Permitir envío por SMS Esperar confirmación por SMS - - Hay %s programa con una configuración específica - Hay %s programas con configuraciones específicas - Hay %s programas con configuraciones específicas - Configuración limitada Globalmente por programa @@ -212,15 +123,11 @@ Aquí están todos los errores encontrados durante la sincronización de datos y metadatos. Borrar datos locales Se borrarán todos los datos guardados por la App. Los datos que no se hayan enviado al servidor se perderán. - Reiniciar app - Se borrará la configuración y todos los datos guardados por la App. Tendrá que iniciar sesión de nuevo y los datos que no se hayan enviado al servidor se perderán. Configuración SMS Verifique los parámetros relacionados con la SMS gateway Estado - Fecha de inscripción - Buscar @@ -241,29 +148,15 @@ Coordenadas para - Lista de secciones - %s preguntas de %s completadas - %s de %s completados - Completar y cerrar - Días - Meses - Años - Semanas Fecha del incidente - Resultados locales - Resultados online Programas pasados No tiene los permisos necesarios para realizar esta acción - Añadir como… - Limpiar Periodo - (%d) Unidad Org. - Nivel %d Bienvenido a dhis2 Aquí aparecerán sus actividades Puedes aplicar un filtro… @@ -272,16 +165,11 @@ En el menú principal podrá realizar más acciones - Añadir relación Siguiente Es necesario introducir al menos %d de los atributos para iniciar una búsqueda - Hay que rellenar al menos un atributo para buscar - Hay más de %d resultados. Por favor, especifique más su búsqueda - Introduzca algún criterio de búsqueda Importar datos QR - Guardar Correcto Identificación Atributos @@ -293,32 +181,20 @@ sin datos Código QR no válido Este atributo no puede ser añadido - Esta admisión no puede ser añadida Acerca de Programas disponibles para admisión - Selecionar todo - Borrar selección - No hay información para los filtros seleccionados. Pruebe modificándolos. Compartir Compartir QR Máx En uso TEI TEIs - Limitar por unid. organizativa - Limitado por programa - Listado de programas Reiniciar app - No es posible añadir esta relación ¡Importante! - Advertencia - Error Guardar Valores Reservados - Quiere añadir más información de códigos QR? - Versión de la app: %s Versión del SDK: %s @@ -329,141 +205,44 @@ Desarrollada y mantenida por el proyecto DHIS2, parte de la Universidad de Oslo, departamento de informática. Para más información o preguntas sobre la aplicación, por favor escriba a android\@dhis2.org - La contraseña está vacía. Complete este campo por favor. - El usuario está vacío. Complete este campo por favor. - Este servidor trabaja sobre una versión de dhis distinta a 2.29. Actualice el servidor o utilice el entorno de pruebas para continuar. - Este servidor tiene una version de dhis diferente de %s. Actualice su servidor o use el servidor de testing para continuar. - Algo ha ido mal. Por favor, compruebe que tanto la url del servidor como el usuario y contraseña son correctos y pruebe otra vez. - El servidor no ha podido completar su solicitur. - Ha habido un problema intentando conectarse al servidor. - Error de Acceso - Los campos obligatorios no pueden estar vacios - Error inesperado mientras se ejecutaba la búsqueda online - Error al obtener los resultados - La busqueda online devolvió un error - Algo fue mal al conectar con el servidor Seleccione un programa para habilitar nuevas admisiones No tiene los permisos necesarios - Esta funcionalidad no está lista. - Error al recuperar la etiqueta de la relación Todo %s Este no es un número de teléfono válido - Este no es un email válido - Solo se permiten números negativos Solo se permiten números positivos o cero - Solo se permiten números positivos - Solo se permiten valores entre 0 y 1 - Solo se permiten valores entre 0 y 100 - Selecciona las fechas para filtrar Seleccionar opción - Incidencia reportada - Generar nuevo evento - ¿Quiere crear un nuevo evento? Eventos Completados Todos los eventos de este programa están completados. ¿Quiere completar el programa también? - ¿ Completar también la inscripción? Ver Inicio EJEMPLO - Añadir Widget Ver ayuda - Sincronice sus datos... - ... sincronive su configuracion... - Configure el almacenamiento local - Ver y gestionar cuantos valores reservados quedan - Compruebe si la conexión con el servidor ha fallado - Liberar memoria - o borre todo y empiece de cero. - Encuentre sus entidades tanto en tu dispositivo local como en el servidor. - Puede cambiar de programa - Buscar o añadir nuevas entidades. ¡ Hay que buscar antes de añadir nuevas entidades ! - Click aquí para limpiar los campos y reiniciar la búsqueda. Este es el Dashboard de entidad Puede ver la información de perfil - Compartir información via QR o SMS - O marcar una entidad para seguimiento - Puede crear o programar un evento, o referir a otra Org. Unit Aquí estan todos los eventos. Pulse para ver los detalles. Navegue el menú para añadir relaciones, ver indicadores o escribir notas. - Aqui puede ver todos los enrolments o crear uno nuevo. - Pulse para borrar este evento. Esta acción no se puede deshacer. - Completar el evento. - Todos los eventos de este programa seran listados aqui. - Haga click para añadir un nuevo evento Complete todos los campos para crear el evento Aqui puedes ver el % de campos completos en el evento. - Haga click para completar o re-abrir un evento - Lea y almacene información de otros dispositivos a través de códigos QR - Sincronice sus datos con el servidor - Bloquee la session con un digito de 4 cifras. ¡No olvide su codigo! - Cierre sesión para cambiar de usuario o de servidor. Podrá volver a entrar con el mismo usuario si no hay internet. - %s: relaciones No hay eventos registrados, haga click en \"+\" para añadir uno. - No hay eventos registrados. Valores de datos Información No se ha podido leer este Id Borrar datos General - Relaciones - Indicadores - Analíticas No hay indicadores para este programa. - Notas - No se puede completar - No se pudo guardar - Hay mensajes de aviso en el formulario - ¿Qué quiere hacer? - Algunos campos contienen errores. Por favor, revíselos para poder completar. Deseleccionar inscripción Mostrar todos los programas activos - Comprobar errores de sincronización Lista de errores de sincronización Compartir con... - La sincronización de configuración ha fallado. Puede haber fallos en el servidor. Haga click aquí para mas información. - ANTERIOR - SIGUIENTE - Detalles - Indicadores - Notas - - Completar mas tarde - - Acabar - Campos con errores - Algunos campos presentan errores. No se han guardado. Revíselos por favor. El valor debe ser único, se ha encontrado una coincidencia. El campo no se ha guardado, revíselo. - Se ha encontrado una coincidencia en un campo único. Por favor compruebe el formulario para guardar el registro. Por favor, espere Omitir - Reprogramar No hay información sobre esta pantalla - Atrasado - Hay alguna program rule no soportada. Revise la documentación. - No hay información para este campo - Vaciar credenciales - Para poder iniciar sesión por huella dactilar debe entrar primero por el modo normal - Seguridad biométrica - Tablas - Tabla - https:// Los datos no sincronizados se perderán. Continuar? - Aceptar Los datos no se pudieron borrar. Inténtelo de nuevo. Los datos locales se borraron correctamente. - Debe seleccionar al menos una unidad organizativa. - Enviar datos - Ayude a DHIS2 a mejorar esta app compartiendo errores asociados a su usuario. - Comprobar campos - Se perdió la conexión a internet durante la sincronización. Inténtelo mas tarde. - Confirmar - Seguir - URL de servidor - ¿Olvido su código? - ¿Que quiere hacer con este evento? - FIN %s no encontrado Este registro no esta disponible. ¿Quiere buscarlo? Sincronizado por SMS @@ -471,33 +250,16 @@ Subiendo Relación Enviado por mensaje de texto - Este programa no tiene conflictos - No hay conexión a internet, pero hay señal telefónica - puede utilizar SMS. - Conexión no disponible. Cerrar Puede ver la información de configuración haciendo click aquí - Estos datos están guardados en su dispositivo solamente, no se han enviado al servidor. - ¡Esta al día! Todos tus datos se han enviado al servidor. No se puede relacionar un registro con el mismo - Desbloquear sesión - Completar - Abierto - Tipo de valor no admitido Cambiar vista - 15 minutos - Cada hora - Diario - Semanal - Semana - Parámetros de configuración: %s Este registro no tiene ninguna inscripción. No se puede acceder al dashboard. Error de formato Eliminar %s No tienes permisos para borrar este registro - La URL no es válida Adaptando el formulario... - Numero receptor Quiere enviar %d SMS? Enviando %1$d/%2$d Esperando confirmación - %d @@ -515,51 +277,29 @@ Puerta de acceso no definida El usuario no ha iniciado sesión Modulo SMS desabilitado - Se está enviando otro SMS en este momento Modulo SMS no inicializado. No se encontraron los metadatos necesarios. - Completado No se ha aceptado el número de SMS Inicio del envío de sms Datos leídos y convertidos. Id de envío: 1%d - Error al enviar sms SMS para registro de TEI SMS para evento SMS para conjunto de datos - Iniciando - En proceso - Enviado - Ha fallado - Iniciando sincronización. Sincronizando... - Sincronización completada - Ocurrió un error en la sincronización - Se ha producido un error durante la sincronización. Pulse aquí para ver el listado de errores - Cancelar sincronización Copiado - Imagen seleccionada - Mostrar en el Mapa - Mostrar lista - %s es obligatorio y no tiene valor - Polígono guardado Polígono - Huella dactilar - Por favor, toque con el dedo el lector de huellas. Seguridad Por motivos de seguridad, esta versión de la aplicación no permite su uso en dispositivos root. Por favor use la versión de formación. - Hay algunos errores. Por favor corríjalos antes de continuar. Filtros activos Filtros desactivados Periodo Unidad Org. - Orden - Estado Sincronizado Asignado a mí Hoy @@ -574,8 +314,6 @@ Desde - hasta Otro Cualquier momento - La sincronización falló. Compruebe los valores para tratar de resolver el problema e inténtelo otra vez. Si el fallo persiste contacte con su administrador del sistema. - Los datos se han enviado al servidor pero no se puede garantizar que se hayan guardado Abierto Planificado Atrasado @@ -591,7 +329,6 @@ El gateway de SMS no puede estar vacío No hay registros que sincronizar La conversión elegida no está soportada - No volver a mostrar la pantalla de bloqueo Establecer pin Conflicto Cambiar fecha @@ -616,11 +353,7 @@ Elija unidad organizativa Introduzca url Introduzca valor - Elija una imagen - Escanear código - Elija una opción Valores reservados descargados por atributo de entidad - Valores reservados descargados por atributo de entidad Quedan %s valores reservados Gestionar valores reservados Eventos a descargar @@ -640,55 +373,29 @@ No Sincronizado Error en la sincronización Sincronización de SMS - Detalles - Comentarios ¡Guardado! - ¿Quiere validar la calidad de los datos? - ¡Parece que todo está bien! - ¿Quiere completar también el set de datos? - Marcar como completado - Nombre - Fórmula - Completar de todas formas No se ha encontrado %s %s en %s %s Estado del evento - Fecha de inscripción No hay datos. Pulse \"+\" para añadir un nuevo registro No hay datos. No tiene permisos para añadir un nuevo registro. No se han proporcionado descripción o instrucciones Datos a revisar - ¡Guardado y completado! Ya existe un registro activo Abierto Completar Cancelar - Datos El %s seleccionado no puede ser descargado. - Completado por %s: %s - Actualizado: %s - Terminado - No hemos podido verificar la calidad de los datos. Contacte con su administrador - ¿Está seguro? - ¿Quiere reabrir este set de datos? Fecha - Fecha de evento - %s no tiene atributos públicos. Por favor seleccione un programa Política de privacidad Pulse aquí para comprobar nuestra política de privacidad El valor de las coordenadas no es válido + No puede registrar un/una %s desde otras unidades organizativas en este programa por que está protegido.\nContacte con su administrador. El campo es obligatorio Por razones de seguridad, esta versión de la app no permite el uso de adb. Por favor use la versión de formación. Ir atrás - Cerrar app - Más detalles - Más detalles - Cerrar Copiar al portapapeles - Copiado al portapapeles - Información del error Encontramos un error al sincronizar con su servidor. Comparta el error con el administrador del sistema si es posible. - No pudimos iniciar la sesión con su usuario Hay eventos planificados que no serán reprogramados Gráficas Vista de tabla @@ -735,46 +442,27 @@ Retroalimentación Indicadores Valor + El permiso de la cámara está denegado.\nDebe habilitarlo para usar esta función. Gráficos e indicadores Tomar foto - Elija de la galería - ¡Listo! Este programa está protegido Describe aquí la razón - Autentificando - El enlace del servidor proporcionada no es una instancia de dhis - Credenciales incorrectas - Servidor desconocido - El valor debe ser un entero La sesión ha terminado Su sesión actual ha terminado. Por razones de seguridad, tendrá que volver a proporcionar sus credenciales - Valores Semana %d %s a %s No volver a mostrar Configuración de funciones Ver como gráfico de radar - No se pudo modificar las coordenadas Seguido %s - %s no es una fecha válida No se ha podido actualizar este campo. Prueba otra vez. - Edición - Disponible Los datos no son editables por que está marcado como completado. Los datos no son editables por que el tiempo de edición ha expirado. No tiene permisos de edición para estos datos. Los datos no son editables por que la unidad organizativa está cerrada. No tiene permisos de edición para estos datos. - No puede editar datos en esta unidad organizativa. - Reiniciar búsqueda + No puede editar datos en esta unidad organizativa. La búsqueda online a devuelto este mensaje: - Advertencia de reglas del programa - Hay un problema de configuración que provoca un bucle en las reglas. Póngase en contacto con su administrador. - Recarga - Desconocido - Recarga todos los datos - Se ha alcanzado el nunero máximo de cuentas de usuario (%d). Para agregar una nueva cuenta, inicie sesion y elimine una de las existentes. Eliminar una cuenta - Administrar cuenta Revisión de configuración Idioma Toque aquí para cambiar el idioma de la aplicación @@ -792,13 +480,12 @@ No hay más resultados para esta búsqueda Demasiados resultados Intente cambiar su búsqueda para restringir los resultados. - Crear nuevo - Es necesario que ingrese al menos %s atributos para iniciar la búsqueda De acuerdo Empieza una búsqueda para encontrar %s Puedes buscar o crear un nuevo %s ¿Desea marcar este formulario como completo? Si sales los cambios serán descartados. + Tiene algunos mensajes de error. \n¿Desea revisarlos? Reabrir Cuenta deshabilitada Tu cuenta ha sido deshabilitada por un administrador @@ -808,16 +495,8 @@ Fecha actual: %s Dispositivo: %s Versión del SO: %s - Abrir detalles de %s Sincronizando %s... - SMS enviado. Esperando respuesta del servidor. - Se ha confirmado que el SMS llegó al servidor. Actualizando estado - No podemos confirmar que el SMS llegó al servidor. Actualizando estado - SMS enviado. Actualizando estado. - SMS no enviado. - Abriendo aplicación de SMS. Por favor, no modifique el mensaje. Enviar SMS con - Esperando confirmación manual Sincronizado Sincronizando Ahora no @@ -829,4 +508,9 @@ evento Sincronizado Sincronizando %s... - + + Hay %s programa con una configuración específica + Hay %s programas con configuraciones específicas + Hay %s programas con configuraciones específicas + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0ba47fac40a..73b5115e7af 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,65 +1,28 @@ DHIS 2 - DHIS - 2 Error - - Error - Errores - Errores - - URL incorrecta - Credenciales incorrectas - Error desconocido - Error interno - sin valor No quedan valores reservados. Contacte con el administrador del sistema. - No quedan valores reservados. Conéctese a internet para recargarlos. - errores Algo ha ido mal - Sincronizar ahora - Sincronizando con el servidor - Sincronizando - Metadatos Eventos - Entidades monitoreadas - - Terminado - Sincronizacion completa Error en sincronizacion La sincronizacion ha fallado. Intentelo de nuevo. La sincronización ha terminado pero no se ha recibido confirmación del servidor en algunos rescursos. Éstos estarán marcados como \"no sincronizados\" en la App. Recomendamos reintentar la sincronización. - WARNING: la mayoría de sus datos se han sincronizado, pero algunos generaron conflictos. Sincronice su configuración y pruebe otra vez. Si el error persiste, revise el estado de sincronización: los programas, TEIs y events con conflictos tienen el icono @ - Hubo un error y el serivdor no respondío. Contacte su administrador. - - %s resultados - Agregar nuevo - Añadir Ingresar %s Editar / Omitir %s - Programar Remitir - URL de servidor - Nombre de usuario - Contraseña - Iniciar sesion - Desbloquear sesion Sincronizando tu configuracion Sus datos estaran disponibles pronto Sincronizando sus datos Su configuracion esta lista Sus datos están disponibles - Error en la sincronizacion de configuracion - Error en la sincronizacion de datos Recuperación de cuenta - Necesita conexión de datos si desea recuperar su cuenta. Diario @@ -80,140 +43,88 @@ Introducir texto Introducir texto largo Introducir numero - Introducir entero Introducir entero negativo Introducir entero o cero Introducir entero positivo - Opciones Introducir fecha - Cambiar calendario - Programa Completado - Programa suspendido Abrir No completado Evento completado Sólo lectura - Acabar y completar Completado Cancelar Reabrir - Reabrir formulario - Activar Activo Sincronizado Por enviar Para actualizar - Para borrar Error - Sincronizar - ENVIAR - Sincronizar SMS - Completado - Crear Actualizar - Fecha Unidad Organizativa No hay Unidades Organizativas abiertas para la fecha seleccionada. Seleccione otra fecha u otra Unidad Organizativa. - Opcion de categoria Latitud Longitud - Evento nuevo - Una vez - Permanente Periodo de presentacion de informe - Opción de categoría - Si introduce un valor, tendrá que completar toda la fila para poder marcar como Completado el Data Set. - Hay campos obligatorios vacíos. Deben tener valor para poder marcar como Completado el Data Set. - Hay campos obligatorios vacíos. Por favor, rellénelos para poder completar el set de datos. - Guardado! - No hay periodos disponibles. Fecha de vencimiento Siguiente %1$s Cat Combo - DD/MM/YYYY Cancelar Borrar - Total Fecha de evento más reciente No hay opciones disponibles Lista de inscripción Programas activos - Programas disponibles para inscripción Admitir - Ultima visita: - Aplicar - Ver detalles - Ver mas Editar - Seguimiento - Seguimiento activado - Seguimiento desactivado Desactivar Este programa no admite más eventos - No hay datos Eventos Inicio - Información de la App Cerrar Sesion - Bloquear sesion Mostrar %s más... Mostrar menos... - 1 minuto - 15 minutos 30 minutos 1 hora 6h 1 día (por defecto) 12h - Sincronizacion de datos automatica cada: Manual - Sincronizacion de configuracion automatica cada: Semanal Ajustes actuales de sincronización: Volver a los valores por defecto + Se borrará la base de datos. \nTendrá que volver a iniciar sesión. Aceptar Cancelar Sincronizar datos Ahora Sincronizar configuracion ahora Configuración - Última sincronización: %s Última sincronización - Última sincronización: %s Recargar - Modulo SMS: SMS gateway Emisor del SMS de resultado Tiempo de espera para SMS (seg) - segundos - numero - numero Permitir envío por SMS Esperar confirmación por SMS - - Hay %s programa con una configuración específica - Hay %s programas con configuraciones específicas - Hay %s programas con configuraciones específicas - Periodo de sincronización Configuración limitada Globalmente @@ -232,20 +143,15 @@ Aquí están todos los errores encontrados durante la sincronización de datos y metadatos. Borrar datos locales Se borrarán todos los datos guardados por la App. Los datos que no se hayan enviado al servidor se perderán. - Reiniciar app - Se borrará la configuración y todos los datos guardados por la App. Tendrá que iniciar sesión de nuevo y los datos que no se hayan enviado al servidor se perderán. Configuración SMS Verifique los parámetros relacionados con la SMS gateway Actualización de software - Software Exportar base de datos Exporta tu base de datos y compártela con tu administrador Autenticación de dos factores Estado - Fecha de inscripción - Buscar @@ -267,29 +173,15 @@ Coordenadas para - Lista de secciones - %s preguntas de %s completadas - %s de %s completados - Completar y cerrar - Días - Meses - Años - Semanas Fecha del incidente - Resultados locales - Resultados online Programas pasados No tiene los permisos necesarios para realizar esta acción - Añadir como… - Limpiar Periodo - (%d) Unidad Org. - Nivel %d Bienvenido a dhis2 Aquí aparecerán sus actividades Puedes aplicar un filtro… @@ -298,16 +190,11 @@ En el menú principal podrá realizar más acciones - Añadir relación Siguiente Es necesario introducir al menos %d de los atributos para iniciar una búsqueda - Hay que rellenar al menos un atributo para buscar - Hay más de %d resultados. Por favor, especifique más su búsqueda - Introduzca algún criterio de búsqueda Importar datos QR - Guardar Correcto Identificación Atributos @@ -319,32 +206,20 @@ sin datos Código QR no válido Este atributo no puede ser añadido - Esta admisión no puede ser añadida Acerca de Programas disponibles para admisión - Selecionar todo - Borrar selección - No hay información para los filtros seleccionados. Pruebe modificándolos. Compartir Compartir QR Máx En uso TEI TEIs - Limitar por unid. organizativa - Limitado por programa - Listado de programas Reiniciar app - No es posible añadir esta relación ¡Importante! - Advertencia - Error Guardar Valores Reservados - Quiere añadir más información de códigos QR? - Versión de la app: %s Versión del SDK: %s @@ -355,131 +230,43 @@ Desarrollada y mantenida por el proyecto DHIS2, parte de la Universidad de Oslo, departamento de informática. Para más información o preguntas sobre la aplicación, por favor escriba a android\@dhis2.org - La contraseña está vacía. Complete este campo por favor. - El usuario está vacío. Complete este campo por favor. - Este servidor trabaja sobre una versión de dhis distinta a 2.29. Actualice el servidor o utilice el entorno de pruebas para continuar. - Este servidor tiene una version de dhis diferente de %s. Actualice su servidor o use el servidor de testing para continuar. - Algo ha ido mal. Por favor, compruebe que tanto la url del servidor como el usuario y contraseña son correctos y pruebe otra vez. - El servidor no ha podido completar su solicitur. - Ha habido un problema intentando conectarse al servidor. - Error de Acceso - Los campos obligatorios no pueden estar vacios - Error inesperado mientras se ejecutaba la búsqueda online - Error al obtener los resultados - La busqueda online devolvió un error - Algo fue mal al conectar con el servidor Seleccione un programa para habilitar nuevas admisiones No tiene los permisos necesarios - Esta funcionalidad no está lista. - Error al recuperar la etiqueta de la relación Todo %s Este no es un número de teléfono válido - Este no es un email válido - Solo se permiten números negativos Solo se permiten números positivos o cero - Solo se permiten números positivos - Solo se permiten valores entre 0 y 1 - Solo se permiten valores entre 0 y 100 - Selecciona las fechas para filtrar Seleccionar opción - Incidencia reportada - Generar nuevo evento - ¿Quiere crear un nuevo evento? Eventos Completados Todos los eventos de este programa están completados. ¿Quiere completar el programa también? - ¿ Completar también la inscripción? ¿Le gustaría completar %s también? Ver Inicio EJEMPLO - Añadir Widget Ver ayuda - Sincronice sus datos... - ... sincronive su configuracion... - Configure el almacenamiento local - Ver y gestionar cuantos valores reservados quedan - Compruebe si la conexión con el servidor ha fallado - Liberar memoria - o borre todo y empiece de cero. - Encuentre sus entidades tanto en tu dispositivo local como en el servidor. - Puede cambiar de programa - Buscar o añadir nuevas entidades. ¡ Hay que buscar antes de añadir nuevas entidades ! - Click aquí para limpiar los campos y reiniciar la búsqueda. Este es el Dashboard de entidad Puede ver la información de perfil - Compartir información via QR o SMS - O marcar una entidad para seguimiento - Puede crear o programar un evento, o referir a otra Org. Unit Aquí estan todos los eventos. Pulse para ver los detalles. Navegue el menú para añadir relaciones, ver indicadores o escribir notas. - Aqui puede ver todos los enrolments o crear uno nuevo. - Pulse para borrar este evento. Esta acción no se puede deshacer. - Completar el evento. - Todos los eventos de este programa seran listados aqui. - Haga click para añadir un nuevo evento Complete todos los campos para crear el evento Aqui puedes ver el % de campos completos en el evento. - Haga click para completar o re-abrir un evento - Lea y almacene información de otros dispositivos a través de códigos QR - Sincronice sus datos con el servidor - Bloquee la session con un digito de 4 cifras. ¡No olvide su codigo! - Cierre sesión para cambiar de usuario o de servidor. Podrá volver a entrar con el mismo usuario si no hay internet. - %s: relaciones No hay eventos registrados, haga click en \"+\" para añadir uno. - No hay eventos registrados. Valores de datos Información No se ha podido leer este Id Borrar datos General - Relaciones - Indicadores - Analíticas No hay indicadores para este programa. - Notas - No se puede completar - No se pudo guardar - Hay mensajes de aviso en el formulario - ¿Qué quiere hacer? - Algunos campos contienen errores. Por favor, revíselos para poder completar. Deseleccionar inscripción Mostrar todos los programas activos - Comprobar errores de sincronización Lista de errores de sincronización Compartir con... - La sincronización de configuración ha fallado. Puede haber fallos en el servidor. Haga click aquí para mas información. - ANTERIOR - SIGUIENTE - Detalles - Indicadores - Notas - - Completar mas tarde - - Acabar - Campos con errores - Algunos campos presentan errores. No se han guardado. Revíselos por favor. El valor debe ser único, se ha encontrado una coincidencia. El campo no se ha guardado, revíselo. - Se ha encontrado una coincidencia en un campo único. Por favor compruebe el formulario para guardar el registro. Por favor, espere Omitir - Reprogramar No hay información sobre esta pantalla - Atrasado - Hay alguna program rule no soportada. Revise la documentación. - No hay información para este campo - Vaciar credenciales - Para poder iniciar sesión por huella dactilar debe entrar primero por el modo normal - Seguridad biométrica - Acceso rápido - ¿Te gustaría usar tu huella o algún otro método biométrico para acceder? - Tablas - Tabla - https:// Los datos no sincronizados se perderán. Continuar? - Aceptar Los datos no se pudieron borrar. Inténtelo de nuevo. Los datos locales se borraron correctamente. Debe seleccionar al menos una unidad organizativa. @@ -492,6 +279,7 @@ Confirmar Seguir URL de servidor + Introduzca la URL del servidor DHIS 2 de su organización proporcionada por su administrador. También puede escanear un código QR con la URL de su servidor. ¿Olvido su código? ¿Que quiere hacer con este evento? FIN @@ -502,33 +290,16 @@ Subiendo Relación Enviado por mensaje de texto - Este programa no tiene conflictos - No hay conexión a internet, pero hay señal telefónica - puede utilizar SMS. - Conexión no disponible. Cerrar Puede ver la información de configuración haciendo click aquí - Estos datos están guardados en su dispositivo solamente, no se han enviado al servidor. - ¡Esta al día! Todos tus datos se han enviado al servidor. No se puede relacionar un registro con el mismo - Desbloquear sesión - Completar - Abierto - Tipo de valor no admitido Cambiar vista - 15 minutos - Cada hora - Diario - Semanal - Semana - Parámetros de configuración: %s Este registro no tiene ninguna inscripción. No se puede acceder al dashboard. Error de formato Eliminar %s No tienes permisos para borrar este registro - La URL no es válida Adaptando el formulario... - Numero receptor Quiere enviar %d SMS? Enviando %1$d/%2$d Esperando confirmación - %d @@ -546,51 +317,29 @@ Puerta de acceso no definida El usuario no ha iniciado sesión Modulo SMS desabilitado - Se está enviando otro SMS en este momento Modulo SMS no inicializado. No se encontraron los metadatos necesarios. - Completado No se ha aceptado el número de SMS Inicio del envío de sms Datos leídos y convertidos. Id de envío: 1%d - Error al enviar sms SMS para registro de TEI SMS para evento SMS para conjunto de datos - Iniciando - En proceso - Enviado - Ha fallado - Iniciando sincronización. Sincronizando... - Sincronización completada - Ocurrió un error en la sincronización - Se ha producido un error durante la sincronización. Pulse aquí para ver el listado de errores - Cancelar sincronización Copiado - Imagen seleccionada - Mostrar en el Mapa - Mostrar lista - %s es obligatorio y no tiene valor - Polígono guardado Polígono - Huella dactilar - Por favor, toque con el dedo el lector de huellas. Seguridad Por motivos de seguridad, esta versión de la aplicación no permite su uso en dispositivos root. Por favor use la versión de formación. - Hay algunos errores. Por favor corríjalos antes de continuar. Filtros activos Filtros desactivados Periodo Unidad Org. - Orden - Estado Sincronizado Asignado a mí Hoy @@ -605,8 +354,6 @@ Desde - hasta Otro Cualquier momento - La sincronización falló. Compruebe los valores para tratar de resolver el problema e inténtelo otra vez. Si el fallo persiste contacte con su administrador del sistema. - Los datos se han enviado al servidor pero no se puede garantizar que se hayan guardado Abierto Planificado Atrasado @@ -615,15 +362,12 @@ Omitido Eventos:%smax:%s%sactual:%sEntidades:%smax:%s%sactual:%s Configuración limitada %s - Ocultar datos de registro - Mostrar datos de registro Módulo de SMS activado Módulo de SMS desactivado Descargando datos para el módulo de SMS. Por favor espere. El gateway de SMS no puede estar vacío No hay registros que sincronizar La conversión elegida no está soportada - No volver a mostrar la pantalla de bloqueo Establecer pin Conflicto Cambiar fecha @@ -648,11 +392,7 @@ Elija unidad organizativa Introduzca url Introduzca valor - Elija una imagen - Escanear código - Elija una opción Valores reservados descargados por atributo de entidad - Valores reservados descargados por atributo de entidad Quedan %s valores reservados Gestionar valores reservados Eventos a descargar @@ -672,55 +412,29 @@ No Sincronizado Error en la sincronización Sincronización de SMS - Detalles - Comentarios ¡Guardado! - ¿Quiere validar la calidad de los datos? - ¡Parece que todo está bien! - ¿Quiere completar también el set de datos? - Marcar como completado - Nombre - Fórmula - Completar de todas formas No se ha encontrado %s %s en %s %s Estado del evento - Fecha de inscripción No hay datos. Pulse \"+\" para añadir un nuevo registro No hay datos. No tiene permisos para añadir un nuevo registro. No se han proporcionado descripción o instrucciones Datos a revisar - ¡Guardado y completado! Ya existe un registro activo Abierto Completar Cancelar - Datos El %s seleccionado no puede ser descargado. - Completado por %s: %s - Actualizado: %s - Terminado - No hemos podido verificar la calidad de los datos. Contacte con su administrador - ¿Está seguro? - ¿Quiere reabrir este set de datos? Fecha - Fecha de evento - %s no tiene atributos públicos. Por favor seleccione un programa Política de privacidad Pulse aquí para comprobar nuestra política de privacidad El valor de las coordenadas no es válido + No puede registrar un/una %s desde otras unidades organizativas en este programa por que está protegido.\nContacte con su administrador. El campo es obligatorio Por razones de seguridad, esta versión de la app no permite el uso de adb. Por favor use la versión de formación. Ir atrás - Cerrar app - Más detalles - Más detalles - Cerrar Copiar al portapapeles - Copiado al portapapeles - Información del error Encontramos un error al sincronizar con su servidor. Comparta el error con el administrador del sistema si es posible. - No pudimos iniciar la sesión con su usuario Hay eventos planificados que no serán reprogramados Gráficas Mostrar @@ -771,47 +485,26 @@ Retroalimentación Indicadores El permiso de cámara está denegado.\nDebes activarlo para usar esta función. - Valor Gráficos e indicadores Tomar foto - Elija de la galería - ¡Listo! Este programa está protegido Describe aquí la razón - Autentificando - El enlace del servidor proporcionada no es una instancia de dhis - Credenciales incorrectas - Servidor desconocido - El valor debe ser un entero La sesión ha terminado Su sesión actual ha terminado. Por razones de seguridad, tendrá que volver a proporcionar sus credenciales - Valores Semana %d %s a %s No volver a mostrar Configuración de funciones Ver como gráfico de radar - No se pudo modificar las coordenadas Seguido %s - %s no es una fecha válida No se ha podido actualizar este campo. Prueba otra vez. - Edición - Disponible Los datos no son editables por que está marcado como completado. Los datos no son editables por que el tiempo de edición ha expirado. No tiene permisos de edición para estos datos. Los datos no son editables por que la unidad organizativa está cerrada. No tiene permisos de edición para estos datos. - No puede editar datos en esta unidad organizativa. - Reiniciar búsqueda + No puede editar datos en esta unidad organizativa. La búsqueda online a devuelto este mensaje: - Advertencia de reglas del programa - Hay un problema de configuración que provoca un bucle en las reglas. Póngase en contacto con su administrador. - Recarga - Desconocido - Recarga todos los datos - Se ha alcanzado el nunero máximo de cuentas de usuario (%d). Para agregar una nueva cuenta, inicie sesion y elimine una de las existentes. Eliminar una cuenta - Administrar cuenta Revisión de configuración Idioma Toque aquí para cambiar el idioma de la aplicación @@ -830,9 +523,7 @@ No hay más resultados para esta búsqueda Demasiados resultados Intente cambiar su búsqueda para restringir los resultados. - Crear nuevo Nuevo %s - Es necesario que ingrese al menos %s atributos para iniciar la búsqueda De acuerdo Empieza una búsqueda para encontrar %s Puedes buscar o crear un nuevo %s @@ -841,7 +532,6 @@ Algunos campos tienen errores y no se han guardado.\n¿Deseas revisar el formulario\? ¿Desea marcar este formulario como completo? Tienes algunos mensajes de error.\n¿Deseas revisarlos\? - Si sales los cambios serán descartados. Reabrir Cuenta deshabilitada Tu cuenta ha sido deshabilitada por un administrador @@ -851,27 +541,10 @@ Fecha actual: %s Dispositivo: %s Versión del SO: %s - Abrir detalles de %s - %s detalles Sincronizando %s... - SMS enviado. Esperando respuesta del servidor. - Se ha confirmado que el SMS llegó al servidor. Actualizando estado - No podemos confirmar que el SMS llegó al servidor. Actualizando estado - SMS enviado. Actualizando estado. - SMS no enviado. - Abriendo aplicación de SMS. Por favor, no modifique el mensaje. Enviar SMS con ¿Ha enviado el sms? - Esperando confirmación manual - Sincronizando eventos - Sincronizando entidades monitoreadas - Sincronizando set de datos - Sincronizando recursos - Sincronización completada - La sincronización de SMS está disponible, pero se debe acceder a cada registro. Descargando imágenes... - Descripción del programa - Pin Incorrecto Inscrito en Propiedad de Sincronización necesaria @@ -891,22 +564,11 @@ Actualizar Ahora no Toca aquí para explorar - - %s error - %s errores - %s errores - - - %s advertencia - %s advertencias - %s advertencias - La versión %s de la app está disponible. ¿Quieres descargarla? Actualización de software Más tarde Descarga ahora Comprobar actualizaciones - Buscando actualizaciones No hay nuevas actualizaciones Versión de la app Algo salió mal con la configuración del programa, no podemos cargar este evento. Comuníquese con el administrador de su sistema. @@ -943,8 +605,6 @@ Etapa de programa evento Línea de tiempo - Borrar búsqueda - Opcional Base de datos descargada Importando base de datos Mostrar más @@ -970,4 +630,22 @@ Completar %s Desactivar %s Reabrir %s + + + %s error + %s errores + %s errores + + + + %s advertencia + %s advertencias + %s advertencias + + + + Hay %s programa con una configuración específica + Hay %s programas con configuraciones específicas + Hay %s programas con configuraciones específicas + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d7e5e5cab2f..29dfbe19889 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,62 +1,27 @@ Dhis2 - dhis - 2 Erreur - - Erreur - Erreurs - Erreurs - - URL incorrecte - références incorrectes - Erreur inattendue - Erreur interne - pas de valeur Il n\'y a plus de valeurs réservées. Veuillez contacter votre administrateur système. - Il n\'y a plus de valeurs réservées. Veuillez vous connecter pour en obtenir d\'autres. - erreurs Quelque chose a mal tourné - Synchronisez_maintenant - Synchronisation avec le serveur - Synchronise maintenant - Méta-données Événements - Entités suivies - - Fini - Synchronisation complète Problème de synchronisation Une erreur s´est produite pendant sync. Essayez à nouveau. La synchronisation est terminée, mais nous n\'avons pas reçu de confirmation du serveur pour tous les enregistrements. Ces enregistrements sont toujours marqués comme \"hors ligne\" dans l\'application. Nous vous recommandons de réessayer la synchronisation. - AVERTISSEMENT: La plupart de vos données ont été syncrhonisées mais certains champs ont eu des conflits. Synchronisez votre configuration et ressayez, Si l\'erreur persiste, vérifiez le statut de synchronisation: les programmes, TEIs et évènements avec conflits sont marqués avec @. - Une erreur s\'est produite et le serveur n\'a pas répondu. Contactez votre administrateur. - - %s résultats trouvé - Nouveau - Ajouter - planifier nouveau référence - URL du serveur - Nom d\'utilisateur - Mot de passe - Se connecter - Déverrouiller la session Sync votre configuration Vos données seront sync sous peu Synchronisation des données La configuration est prête vos données sont prêtes - La configuration a échoué - Synchronisation a échoue Récupération de compte + Quotidien Hebdomadaire @@ -73,129 +38,78 @@ Saisir du texte Saisir texte long Saisir un nombre - Saisir un nombre entier Saisir un entier négatif Saisir un entier ou zéro Saisir un entier positif - Options de filtre Choisissez une date - Changer de calendrier - Programme complété - Programme inactif Ouvrir Événement terminé Lecture seule - Terminer et Compléter Terminé Annulé - Activer Actif Synchronisé Envoyer Prêts à actualiser - Prêts de supprimer Erreur - Synchroniser - ENVOYER - SYNC SMS - Terminé - Créer Mettre à jour - Date Unités d´Organisation - Il n\'y a pas d\'Unité d\'organisation Ouvert pour la date sélectionnée. Merci de sélectionner une autre date ou une autre Unité d\'Organisation. - Options de catégorie + Il n\'y a pas d\'unité d\'organisation ouverte pour la date sélectionnée. Merci de sélectionner une autre date ou une autre unité d\'organisation. Latitude Longitude - Nouvel événement - Une fois - Définitif Période du rapport - Option de catégorie - Si vous saisissez une valeur, la ligne entière doit être remplie pour compléter l\'ensemble de données. - Des valeurs obligatoires sont manquantes. Vous devez spécifier les valeurs obligatoires pour compléter l\'ensemble de données. - Il manque des champs obligatoires. Veuillez les remplir pour compléter l\'ensemble de données. - La valeur a été enregistrée - Aucune période disponible pour sélectionner Date d\'échéance Combinaison de catégories - JJ/MM/AAAA Annuler Supprimer - Total Date de l\'événement le plus récent Aucune option disponible Programmes en cours - Programmes disponibles pour l´admission Inscrire - Dernière visite - Appliquer - Voir les détails - Afficher plus Modifier - Suivi - Suivi activé - Suivi désactivé Désactiver Ce programme ne permet pas plus d\'événements - Aucune donnée événements Accueil - Information de l´Appli Déconnecter - Bloquer la session - 1 Minute - 15 Minutes 30 Minutes 1 Heure 6h 1 jour (par défaut) 12h - Données de synchronisation automatique (chaque:) Manuel - Configuration automatique de la synchronisation (chaque:) 1 Semaine Paramètres de synchronisation actuels Retour aux valeurs par défaut + La base de données sera supprimée.\nVous devez donc entrer de nouveau Accepter Annuler Synchroniser les données mainténant Synchroniser la configuration mainténant Paramètres - Dernière synchronisation sur: %s - Dernière synchronisation sur: %s Remplir - Module SMS: Passerelle SMS Résultat de l\'origine du SMS - secondes - nombre - nombre Activer soumission SMS En attente du résultat de la réponse SMS - - Il y a %s programme avec des paramètres spécifiques - Il y a %s programmes avec des paramètres spécifiques - Il y a %s programmes avec des paramètres spécifiques - Paramètres limités Globalement par programme @@ -213,20 +127,15 @@ Ce registre contient toutes les erreurs causées par la synchronisation de vos données et métadonnées. Suprimmer les données Toutes les données de l\'application enregistrées sur votre appareil seront supprimées. Les données non synchronisées avec le serveur seront perdues. - Redémarrer l´application - Votre configuration et les données de l\'application enregistrées sur l\'appareil seront supprimées. Vous devrez vous connecter de nouveau et les données non synchronisées avec le serveur seront perdues. Paramètres SMS Cliquez et éditez les paramètres de la passerelle SMS Mise à jour du logiciel - Logiciel Exportation de la base des données Exporter votre base de données et le partager avec l\'administrateur Authentification à deux facteurs Statut - Date d\'enrôlement - Chercher @@ -247,29 +156,15 @@ Coordonnées pour - Liste des sections - %s questions de %s complétées - %s de %s complété - Completé et fermer - Jours - Mois - Années - Semaines Date d\'incident - Résultats locaux - Résultats en ligne Programmes antérieurs Vous n\'êtes pas autorisé à effectuer cette action - Ajouter comme... - Effacer Période - (%d) L´unité d´Organisation - Niveau %d Bienvenue à dhis2 Ici vous pouvez voir vos activités Vous pouvez appliquer un filtre... @@ -278,16 +173,11 @@ Vérifiez les autres actions dans le menu principal - Créer une relation Suivant Vous devez entrer au moins %d des attributs pour lancer une recherche - Vous devez renseigner au moins un des attributs afin d\'effectuer la recherche - il y a plus de %d des résultats. Veuillez entrer les autres critères de recherche - Entrez vos critères de recherche Importer des données QR - Enregistrer Ok Identification Attributs @@ -299,32 +189,20 @@ Aucune donnée Code QR invalide Cet attribut ne peut pas être ajouté - Cette inscription ne peu pas être ajoutée À propos Programmes disponibles pour l´admission - Tout sélectionner - Dégager sélection - Il n\'y a pas d\'information pour les critères choisis. Veuillez les changer Partager Partager le QR Max Courant TEI TEIs - Limite par l´unité organizationnelle - Limite par porgramme - Liste de Programmes Redémarrer l´application - Il n\'est pas possible d\'ajouter cette relation Attention. Verifiez ça! - Attention - Erreur Enregistrer Valeurs réservées - Voulez-vous ajouter plus de données de QR - Version de l´appli: %s Version de SDK: %s @@ -335,128 +213,42 @@ Développé et entretenu par le DHIS2 projet, partie de l\'Université d\'Oslo, département d\'informatique. Pour plus d\'informations ou des questions sur cette application, veuillez écrire à android\@dhis2.org - Le mot de passe est manquant. Veuillez compléter ce champ - Le nom d\'utilisateur est manquant. Veuillez compléter ce champ - Ce serveur a une version différente de 2.29. Mettre à jour la version ou utiliser un environnement d\'essai pour continuer - Ce serveur utilise une version différente que %s. Veuillez mettre à jour votre serveur ou utilisez l\'environnement test pour continuer. - Quelque chose a mal tourné. veuillez vérifier l\'url du serveur, le nom d\'utilisateur et le mot de passe sont corrects et réessayez - Le serveur ne peut pas terminer cette requête - Il y a un problème en essayant de se connecter au serveur - Erreur d\'accès - Les champs obligatoires sont manquant - Erreur inattendue lors d\'une recherche en ligne - Erreur lors de l\'analyse des résultats - La recherche en ligne a renvoyé une erreur - Quelque chose a mal tourné dans le serveur Sélectionnez un programme pour permettre l\'inscription Vous n\'avez pas l\'autorisation requise - Cette fonctionnalité n\'est pas encore prête - Erreur lors de l\'obtention de l\'étiquette de relation Tous %s N´est pas un numéro de téléphone valide - Ce n´est pas une adresse e-mail - Seuls les nombres négatifs sont autorisés seuls les nombres positifs ou zéro sont autorisés - Seuls les nombres positifs sont autorisés - Seules les valeurs de 0 à 1 sont autorisés - Seules les valeurs de 0 à 100 sont autorisés - sélectionner des dates pour filtrer Sélectionnez une option - Problème signalé - Générer un nouvel événement - Voulez-vous créer un nouvel événement événements terminés Tous les événements de ce programme sont terminés. Voulez-vous fermer le programme aussi? - Souhaitez-vous également compléter l\'enrôlement? Vérifier Accueil Exemple - Ajouter un widget Montrer l´aide - Synchronisation des données - Synchroniser votre configuration - Définir votre capacité de stockage locale - Vérifiez et gérez le nombre de valeurs réservées restantes - Vérifiez si la connexion au serveur a échoué - Libérer de l\'espace de stockage - ...ou supprimer tout pour recommencer. - Trouvez les entités suivies aussi bien dans votre appareil mobile que dans le serveur - Passer à un autre programme - Recherchez ou Ajouter de nouvelles entités. Vous devez effectuer une recherche avant de pouvoir en ajouter une nouvelle. - Cliquer ici pour réinitialiser les champs et pour recommencer la recherche C\'est le tableau de bord de l\'entité Voir les informations sur le profil des entités - ...Partager des informations via QR ou SMS... - ...Ou maqué l´entité pour le suivi - Cliquer pour planifier un événement ou faire référence à une autre unité d\'organisation Ici, vous verrez tous les événements. Cliquez pour voir un détail de l\'événement. Naviguer dans le menu pour ajouter des relations, voir des indicateurs ou écrire des notes - Cliquez pour voir toutes les inscriptions de votre entité ou en créer une nouvelle. - Cliquez ici pour supprimmer cet événement. Cette action ne peut pas être annulée - Completér l´événement - Tous les événements liés à ce programme seront listés ici - cliquez ici pour ajouter un nouvel événement Entrez toutes les données pour créer l\'événement Cliquez ici pour vérifier l\'état d\'achèvement de l\'événement - Cliquez ici pour complèter ou rouvrir un événement - Lire et stocker des informations provenant d\'autres appareils via QR - Synchroniser avec le serveur - Bloquer la session avec un code à 4 chiffres. N\'oubliez pas votre code! - Déconnectez-vous pour changer d\'utilisateur ou de serveur. Vous pourrez vous reconnecter même s\'il n\'y a pas de connexion internet - %s: Relation Aucun évènement n\'a été enregistré, cliquez \"+\" pour en ajouter un nouveau. - Aucun évènement enregistré. Valeurs de données Info Impossible de lire cet Id Suprimmer les données Général - Relations - Indicateurs - Analytiques Il n\'y a pas d\'indicateurs pour ce programme. - Notes - Impossible de terminer - Impossible de sauvegarder - Il y a des avertissements dans le formulaire - Certains champs comportent des erreurs. Merci de les vérifier pour compléter. Désélectionner l\'inscription Montrer le tableau de bord avec tous les programmes actifs - Vérifier les erreurs de synchronisation Registre d\'erreur de synchronisation Partager avec... - Votre synchronisation a échoué. Il pourrait y avoir des erreurs dans le serveur. Cliquez ici pour plus d\'informations. - PREC - SUIV. - Détails - Indicateurs - Notes - - Compléter plus tard - - Terminer - Champs avec erreurs - Certains champs ont des erreurs. Les valeurs n\'ont pas été enregistrées. Veuillez les vérifier. La valeur doit être unique, une coincidence a été trouvée. Elle n\'a pas été sauvegardée. Veuillez vérifier. - Une coïncidence dans un domaine unique a été trouvée. Veuillez vérifier le formulaire pour enregistrer l\'inscription. Veuillez patienter Ignorer - Re-agender Il n\'y a pas d\'information pour cet écran - En retard - Il y a une règle de programme dont le fonctionnement n\'est pas encore actif. Veuillez vérifiez la documentation - Pas d\'information pour ce champ - Références vides - Afin d\'utiliser la connexion par le scanner d\'empreinte digitale vous devez d\'abord vous connecter normalement - Sécurité biométrique - Voudriez-vous utiliser l\'empreinte digitale ou autre méthode biométrique pour se connecter - Tables - Tableau - https:// Les données non synchronisées seront perdues. Voulez-vous vraiment continuer? - Accepter Les données n\'ont pu être supprimées. Veuillez ressayer. Suppression des données locales terminée avec succès. Au moins une unité d\'organisation doit être sélectionnée @@ -469,6 +261,7 @@ Accord Continuer URL du serveur + Saisissez l\'URL du serveur DHIS 2 de votre organisation tel que fourni par votre administrateur système.\n\nVous pouvez également scanner le code QR avec le lien URL du serveur Oublié le code? Que souhaitez-vous faire pour cet évènement? FIN @@ -479,33 +272,16 @@ Téléchargement en cours Relation Envoyé par sms - Il n\'y a pas de conflits pour ce programme - La connexion réseau non disponible, mais apparemment le réseau mobile fonctionne - vous pouvez utiliser le SMS. - Connection réseau non disponible Fermer Maintenant vous pouvez vérifier l\'information de synchronisation en cliquant ici - Cette donnée est enregistrée seulement sur votre appareil et n\'a pas été synchronisée avec le serveur. - Vous êtes à jour! Toutes vos données ont été envoyées au serveur. - Vous ne pouvez pas établir de relation une donnée avec elle-même - Dévérouiller la session - Terminer - Ouvrir - Type de valeur non supporté + Vous ne pouvez pas établir de relation entre une donnée et elle-même Changer de vue calendrier - 15 minutes - Toutes les heures - Quotidien - Hebdomadaire - Semaine - Paramètre de synchronisation: %s - La donnée à laquelle vous essayer d\'acceéer n\'a pas d\'enrôlement. Vous ne pouvez pas accéder au tableau de bord. + La donnée à laquelle vous essayer d\'accéder n\'a pas d\'enrôlement. Vous ne pouvez pas accéder au tableau de bord. Erreur de formattage Supprimer 1%s Vous n\'avez pas l\'autorité pour supprimer cette donnée - Ceci n\'est pas un lien URL valide Adapter le formulaire... - Numéro du destinataire Acceptez-vous d\'envoyer %d messages? Envoi de %1$d/%2$d En attent de confirmation du montant - %d @@ -523,51 +299,29 @@ Aucune passerelle définie Utilisateur non connecté Le module SMS est désactivé - Une autre soumission SMS est en cours Le module SMS n\'a pas été initié, metadata requises non trouvées - Terminé Le compte SMS n\'a pas été accepté La soumission SMS a débuté Données lues et converties. Identifiant de soumission: %d - Erreur avec l\'envoi de la soumission SMS SMS TEI enregistrement et enrôlement Soumission SMS de l\'évènement Soumission SMS de l\'ensemble de données - Démarrage - En cours - Envoyé - Echoué - Démarrage de la synchronisation. Synchronisation... - Synchronisation terminée - Une erreur s\'est produite lors de la synchronisation. - Une erreur s\'est produite lors de la synchronisation. Cliquez ici pour vérifier les journaux d\'erreurs. - Synchronisation annulée Copié - Image sélectionnée - Afficher sur la carte - Afficher la liste - %s est obligatoire mais n\'a pas été spécifié. - Le polygône a été défini Polygone - Empreinte digitale - Veuillez passer votre doigt dans le lecteur d\'empreintes digitales. Securité Pour des raisons de sécurité, cette version de l\'application n\'est pas autorisée à être utilisée sur des appareils enracinés. Veuillez utiliser la version de formation. - Il y a des erreurs. Veuillez les corriger avant de continuer. Application des filtres Aucun flitre n\'est appliqué Période Unité d\'org. - Trier - Statut Synchronisation Assigné à moi Aujourd\'hui @@ -582,8 +336,6 @@ De - à Autre A tout moment - La synchronisation a échoué. La synchronisation a échoué. Vérifiez les valeurs des données pour essayer de résoudre le problème et réessayez. S\'il échoue, contactez votre administrateur système - Ces données ont été envoyées au serveur mais il n\'est pas garanti qu\'elles aient été stockées Ouvrir Planning En retard @@ -599,7 +351,6 @@ La passerelle SMS ne peut pas être vide Il n\'y a aucune inscription à télécharger Cette tâche de conversion n\'est pas prise en charge - Ne plus afficher l\'écran de verrouillage Définir la broche Conflit Date de modification @@ -624,11 +375,7 @@ Choisissez l\'unité d\'organisation Entrez url Entrez une valeur - Choisissez l\'image - Scanner le code - Choisissez une option Valeurs réservées téléchargées par attribut TEI - Valeurs réservées téléchargées par attribut TEI %s valeurs réservées restantes Gérer les valeurs réservées Événements à télécharger @@ -648,39 +395,19 @@ Non synchronisé Erreur de synchronisation SMS synchronisé - Details - Commentaires Enregistré! - Voulez-vous vérifier la qualité des données? - Tout a l\'air bien! - Voulez-vous également compléter l\'ensemble de données? - Marquer comme terminé - Nom - Formule - Terminer quand même Disparu%s%sde%s%s Statut de l\'événement - Date d\'enrôlement Il n\'y a pas de données. Cliquez \"+\" pour ajouter un nouvel enregistrement Il n\'y a pas de données. Vous n\'êtes pas autorisé à ajouter de nouveaux enregistrements. Pas de description ou instruction fournie Données à examiner - Enregistré et terminé! Il y a déjà une enrôlement active Ouvrir Terminer Annulé - Données La sélection %s n\'a pas pu être téléchargé. - Terminé par %s: %s - Mis à jour: %s - Terminé - Nous n\'avons pas pu vérifier la qualité des données. Contactez votre administrateur - Etes-vous sûre ? - Voulez-vous rouvrir l\'ensemble de données? Date - Date d´événement - %s n\'a pas d\'attributs publics. Veuillez sélectionner un programme Politique de confidentialité Cliquez ici pour consulter notre politique de confidentialité Les valeurs des coordonnées ne sont pas valides @@ -688,15 +415,8 @@ Pour des raisons de sécurité, cette version de l\'application ne peut pas être utilisée avec adb. Veuillez utiliser la version de formation. Précédent - Fermer l\'application - Plus de détails - Plus de détails - Fermer Copier dans le presse-papier - Copié dans le presse-papier - Informations d\'erreur Nous avons trouvé une erreur lors de la synchronisation avec votre serveur. Partagez l\'erreur avec votre administrateur système si c\'est possible - Nous n\'avons pas pu connecter la session avec votre nom d\'utilisateur Il y a des événements planifiés qui ne seront pas reprogrammés Graphiques Lister @@ -745,63 +465,39 @@ Réaction Indicateurs Valeur + L\'autorisation de la caméra est refusée. \nVous devez l\'activer pour utiliser cette fonctionnalité. Graphiques et indicateurs Prendre une photo - Choisir depuis la galerie - Terminé! Ce programme est protégé Décrivez la raison ici - En cours d\'authentification - L\'URL du serveur fournie n\'est pas une instance dhis - références incorrectes - Serveur inconnu - La valeur doit être un entier La session a expiré Votre session actuelle a expiré. Pour des raisons de sécurité, vous devrez fournir à nouveau vos informations d\'identification - Valeurs Semaine %d %s à %s Ne pas montrer de nouveau - 1%s ne peut pas être analysé comme une date Ce champ n\'a pas pu être mis à jour. Veuillez réessayer - Disponible Ces données ne sont pas modifiables car elles sont marquées comme terminées Cette donnée n\'est pas modifiable car son heure d\'édition est expirée Vous n\'êtes pas autorisé à modifier ces données Vous n\'êtes pas autorisé à modifier ces données - Vous ne pouvez pas modifier les données de cette unité organisationnelle - Réinitialiser la recherche + Vous ne pouvez pas modifier les données de cette unité organisationnelle La recherche en ligne a renvoyé le message suivant : - Avertissement relatif aux règles du programme - Il y a un problème de configuration provoquant une boucle dans les règles. Contactez votre administrateur. - Actualiser - Inconnu - Actualiser toutes les données Supprimer compte - Gérer les comptes Langue Non enregistré! Il n\'y a pas de résultats pour cette recherche Résultats trop nombreux - Créer nouveau Ok Lancez une recherche pour trouver 1%s Vous pouvez rechercher ou créer un nouveau 1%s Vous avez des messages d\'avertissement. Voulez-vous marquer ce formulaire comme complet? Si vous quittez maintenant, les modifications seront ignorées. + Vous avez des messages d\'erreur.\nVoulez-vous les consulter ? Réouvert Compte désactivé Votre compte a été désactivé par votre administrateur. Pour rechercher en dehors de ce programme, ouvrez le formulaire de recherche et remplissez l\'un des champs 1%s suivants : - Ouvrir détails %s - %s détails - SMS confirmé pour atteindre le serveur. Mise à jour du statut - SMS envoyé. Mise à jour du statut. - SMS non envoyé. Envoyer des SMS avec - Synchroniser les événements - Synchronisation des entités suivies - Synchronisation des ensembles de données Téléchargement d\'images ... Synchronisation nécessaire Erreur de synchronisation @@ -812,16 +508,6 @@ Envoyer Actualiser Pas maintenant - - %s erreur - %s erreurs - %s erreurs - - - %s avertissement - %s avertissements - %s avertissements - Mise à jour du logiciel Vérifier les mises à jour Pas de nouvelles mises à jour @@ -845,14 +531,28 @@ Étape du programme événement Chronologie - Optional Afficher plus Afficher moins Synchronisation - Afficher les champs - Masquer les champs Synchronisation réussie Afficher la description Masquer la description Transférer - + + %s erreur + %s erreurs + %s erreurs + + + + %s avertissement + %s avertissements + %s avertissements + + + + Il y a %s programme avec des paramètres spécifiques + Il y a %s programmes avec des paramètres spécifiques + Il y a %s programmes avec des paramètres spécifiques + + diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index e103b8d156d..72145803d40 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -1,23 +1,13 @@ त्रुटि - गलत सर्वर - गलत लॉगिन जानकारी कुछ गलत हुआ - अभी सिंक करें - सिंक हो रहा है - सिंक हो रहा है इवेंट्स - खत्म - सिंक पूरा हुआ सिंक की गलती सिंक के दौरान एक त्रुटि हुई है। कृपया फिर से प्रयास करें। - जोड़ें - यूज़रनेम - पासवर्ड रोज़ाना साप्ताहिक @@ -31,71 +21,49 @@ लंबा अक्षर दर्ज करें नंबर दर्ज करें दिन चुनें + + + खोलें इवेंट कंप्लीटेड पूरा हुआ त्रुटि + + + - बनाएँ अपडेट करें - तारीख अक्षांश देशांतर - नया इवेंट - यदि आप कोई वैल्यू दर्ज करते हैं, तो डेटा सेट पूरा करने के लिए पूरी पंक्ति के सभी वैल्यू आवश्यक हैं। - कुछ अनिवार्य फ़ील्ड खाली हैं। डेटा सेट पूरा करने के लिए उन्हें भरें। + रद्द करें हटाएं + संपादन करें - कोई डेटा नहीं इवेंट्स स्वीकार करें रद्द करें सेटिंग्स - सॉफ्टवेयर + + सर्च करें / खोजें फ़ाइल जोड़ें - - दिन - महीने - वर्ष - सप्ताह - साफ़ करें + आगे - सहेजें ठीक है इवेंट्स साझा करें - अधिकतम - त्रुटि सहेजें यह मान्य फ़ोन नंबर नहीं है - यह मान्य ईमेल नहीं है - केवल नेगेटिव संख्या ही अनुमति है केवल शून्य या पॉजिटिव संख्या ही अनुमति है - केवल पॉजिटिव संख्या ही अनुमति है - केवल 0 से 1 के मान ही अनुमति हैं - केवल 0 से 100 के वैल्यूज़ अनुमति हैं - नया इवेंट जनरेट करें - क्या आप एक और इवेंट बनाना चाहते हैं? - इंडिकेटर्स - इंडिकेटर्स - खत्म मान अद्वितीय होना चाहिए। समान मान मिला है। इसे सहेजा नहीं गया। कृपया जाँच करें छोड़ें - अतिदेय - स्वीकार करें संबंध बंद करें - पूरा हुआ - खोलें - रोज़ाना - साप्ताहिक - यह मान्य URL नहीं है - पूरा हुआ सिंक हो रहा है… + आज कल (बिता हुआ) इस सप्ताह @@ -119,39 +87,23 @@ मान दर्ज करें सिंक की गलती सेव - क्या आप डेटा क्वालिटी चेक करना चाहते हैं\? - क्या आप डेटा सेट पूरा करना चाहते हैं\? - नाम - फिर भी पूरा करें रिव्यू के लिए डेटा - सेव और कंप्लीटेड खोलें पूरा हुआ - डेटा - पूरा हुआ - क्या आप सुनिश्चित हैं? तारीख यह फ़ील्ड आवश्यक है पीछे जाएं - बंद करें इस वर्ष पिछली तिमाही पिछला वर्ष इंडिकेटर्स - वैल्यू फ़ोटो लें यह प्रोग्राम सुरक्षित है - गलत लॉगिन जानकारी - वैल्यू - %s डेट के रूप में पढ़ा नहीं जा सकता यह फ़ील्ड अपडेट नहीं हो सका। कृपया फिर से प्रयास करें - उपलब्ध इस डेटा को संपादित करने की अनुमति नहीं है इस डेटा को संपादित करने की अनुमति नहीं है - अज्ञात भाषा सेव नहीं - नया बनाएँ आपके पास कुछ चेतावनी संदेश हैं आपके पास कुछ चेतावनी संदेश हैं\nक्या आप इस फ़ॉर्म को कम्प्लीट मार्क करना चाहते हैं\? सिंक हो रहा है @@ -160,10 +112,6 @@ सिंक की गलती रेफर करें इवेंट - खोज साफ़ करें - वैकल्पिक - उपयोग के लिए कोई खोज फ़ील्ड कॉन्फ़िगर नहीं है\nकृपया अपने एडमिन से संपर्क करें और दिखाएँ कम दिखाएँ - यह सेक्शन सही तरह से कॉन्फ़िगर नहीं है\nअपने एडमिन से संपर्क करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index e7bde4535ac..bfd1c88a934 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -1,59 +1,26 @@ DHIS2 - dhis - 2 Error - - Kesalahan - - URL Server salah - Kredensial salah - Kesalahan tak terduga - Kesalahan internal server - tidak ada nilai Tidak ada nilai lainnya. Hubungi admin sistem Anda. - Tidak ada nilai lainnya. Sambungkan ke Internet untuk mengisi. - Kesalahan Terjadi kesalahan - Sinkronkan sekarang - Mensinkronkan dengan server - Sedang mensinkronkan - Metadata Event - Entitas terlacak - - Selesai - Sync lengkap Sync error Terjadi kesalahan saat sinkronisasi. Silakan coba kembali. - PERINGATAN: Sebagian besar data telah tersinkronisasi tapi terdapat beberapa konflik. Sinkronisasi konfigurasi Anda dan coba kembali. Jika status sinkronisasi: program, TEI, dan event yang berkonflik ditandai sebagai @. - Terjadi kesalahan dan server tidak merespon. Hubungi administrator anda. - - %s hasil ditemukan - Tambahkan baru - Tambah - Penjadwalan baru Rujukan - URL server - User name - Password - Masuk - Buka sesi Mensinkronkan konfigurasi Anda Data Anda segera disinkronkan Mensinkronkan data Anda Konfigurasi Anda siap digunakan Data Anda tersedia - Konfigurasi sinkron gagal - Sinkronisasi data gagal Penyelamatan akun + Harian Mingguan @@ -70,125 +37,76 @@ Masukkan teks Masukkan teks panjang Masukkan angka - Masukkan angka positif Masukkan angka bulat negatif Masukkan bilangan bulat positif atau nol Masukkan bilangan bulat - Pilihan penyaringan Pilih tanggal - Ganti kalender - Program selesai - Program tidak aktif Buka Even selesai Baca saja - Selesai dan lengkap Selesai Dibatalkan - Aktivasi Aktif Sinkronisasi Untuk dikirim Untuk diperbarui - Untuk dihapus Error - SINKRONISASI - KIRIM - SINKRONISASI SMS - Melengkapi - Buat perbarui - Tanggal Unit Organisasi Tidak ada unit organisasi yang terbuka untuk tanggal terpilih. Mohon pilih tanggal atau unit organisasi lain - Kategori opsi Lintang Bujur - Event baru - Satu kali - Permanen Periode pelaporan - Opsi kategori - Jika Anda mengisi, semua baris harus terisi untuk menyelesaikan data set - Beberapa data yang diperlukan tidak ada. Anda perlu menetapkan data wajib terisi untuk menyelesaikan Data.Set - Terdapat hal penting yang hilang. Harap mengisinya untuk melengkapi data.set - Data tersimpan! - Tidak ada periode yang dapat dipilih. Tanggal jatuh tempo Cat Combo - DD/MM/YYYY batal hapus - Total hal yang paling terbaru Aktifkan program - Program untuk didaftarkan Daftar - Kunjungan terakhir: - Terapkan - Lihat rincian - LIHAT LAINNYA Sunting - Tindak lanjut - Tindak lanjut diterapkan - Tindak lanjut ditutup Deaktivasi Program ini tidak diperbolehkan menambah even - Tidak ada data Even Beranda - Informasi App Keluar - Tutup sesi - 1 Menit - 15 Menit 30 Menit 1 Jam 6 Jam 1 Hari (Default) 12 Jam - Sinkronisasi data otomatis setiap: Manual - Sinkronisasi konfigurasi otomasi setiap: 1 Minggu Parameter sinkronisasi saat ini: Kembalikan ke default + Database akan dihapus. Anda akan diminta masuk lagi Terima batal Sinkronkan Data Sekarang Sinkronkan Konfigurasi Sekarang Setting - Sinkronisasi terakhir pada: %s - Sinkronisasi terakhir pada: %s Isi ulang - Modul SMS: SMS Gateway Pengirim hasil SMS - Kedua - Nomor - Nomor Fungsikan pengiriman SMS Tunggu balasan hasil SMS - - Terdapat %s program dengan pengaturan khusus - pengaturan terbatas Secara global berdasarkan program @@ -206,15 +124,11 @@ Log ini berisi semua kesalahan yang disebabkan karena sinkronisasi data dan metadata Anda Menghapus data local Semua data aplikasi yang tersimpan pada perangkat Anda akan dihapus. Data yang belum tersinkron pada server akan hilang. - Setel ulang app data & konfigurasi - Konfigurasi dan aplikasi penyimpan data pada perangkat Anda akan dihapus. Anda akan diminta untuk masuk kembali dan data yang belum disinkronkan pada server akan hilang. Pengaturan SMS Periksa dan edit parameter yang terkait dengan SMS gateway Status - Tanggal pendaftaran - Pencarian @@ -223,11 +137,6 @@ Tulis catatan baru Kosongkan Tambah catatan - Kembali - Diaktifkan - Bidang wajib tidak ada - Beberapa bidang wajib tidak ada. Jika Anda ingin kembali sekarang, semua informasi yang disimpan akan dihapus. - Beberapa bidang wajib tidak ada. Jika Anda ingin kembali, silakan isi. Data Anda tidak disimpan Jika Anda kembali sekarang, semua informasi yang dimasukkan akan dihapus. Hapus dan kembali @@ -235,29 +144,15 @@ Koordinat untuk - Daftar bagian - %s pertanyaan %s selesai - %s dari %s selesai - Selesai dan tutup - Hari - Bulan - Tahun - Minggu Tanggal insiden - Hasil lokal - Hasil online Program sebelumnya Anda tidak memiliki izin yang dibutuhkan untuk melakukan tindakan ini - Tambahkan sebagai… - Hapus Periode - (%d) Unit Organisasi - Tingkat %d Selamat datang di dhis2 Di sini Anda dapat melihat aktivitas Anda Anda dapat menerapkan filter… @@ -266,17 +161,11 @@ Periksa tindakan lain di menu utama - Buat hubungan Se;lanjutnya Anda harus memasukkan setidaknya %d atribut untuk melakukan pencarian - Anda harus memasukkan setidaknya satu atribut untuk melakukan pencarian - Ada lebih dari %d hasil. Silakan masukkan kriteria pencarian lebih lanjut - Kriteria penelusuran Anda tidak memberikan hasil apa pun.\nRevisi kriteria Anda atau daftarkan %s baru - Masukkan kriteria pencarian Anda Impor data QR - Simpan OK Identifikasi Atribut @@ -288,33 +177,18 @@ tidak ada data Kode QR tidak valid Atribut ini tidak dapat ditambahkan - Pendaftaran ini tidak dapat ditambahkan Tentang Program untuk didaftarkan - Pilih Semua - hapus pilihan - Tidak ada informasi untuk filter yang dipilih. Silakan coba mengubahnya. Bagikan Bagikan QR - Pencarian menurut jenis tidak dimungkinkan untuk pencarian online.\nSilakan pilih program dari daftar. - Maks - Saat ini TEI TEI - Batasi menurut unit organisasi - Batasi menurut program - Daftar program Setel ulang app data & konfigurasi - Tidak mungkin untuk menambahkan hubungan ini Periksa ini! - Peringatan - Error Simpan Nilai Cadangan - Apakah Anda ingin menambahkan lebih banyak data dari QR? - Versi Aplikasi: %s Versi SDK: %s @@ -325,137 +199,44 @@ Dikembangkan dan dikelola oleh proyek DHIS2 , bagian dari Universitas Oslo, departemen informatika . Untuk informasi lebih lanjut atau pertanyaan tentang aplikasi ini, silakan menulis ke android@dhis2.org - Kata sandi tidak ada. Silakan isi kolom ini. - Nama pengguna tidak ada. Silakan isi kolom ini. - Server ini menjalankan versi dhis yang berbeda dari 2.29. Perbarui versi server Anda atau gunakan pengujian lingkungan untuk melanjutkan. - Server ini menjalankan versi dhis yang berbeda dari %s. Perbarui versi server Anda atau gunakan pengujian lingkungan untuk melanjutkan. - Ada yang salah. Harap periksa bahwa url server, nama pengguna, dan kata sandi sudah benar dan coba lagi. - Server tidak dapat menyelesaikan permintaan ini. - Terjadi masalah saat mencoba masuk ke server. - Kesalahan login - Bidang wajib tidak ada - Kesalahan tak terduga pada saat pencarian online - Kesalahan saat penguraian hasil - Pencarian online menghasilkan kesalahan - Ada yang salah di server Pilih program untuk mengaktifkan pendaftaran Anda tidak memiliki izin yang diperlukan - Fungsi ini belum tersedia - Kesalahan saat mengambil label hubungan Semua %s Ini bukan nomor telepon yang valid - Ini bukan email yang valid - Hanya angka negatif yang diperbolehkan Hanya angka positif atau nol yang diperbolehkan - Hanya angka positif yang diperbolehkan - Hanya nilai dari 0 hingga 1 yang diperbolehkan - Hanya nilai dari 0 hingga 100 yang diizinkan - Pilih tanggal untuk difilter Pilih opsi - Masalah dilaporkan - Buat even baru - Apakah Anda ingin membuat even lain? Even Selesai Semua even dalam program ini selesai. Apakah Anda ingin menutup program? - Apakah Anda ingin menyelesaikan pendaftaran juga? Memeriksa Beranda CONTOH - Tambahkan widget Tampilkan bantuan - Proses sinkronisasi data... - …konfigurasi Anda… - …atur berapa banyak catatan yang akan diunduh dari server… - Periksa dan kelola berapa banyak nilai cadangan yang tersisa - Periksa apakah koneksi ke server gagal - Kosongkan sebagian ruang disk - …atau hapus semuanya untuk memulai dari awal. - Temukan entitas Anda baik di perangkat lokal maupun server online. - Anda dapat beralih ke program lain - Cari atau Tambahkan entitas baru. Anda perlu mencari sebelum Anda dapat menambahkan yang baru! - Klik di sini untuk membersihkan bidang dan memulai kembali pencarian. Ini adalah dasbor entitas Lihat informasi profil entitas… - …Berbagi informasi melalui QR atau SMS… - …Atau tandai entitas untuk ditindaklanjuti - Klik untuk membuat atau menjadwalkan even atau alihkan ke Unit Org lain. Di sini Anda akan melihat semua even. Klik untuk melihat detail even. Navigasikan menu untuk menambahkan hubungan, melihat indikator, atau menulis catatan. - Klik untuk melihat semua pendaftaran entitas Anda atau buat yang baru. - Klik untuk menghapus even ini. Tindakan ini tidak bisa dibatalkan. - Selesaikan even. - Semua even yang terkait dengan program ini akan dicantumkan di sini. - Klik untuk menambahkan even baru Masukkan semua data untuk membuat even Klik untuk memeriksa status penyelesaian even. - Klik untuk menyelesaikan atau membuka kembali even - Baca dan simpan informasi dari perangkat lain melalui QR - Mensinkronkan data Anda pada server - Blokir sesi dengan kode 4 digit. Jangan lupa kode Anda! - Logout untuk mengubah pengguna atau server. Anda akan dapat masuk kembali meskipun tidak ada koneksi internet. - %s: hubungan Tidak ada acara yang terdaftar, klik \"+\" untuk menambahkan yang baru. - Tidak ada even yang terdaftar. Nilai data Info Tidak dapat membaca ID ini Menghapus data lokal Gambaran - Hubungan - Indikator - Analitik Tidak ada indikator untuk program ini. - Catatan - Beberapa parameter terjadi kesalahan. Mohon periksa untuk dapat menyelesaikannya Semua pendaftaran Tampilkan dasbor dengan semua program aktif - Buka log kesalahan sinkronisasi Log kesalahan sinkronisasi Berbagi dengan... - Konfigurasi sinkronisasi Anda gagal. Terdapat kesalahan pada server. Klik bagian ini untuk informasi lebih lanjut - SEBELUMNYA - SELANJUTNYA - Rincian - Indikator - Catatan - - Selesaikan nanti - - Selesai - Parameter dengan kesalahan - Beberapa parameter terdapat kesalahan. Parameter ini belum disimpan. Mohon periksa kembali Nilai harus unik, kebetulan telah ditemukan. Itu belum disimpan. Tolong diperiksa. - Sebuah kebetulan di bidang yang unik telah ditemukan. Silakan periksa formulir untuk menyimpan Pendaftaran. Mohon tunggu Lewati - Jadwal ulang Tidak ada informasi untuk layar ini - Lewat waktu - Terdapat satu aturan program yang tidak didukung. Silakan periksa dokumentasi - Tidak ada info untuk bidang ini - Kredensial Kosong - Untuk dapat login menggunakan pemindai sidik jari, Anda terlebih dahulu harus menggunakan cara default - Keamanan Biometrik - Tabel - Tabel - https:// Data yang belum tersinkron akan hilang. Apakah Anda akan melanjutkan? - Terima Data tidak dapat dihapus. Silakan coba lagi. Hapus data lokal berhasil diselesaikan. - Setidaknya satu unit organisasi harus dipilih - Kirim data - Bantu DHIS2 menyempurnakan app ini dengan membagi log kesalahan yang berkaitan dengan id user Anda - Periksa bidang - Anda kehilangan koneksi internet saat sedang mensinkronkan konfigurasi. Silahkan coba lagi nanti - Setuju - Terus - URL server - Lupa kode Anda? - Apa yang ingin Anda lakukan untuk even ini? - AKHIR %s tidak ditemukan Catatan yang Anda coba akses tidak tersedia. Apakah Anda ingin mencarinya? Sinkronkan dengan SMS @@ -463,32 +244,15 @@ Mengunggah Hubungan Dikirim melalui sms - Tidak ada konflik untuk program ini - Koneksi jaringan tidak tersedia, tetapi sepertinya ada layanan telepon - Anda dapat menggunakan SMS. - Koneksi jaringan tidak tersedia. Tutup Anda bisa melihat hasil sinkronisasi dengan klik bagian ini - Data ini disimpan dalam perangkat Anda saja, tidak disinkronkan pada server - Anda up to date! Semua data Anda dikirim ke server. Anda tidak dapat menghubungkan rekaman dengan dirinya sendiri - Buka blokir sesi - Lengkap - Buka - Jenis nilai yang tidak didukung Ubah tampilan kalender - 15 menit - Setiap jam - Harian - Mingguan - Minggu - Pengaturan sinkronisasi: %s Catatan yang Anda coba akses tidak memiliki pendaftaran. Anda tidak dapat mengakses dasbor. Memformat kesalahan Anda tidak memiliki wewenang untuk menghapus catatan ini - Ini bukan url yang valid Menyesuaikan formulir… - Nomor penerima Apakah Anda menerima untuk mengirim pesan %d? Mengirim %1$d/%2$d Konfirmasi jumlah tunggu - %d @@ -506,51 +270,29 @@ Tidak ada gateway yang disetel Pengguna tidak masuk Modul SMS dinonaktifkan - Pengiriman SMS lainnya sedang berlangsung Modul SMS tidak dimulai, tidak ditemukan metadata yang diperlukan - Selesai Jumlah SMS tidak diterima Mulai pengiriman sms Data dibaca dan dikonversi. Id pengiriman: %d - Kesalahan pengiriman sms Pendaftaran dan registrasi SMS TEI Pengiriman even SMS Pengiriman data set SMS - Mulai - Dalam proses - terkirim - Gagal - Mulai sinkronisasi Sedang sinkronisasi... - Sinkronisasi selesai. - Kesalahan terjadi saat sinkronisasi - Terjadi kesalahan selama sinkronisasi. Klik di sini untuk memeriksa log kesalahan - Batalkan sinkronisasi. Tersalin - Gambar dipilih - Tampilkan di peta - Tampilkan daftar - %s adalah wajib tetapi belum disediakan. - Poligon ditangkap Poligon - Sidik jari - Harap, letakkan jari Anda di pembaca sidik jari Anda. Keamanan Untuk alasan keamanan, versi aplikasi ini tidak diizinkan untuk digunakan di perangkat yang di-rooting. Silakan gunakan versi pelatihan. - Terdapat beberapa kesalahan. Mohon perbaiki sebelum melanjutkan Filter menerapkan Tidak ada filter yang diterapkan Periode Organisasi Satuan - Penyortiran - Status Sinkronisasi Ditugaskan kepada saya Hari ini @@ -565,135 +307,18 @@ Dari untuk Lainnya Kapan saja - Sinkronisasi gagal. Periksa kembali nilai data untuk memperbaiki permasalahan kemudian coba lagi. Jika tetap gagal hubungi administrator sistem - Data ini telah terkirim ke server tetapi tidak dijamin telah disimpan Buka Jadwal Lewat waktu Dikunjungi Selesai Dilewati - Even:%smaks:%s%ssaatini:%s TEI:%smaks:%s%ssatt ini:%s -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  - Pengaturan terbatas %s\nAcara: %s/%s TEI: %s/%s - Sembunyikan data pendaftaran - Tampilkan data pendaftaran Modul SMS diaktifkan Modul SMS dinonaktifkan Mengunduh data untuk modul SMS. Mohon tunggu Gerbang SMS tidak boleh kosong Tidak ada pendaftaran untuk diunggah convertTask ini tidak didukung - Jangan tampilkan layar kunci lagi Setel pin Konflik Ganti tanggal @@ -718,11 +343,7 @@ Pilih unit organisasi Masukkan URL Masukkan nilai - Pilih gambar - Pindai kode - Pilih opsi Nilai yang dicadangkan diunduh per atribut TEI - Nilai yang dicadangkan diunduh per atribut TEI %s nilai yang dicadangkan tersisa Kelola nilai yang dicadangkan Even untuk diunduh @@ -742,56 +363,27 @@ Tidak tersinkronisasi Sync error SMS tersinkronisasi - Rincian - Komentar Tersimpan! - Apakah Anda ingin memeriksa kualitas data? - Semuanya terlihat bagus! - Apakah Anda juga ingin melengkapi data set? - Tandai sebagai selesai - Nama - Formula - Lengkap Tidak ada %s %s dari %s %s Status even - Tanggal pendaftaran Tidak ada data. Klik \"+\" untuk menambahkan catatan baru Tidak ada data. Anda tidak memiliki izin untuk menambahkan catatan baru. Tidak ada deskripsi atau instruksi yang tersedia Data untuk ditinjau - Tersimpan dan selesai! Sudah ada pendaftaran aktif Buka Lengkap Dibatalkan - Data %s yang dipilih tidak dapat diunduh. - Diselesaikan oleh %s: %s - Diperbarui: %s - Selesai - Kami tidak dapat memeriksa kualitas data. Hubungi administrator Anda - Anda yakin? - Apakah Anda ingin membuka kembali data set? Tanggal - Tanggal even - %s tidak memiliki atribut publik. Silakan pilih program Kebijakan pribadi Klik di sini untuk memeriksa kebijakan privasi kami - Nilai koordinat tidak valid - Anda tidak dapat mendaftarkan TEI dari unit organisasi lain dalam program ini karena program ini dilindungi.\nHubungi administrator Anda. Bidang ini wajib Untuk alasan keamanan, versi aplikasi ini tidak diizinkan untuk digunakan dengan adb. Silakan gunakan versi pelatihan. Kembali - Tutup aplikasi - Keterangan lebih lanjut - Keterangan lebih lanjut - Tutup Menyalin ke clipboard - Disalin ke papan klip - Informasi kesalahan Kami menemukan kesalahan saat menyinkronkan dengan server Anda. Bagikan kesalahan dengan administrator sistem Anda jika memungkinkan. - Kami tidak dapat memulai sesi dengan pengguna Anda Ada even yang direncanakan yang tidak akan dijadwalkan ulang Grafik Daftar @@ -836,23 +428,14 @@ Umpan balik Indikator Izin kamera ditolak.\nAnda harus mengaktifkannya untuk menggunakan fitur ini. - Nilai Grafik dan indikator Ambil gambar - Pilih dari galeri - Selesai! Program ini dilindungi Jelaskan alasannya di sini - Otentikasi - URL server yang disediakan bukan instance dhis - Kredensial salah - Nilai harus dalam bilangan bulat Sesi telah berakhir Sesi Anda saat ini telah kedaluwarsa. Untuk alasan keamanan, Anda harus memberikan kredensial Anda lagi - Nilai Minggu %d %s hingga %s Jangan perlihatkan lagi - Tersedia Bahasa Hasil terlalu banyak Tersinkronisasi @@ -871,4 +454,7 @@ Tampilkan kurang Sinkronkan Tampilkan keterangan - + + Terdapat %s program dengan pengaturan khusus + + diff --git a/app/src/main/res/values-km/strings.xml b/app/src/main/res/values-km/strings.xml index 106273f220e..100ab4b963f 100644 --- a/app/src/main/res/values-km/strings.xml +++ b/app/src/main/res/values-km/strings.xml @@ -1,50 +1,25 @@ ឌីអេចអាយអេសធូ (DHIS 2) - ឌីអេចអាយអេសធូ (DHIS 2) - លេខពីរ កំហុសបច្ចេកទេស - អាសយដ្ឋានម៉ាស៊ីនមេមិនត្រឹមត្រូវ - អត្ដសញ្ញាណមិនត្រឹមត្រូវ - កំហុសបច្ចេកទេសមិនបានរំពឹងទុក - ម៉ាស៊ីនមេខាងក្នុងមានបញ្ហាបច្ចេកទេស - មិនមានតំលៃ មានអ្វីមួយខុសប្រក្រតី - បញ្ជូនទិន្នន័យឥឡូវនេះ - កំពុងបញ្ជូនទិន្នន័យទៅកាន់ម៉ាស៊ីនមេ - បញ្ចូនទិន្នន័យឥឡូវនេះ - ទិន្នន័យ ព្រឹត្តិការណ៏ - ផ្នែកតាមដាន - - បានបញ្ចប់ - ការបញ្ចូនទិន្នន័យបានបញ្ចប់ ការបញ្ចូនទិន្នន័យមានបញ្ហាបច្ចេកទេស មានបញ្ហាបច្ចេកទេសក្នុងកំឡុងពេលបញ្ជូនទិន្នន័យ, សូមព្យាយាម្ដងទៀត។ - %s លទ្ធផលដែលបានរកឃើញ - បន្ថែម​ថ្មី​ - បន្ថែម - កំណត់កាលវិភាគឥឡូវនេះ ការបញ្ជូន - អាសយដ្ឋានរបស់ម៉ាស៊ីនមេ - ឈ្មោះ​គណនី - ពាក្យ​សម្ងាត់ - ចូលដំណើរការ - ដោះសោ កំពុងបញ្ជូនការកំណត់​រចនាសម្ព័ន្ធរបស់អ្នក ទិន្នន័យរបស់អ្នកនឹងបញ្ជូនក្នុងពេលដ៏ខ្លីខាងមុខនេះ កំពុងបញ្ជូនទិន្នន័យរបស់អ្នក ការកំណត់​រចនាសម្ព័ន្ធរបស់អ្នករួចរាល់ ទិន្នន័យរបស់អ្នករួចរាល់ - ការបញ្ជូនការកំណត់​រចនាសម្ព័ន្ធរបស់អ្នកបរាជ័យ - ការបញ្ជូនទិន្នន័យបរាជ័យ ស្តារគណនីឡើងវិញ + រាល់​ថ្ងៃ រាល់​សប្ដាហ៍ @@ -58,15 +33,12 @@ បញ្ជូលអក្សរ បញ្ចូលអក្សរច្រើន បញ្ជូលលេខ - បញ្ជូលទិន្នន័យជាលេខ បញ្ជូលទិន្នន័យជាលេខ (អវិជ្ជមាន) បញ្ជូលទិន្នន័យជាលេខ (វិជ្ជមាន រឺ លេខ ០) បញ្ជូលទិន្នន័យជាលេខ (វិជ្ជមាន) - ជំរើសសម្រាប់ច្រោះមើលទិន្នន័យ ជ្រើសរើសថ្ងៃខែឆ្នាំ + - កម្មវិធីបានបញ្ចប់ - កម្មវិធីមិនដំណើរការ បើក បានត្រឹមតែអាន @@ -75,79 +47,50 @@ បញ្ចូន ដើម្បីធ្វើបច្ចុប្បន្នភាព - ដើម្បីលុប កំហុសបច្ចេកទេស - បញ្ជូន + - ការបញ្ចប់ - ​បង្កើត ធ្វើ​បច្ចុប្បន្នភាព - កាលបរិច្ឆេទ អង្គភាព - ជម្រើសប្រភេទ រយៈទទឹង រយៈបណ្តោយ - ព្រឹត្តិការណ៏ថ្មី - មួយពេល - អចិន្ត្រៃយ៍ Report period កាលបរិច្ឆេទកំណត់ ប្រភេទបន្សំចូលគ្នា - ថ្ងៃថ្ងៃ/ខែខែ/ឆ្នាំឆ្នាំឆ្នាំឆ្នាំ បោះបង់ ​លុប​ កម្មវិធីដំណើរការ - កម្មវិធីចុះឈ្មោះ ចុះឈ្មោះ - ជួបចុងក្រោយ​ - អនុវត្ត - មើលលំអិត - មើលច្រើនទៀត កែសម្រួល​ - តាមដាន - ការតាមដានដំណើរការ - ការតាមដានមិនដំណើរការ ធ្វើអោយអសកម្ម កម្មវិធីនេះមិនអនុញ្ញាតអោយមានព្រឹត្តិការណ៏ច្រើនទេ - មិនមានទិន្នន័យ ព្រឹត្តិការណ៏ ផ្ទាំងដើម - ព័ត៌មានកម្មវិធី ចាកចេញ - ការទប់ស្កាត់ - - ១នាទី - ១៥នាទី ១ម៉ោង ១ថ្ងៃ (លំនាំដើម) - កំណត់ការបញ្ជូនទិន្នន័យដោយស្វ័យប្រវត្តិជារៀងរាល់ ហត្ថកម្ម - កំណត់​រចនាសម្ព័ន្ការដោយស្វ័យប្រវត្តិជារៀងរាល់ ១សប្តាហ៍ ប៉ារ៉ាម៉ែត្រដែលកំពុងធ្វើការបញ្ជូន ធ្វើអោយទោលំនាំដើម + ប្រព័ន្ធទិន្នន័យនឹងត្រូវលុបចោល\nអ្នកត្រូវចូលមកប្រើម្ដងទៀតដោយបញ្ចុលឈ្មោះ និង លេខសំងាត់​ ទទួលយក បោះបង់ បញ្ជូនទិន្នន័យឥឡូវនេះ បញ្ជូនការកំណត់​រចនាសម្ព័ន្ធឥឡូវនេះ ការ​កំណត់ - បានបញ្ជូនចុងក្រោយ: %s - បានបញ្ជូនចុងក្រោយ: %s - កំណត់កម្មវិធីឡើងវិញ ស្ថានភាព - ថ្ងៃខែឆ្នាំចុះឈ្មោះ - ស្វែងរក @@ -156,34 +99,16 @@ កត់ចំណាំថ្មី លុបចោល បន្ថែមការកត់ចំណាំ - ត្រលប់ក្រោយ - បានដាក់អោយដំណើរការ - ទិន្នន័យចាំបាច់មិនមាន - ឈ្មោះទិន្នន័យចាំបាច់ខ្លះមិនមាន, ប្រសិនបើអ្នកចង់ត្រឡប់ទៅវិញ រាល់ពត៏មាននិងត្រូវលុយចោល - លុប ហើយត្រឡប់ទៅវិញ - បញ្ជីតាមផ្នែក - %s សំនួរនៃ %s ដែលបានបញ្ចប់ - %s នៃ %s ដែលបានបញ្ចប់ - បញ្ចប់ និង ចាកចេញ - ថ្ងៃ - ខែ - ឆ្នាំ - សប្ដាហ៏ ថ្ងៃខែឆ្នាំ ធ្វើរោគវិនិច្ឆ័យ - លទ្ធផលក្នុងតំបន់ - លទ្ធផលអនឡាញ កម្មវិធីពីមុន អ្នកមិនមានសិទ្ធិគ្រប់គ្រាន់ដើម្បីដំណើរការផ្នែកនេះទេ - បន្ថែមថ្មីជា​ - លុបចោល កំឡុងពេល - (%d) អង្គភាព សូមស្វាគមន៏មកកាន់ប្រព័ន្ធ DHIS2 នៅទីនេះអ្នកអាចឃើញសកម្មភាពរបស់អ្នក អ្នកអាចអនុវត្តការច្រោះទិន្នន័យ​ @@ -192,15 +117,11 @@ ពិនិត្យមើលសកម្មភាពផ្សេងទៀតនៅផ្ទាំងបញ្ជីដើម - បង្កើតទំនាក់ទំនង បន្ទាប់ អ្នកត្រូវបញ្ចូលយ៉ាងហោចណាស់ %d នៃលក្ខណៈ (Attributes) ដើម្បីធ្វើការស្វែងរក - មានលទ្ធផលច្រើនជាង %d សូមបញ្ចូលលក្ខខណ្ឌផ្សេងទៀតដើម្បីស្វែងរកបន្ថែម - បញ្ចូលលក្ខខណ្ឌដើម្បីស្វែងរក នាំចូលទិន្នន័យ QR - រក្សាទុក​ យល់ព្រម ការកំណត់អត្តសញ្ញាណ លក្ខណៈ (Attributes) @@ -212,26 +133,17 @@ មិនមានទិន្នន័យ លេខកូដ QR មិនត្រឹមត្រូវ Attribute (លក្ខណៈនេះ) មិនអាចត្រូវបានបន្ថែមទេ - ការចុះឈ្មោះនេះមិនអាចបន្ថែមបានឡើយ អំពី កម្មវិធីចុះឈ្មោះ - ជ្រើសរើសទាំងអស់ - មិនមានព៌ត័មានដែលអ្នកបានច្រោះមើលនេះទេ, សូមប្ដូរចេញ​ ចែករំលែក ចែករំលែកលេខកូដ QR អតិបរមា បច្ចុប្បន្ន TEI - កំណត់ដោយអង្គភាព - បញ្ជីកម្មវិធី កំណត់កម្មវិធីឡើងវិញ - មិនអាចទៅរួចទេក្នុងការបន្ថែមទំនាក់ទំនងនៅទីនេះ ពិនិត្យទីនេះ​ - កំហុសបច្ចេកទេស រក្សាទុក​ - តើអ្នកចង់បន្ថែមទិន្នន័យពី QR ដែររឺទេ? - កំណែកម្មវិធី %s កំណែរ SDK: %s @@ -242,86 +154,29 @@ Developed and maintained by the DHIS2 project, part of University of Oslo, department of informatics សម្រាប់ព៌ត័មានបន្ថែម រឺ ក៏មានសំនួរអំពីកម្មវិធីនេះ, សូមសរសេរមកកាន់android\@dhis2.org - មិនមានលេខសំងាត់, សូមបំពេញ​ - មិនមានឈ្មោះអ្នកប្រើប្រាស់, សូមបំពេញ​ - ម៉ាស៊ីនមេនេះកំពុងដំណើរការខុសពី DHIS ២.២៩។ សូមធ្វើបច្ចុប្បន្នភាពលើម៉ាស៊ីនមេរបស់អ្នក​ - មានអ្វីមួយមិនប្រក្រតី​ សូមមើលអាសយដ្ឋានម៉ាស៊ីនមេ ឈ្នោះ លេខសម្ងាត់ ហើយព្យាយាមម្តងទៀត - ម៉ាស៊ីនមេមិនអាចបំពេញសំណើរនេះបានទេ​ - មានបញ្ហានៅពេលព្យាយាមចូលទៅក្នុងម៉ាស៊ីនមេ​ - ការចូលទៅប្រើប្រាស់មានបញ្ហា - ទិន្នន័យចាំបាច់មិនមាន - កំហុសដែលមិនបានរំពឹងទុកនៅពេលដែលកំពុងស្វែងរកនៅលើអនឡាញ - កំហុសខណៈពេលញែកលទ្ធផល - ការស្វែងរកតាមអនឡានមានបញ្ហាបច្ចេកទេស - មានអ្វីមួយខុសបច្ចេកទេសនៅលើម៉ាស៊ីនមេ ជ្រើសរើសកម្មវិធីដើម្បីដំណើរការចុះឈ្មោះ អ្នកមិនមានសិទ្ធិគ្រប់គ្រាន់ - មុខងារនេះមិនទាន់រួចរាល់នៅឡើយទេ​ - កំហុសក្នុងការធ្វើទំនាក់ទំនង ទាំងអស់ %s លេខទូរស័ព្ទមិនត្រឹមត្រូវ - អាសយដ្ឋានអ៊ីមែលមិនត្រឹមត្រូវ - លេខអវិជ្ជមានតែមួយគត់ដែលត្រូវបានអនុញ្ញាត លេខវិជ្ជមាន រឺ លេខ ០ តែមួយគត់ដែលត្រូវបានអនុញ្ញាត - លេខវិជ្ជមានតែមួយគត់ដែលត្រូវបានអនុញ្ញាត - តំលៃចន្លោះពីលេខ ០ ដល់ ១ ត្រូវបានអនុញ្ញាត - តំលៃចន្លោះពីលេខ ០ ដល់ ១០០ ត្រូវបានអនុញ្ញាត - ជ្រើសរើសថ្ងៃខែឆ្នាំដើម្បីច្រោះទិន្នន័យ កំណត់​ជម្រើស - បានរាយការណ៏បញ្ហា - បង្កើតព្រឹត្តិការណ៏ថ្មី - តើអ្នកចង់បង្កើតព្រឹត្តិការណ៏ថ្មីមួយទៀតទេ? ព្រឹត្តិការណ៏បានបញ្ចប់ គ្រប់ព្រឹត្តិការណ៏ទាំងអស់នៅក្នុងកម្មវិធីនេះបានបញ្ចប់។ តើអ្នកចង់បិទកម្មវិធីនេះទេ? ត្រួតពិនិត្យ ផ្ទាំងដើម ឧទាហរណ៏ - បន្ថែម Widget បង្ហាញជំនួយ - កំពុងបញ្ជូនទិន្នន័យរបស់អ្នក... - ...ការកំណត់​រចនាសម្ព័ន្ធរបស់អ្នក... - ...កំណត់ទំហំផ្ទុកក្នុងម៉ាស៊ីនរបស់អ្នក... - ...រឺ លុបគ្រប់យ៉ាងដើម្បីចាប់ផ្ដើមឡើងវិញ - អ្នកអាចផ្លាស់ប្ដូរទៅកម្មវិធីផ្សេងទៀត នេះគឺជាផ្ទាំងបង្ហាញព៌ត័មានរបស់អង្គភាព មើលព័ត៌មានប្រវត្តិរូបរបស់អង្គភាព... - ...ចែករំលែកព៌ត័មានតាមរយៈ QR រឹ SMS... - ...ឬសម្គាល់អង្គភាពសម្រាប់ការតាមដាន - ចុចដើម្បីបង្កើត រឺ ធ្វើកាលវិភាគលើព្រឹត្តិការណ៏មួយ រឺ បញ្ជូនទៅអង្គភាពផ្សេង ទីនេះអ្នកនឹងឃើញគ្រប់ព្រឹត្តិការណ៏ទាំងអស់, ចុចដើម្បីពិនិត្យមើលលំអិតពីព្រឹត្តិការណ៏ ចុចលើផ្ទាំង Menu ដើម្បីបន្ថែមទំនាក់ទំនង, មើលសូចនាករ រឺ សរសេរកំណត់ត្រា - ចុចដើម្បីមើលបញ្ជីទាំងអស់នៃអង្គភាពរបស់អ្នក ឬ បង្កើតថ្មី - ចុចដើម្បីលុបព្រឹត្តិការណ៏នេះ,​ សកម្មភាពនេះមិនអាចត្រលប់មកវិញបានទេ - បញ្ចប់ព្រឹត្តិការណ៏ - គ្រប់ព្រឹត្តិការណ៏ទាំងអស់ដែលទាក់ទងនឹងកម្មវិធីនេះត្រូវបង្ហាញនៅទីនេះ - ចុចដើម្បីបន្ថែមនូវព្រឹត្តិការណ៏ថ្មី បញ្ចូលគ្រប់ទិន្នន័យទាំងអស់ដើម្បីបង្កើតព្រឹត្តិការណ៏ថ្មី ចុចដើម្បីពិនិត្យពីស្ថានភាពនៃការបញ្ចប់របស់ព្រឹត្តិការណ៍ - ចុចដើម្បីបញ្ចប់ រឺ បើកឡើងវិញនូវព្រឹត្តិការណ៏ - អាន និង ផ្ទុកព៌ត័មានពីឧបករណ៏ផ្សេងៗតាមរយៈ QR - កំពុងបញ្ជូនទិន្នន័យរបស់អ្នកទៅកាន់ម៉ាស៊ីនមេ - បិទកម្មវិធីដោយលេខកូដ ៤ ខ្ទង់។ កុំភ្លេចលេខកូដរបស់អ្នក! - ចាកចេញដើម្បីប្ដូរឈ្មោះអ្នកប្រើប្រាស់ រឺ ដូរម៉ាស៊ីនមេ។ អ្នកនឹងអាចចូលមកប្រើបម្ដងទៀតទោះបីជាមិនភ្ជាប់អ៊ិនធើណែតក៏ដោយ ទំនាក់ទំនង ទិដ្ឋភាពទូទៅ - ទំនាក់ទំនង - Indicators - ចំណាំ - លម្អិត - Indicators - ចំណាំ - ទទួលយក - បន្ត - អាសយដ្ឋានរបស់ម៉ាស៊ីនមេ បិទ - បញ្ចប់ - បើក - រាល់​ថ្ងៃ - រាល់​សប្ដាហ៍ - បានបញ្ចប់ កំឡុងពេល - ស្ថានភាព បញ្ចូន ថ្ងៃនេះ កាលពីម្សិលមិញ @@ -335,19 +190,11 @@ ត្រលប់ថយ​ក្រោយ កាលពីម្សិលមិញ ការបញ្ចូនទិន្នន័យមានបញ្ហាបច្ចេកទេស - លម្អិត - ឈ្មោះ - ថ្ងៃខែឆ្នាំចុះឈ្មោះ បើក បញ្ចប់ បានលុបចោល - Data - Done - តើអ្នកច្បាស់ទេ? កាលបរិច្ឆេទ - ថ្ងៃខែឆ្នាំរបស់ព្រឹត្តិការណ៏ ត្រលប់ថយ​ក្រោយ - បិទ គំនូសតាង(ក្រាប) គំនូសតាង(ក្រាប) ចំណាំ @@ -367,13 +214,8 @@ ឆ្នាំហិរញ្ញវត្ថុមុន មតិ​ឆ្លើយតប Indicators - តម្លៃ - អត្ដសញ្ញាណមិនត្រឹមត្រូវ - អាច​រក​បាន​ - មិន​ស្គាល់ ផ្ញើទៅ ការបញ្ចូនទិន្នន័យមានបញ្ហាបច្ចេកទេស - មិនមានក៏បាន បង្ហាញច្រើនទៀត បញ្ចូន បង្ហាញការពិពណ៌នា diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 04563b74d4d..489b9fa322c 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -1,63 +1,28 @@ ລະບົບ DHIS2 - ລະບົບຂໍ້ມູນຂ່າວສານດ້ານສຸຂະພາບຂັ້ນເມືອງ - 2 ເກີດການຜິດພາດ - - ຂໍ້ຜິດພາດ - - ຄວາມຜິດພາດຈາກການເຊື່ອມຕໍ່ຂອງເຄື່ອງຄອມພີວເຕີໜ່ວຍແມ່ - ຄວາມຜິດພາດຈາກຢັ້ງຢືນເອກະລັກບຸກຄົນ - ຄໍາຜິດທີ່ເກີດຂື້ນໂດຍບັງເອີນ - ຄໍາຜິດທີ່ເກີດຈາກພາຍໃນເຄື່ອງຄອມພີວເຕີໜ່ວຍແມ່ - ບໍ່ມີຂໍ້ມູນ ບໍ່ມີຄ່າທີ່ສະຫງວນໃວ້. ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ - ບໍ່ມີຄ່າທີ່ສະຫງວນໃວ້. ເຊື່ອມຕໍ່ກັບເຄື່ອຂ່າຍເພື່ອບັນຈຸອີກ - ຂໍ້ຜິດພາດ ມີບາງຢ່າງຜິດພາດ - ດຽວນີ້, ລະບົບກໍາລັງທຳງານຮ່ວມກັນ - ກໍາລັງທໍາງານຮ່ວມກັນກັບຄອມພີວເຕີໜ່ວຍແມ່ - ລະບົບກໍາລັງທໍາງານຮ່ວມກັນ - ໂຄງຮ່າງຖານຂໍ້ມູນ ເຫດການ - ຂໍ້ມູນບົ່ງບອກເອກະລັກທີ່ຖືກຕິດຕາມ - - ສໍາເລັດ - ການທຳງານຮ່ວມກັນຂອງລະບົບສໍາເລັດແລ້ວ ຄໍາຜິດທີ່ເກີດຈາກການທໍາງານຮ່ວມກັນຂອງລະບົບ ມີຄໍາຜິດເກີດຂື້ນໃນຂະນະທີ່ລະບົບທໍາງານຮ່ວມກັນ. ກະລຸນາ, ທົດລອງເບີ່ງອີກ... ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນສຳເລັດແລ້ວ ແຕ່ພວກເຮົາບໍ່ໄດ້ຮັບການຢືນຢັນຈາກເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ສຳລັບບັນທຶກທັງໝົດ. ເຫຼົ່ານັ້ນຍັງຖືກໝາຍເປັນ \"ອອບລາຍ\" ໃນແອັບ. ພວກເຮົາແນະນຳໃຫ້ລອງການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນອີກຄັ້ງ. - ຂໍ້ຄວນລະວັງ: ຂໍ້ມູນຂອງທ່ານໄດ້ຖຶກຊິ້ງແລ້ວ ແຕ່ບາງຂໍ້ມູນມີຂໍ້ຂັດແຍ້ງ. ກົດການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນ ແລະ ລອງໃໝ່ອີກຄັ້ງ. ຖ້າຂໍ້ຄວນລະວັງຍັງຄົງຢູ່ ໃຫ້ກວດເບີ່ງສະຖານະການຊິິ້ງຂອງ: ໂປຣແກຣມ, TEI\'s ແລະ ເຫດການທີ່ມີການຂັດແຍ້ງຈະຖຶກໝາຍເປັນ @. - ເກີດຂໍ້ຜິດພາດ ແລະເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ບໍ່ຕອບສະໜອງ. ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. - - %sຜົນໄດ້ຮັບທີ່ຖືກພົບເຫັນ - ເພີ່ມໃໝ່ - ເພີ່ມ ປ້ອນ%s ປ້ອນ / ຂ້າມ%s - ສ້າງແຜນໃໝ່ ການສົ່ງຕໍ່ - ທີ່ຢູ່ຂອງເຄື່ອງຄອມພີວເຕີໜ່ວຍແມ່ - ຊື່ຜູ້ໃຊ້ - ລະຫັດ - ເຂົ້າລະບົບ - ໄລຍະເປີດໃຫ້ນໍາໃຊ້ ລະບົບກໍາລັງທໍາງານຮ່ວມກັບການຈັດວ່າງລະບົບຂອງທ່ານ ຂໍ້ມູນຂອງທ່ານຈະຖືກສົ່ງເຂົ້າລະບົບໃນໄວໆນີ້ ຂໍ້ມູນຂອງທ່ານກໍາລັງຖືກສົ່ງເຂົ້າໃນລະບົບ ການຈັດວ່າງລະບົບຂອງທ່ານກຽມພ້ອມແລ້ວ ຂໍ້ມູນຂອງທ່ານກຽມພ້ອມແລ້ວ - ການຈັດວ່າງລະບົບຂອງທ່ານເພື່ອໃຫ້ທໍາງານຮ່ວມກັນຫຼົມເຫຼວ (ບໍ່ສໍາເລັດ) - ການເຊື່ອມຂໍ້ມູນເຂົ້າກັບລະບົບຫຼົມເຫຼວ (ບໍ່ສໍາເລັດ) ​ປັບ​ປຸງ​ຄືນ​ບັນ​ຊີ​ຂອງ​ທ່ານ - ຈໍາເປັນຕ້ອງມີການເຊື່ອມຕໍ່ອິນເຕີເນັດ ເພື່ອກູ້ຄືນບັນຊີຂອງທ່ານ. ທຸກໆມື້ @@ -78,129 +43,80 @@ ຂຽນຄໍາຕອບ ປ້ອນຂໍ້ມູນເປັນຂໍ້ຄວາມຍາວ ຕື່ມຕົວເລກ - ປ້ອນຂໍ້ມູນເປັນຈໍານວນຖ້ວນ ປ້ອນຂໍ້ມູນເປັນຈໍານວນຖ້ວນຄ່າລົບ ຕື່ມຕົວເລກ ປ້ອນຂໍ້ມູນເປັນຈໍານວນຖ້ວນຄ່າບວກ - ຄັດກອງເອົາຕົວເລືອກ ເລືອກວັນທີ - ປ່ຽນປະຕິທິນ - ໂປຣແກຣມດໍາເນີນການສໍາເລັດແລ້ວ - ໂປຣແກຣມທີ່ບໍ່ໄດ້ເປີດໃຊ້ງານ ເປີດ ບໍ່ສຳເລັດ ກິດຈະກຳສຳເລັດແລ້ວ ອ່ານໄດ້ເທົ່ານັ້ນ - ສີ້ນສຸດ ແລະ ສໍາເລັດ ສໍາເລັດແລ້ວ ຍົກເລີກແລ້ວ - ເປິດຟອມຄືນໃໝ່ - ເປິດໃຊ້ງານ ທີ່ຍັງນໍາໃຊ້ຢູ່ ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ ສົ່ງ ຕ້ອງການອັບເດດ - ເພື່ອລືບອອກ ເກີດການຜິດພາດ - ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ - ສົ່ງ - ຊິ້ງຂໍ້ຄວາມ - ການສໍາເລັດ - ສ້າງ ປັບປຸງ - ວັນທີ່ ສະຖານທີ່ ບໍ່ມີໜ່ວຍງານໃດທີ່ເປິດຢູ່ສຳລັບວັນທີທີ່ເລືອກ. ກະລຸນາເລືອກວັນທີ່ອື່ນຫຼືໜ່ວຍງານອື່ນ - Category Option ເສັ້ນຂະໜານ ເສັ້ນແວງ - ກໍລະນີໃໝ່ - ເທື່ອດຽວ - ຖາວອນ ຊ່ວງເວລາ ການລາຍງານ - ຖ້າທ່ານຕັ້ງຄ່າ, ແຖວທັ້ງໝົດຕ້ອງໃຊ້ຄ່າທັ້ງໝົດເພື່ອເຮັດໃຫ້ຊຸດຂໍ້ມູນສົມບູນ - ຄ່າຂໍ້ມູນບາງຄ່າທີ່ຈຳເປັນຫາຍໄປ. ທ່ານຕ້ອງກຳນົດຄ່າບັງຄັບ ເພື່ອໃຫ້ຊຸດຂໍ້ມູນສົມບູນ - ຍັງປ້ອນຂໍ້ມູນບັງຄັບບໍ່ຄົບ. ກະລຸນາ, ປ້ອນຂໍ້ມູນເພື່ອໃຫ້ຊຸດຂໍ້ມູນຄົບຖ້ວນ. - ປະຫຍັດຄ່າ! - ບໍ່ມີໄລຍະເວລາທີຈະເລືອກ ວັນທີຕ້ອງແລ້ວ ລາຍການທີ່ໃຫ້ເລືອກ - ວ/ດ/ປ ຍົກເລີກ ລົບ - ລວມ ວັນທີເຫດການຄັ້ງຫຼ້າສຸດ ໂປຣແກຣທີ່ເປີດນໍາໃຊ້ - ໂປຣແກຣມເພື່ອລົງທະບຽນ ຂື້ນທະບຽນ - ການລົງຢ້ຽມຢາມຄັ້ງທີ່ຜ່ານມາ - ນໍາໃຊ້ - ເບິ່ງຂໍ້ມູນເພີ່ມຕື່ມ - ເບີ່ງຕື່ມ ແກ້ໄຂ - ຕິດຕາມ - ເປີດການຕິດຕາມ - ການຕິດຕາມບໍ່ສາມາດນໍາໃຊ້ໄດ້ ຢຸດນໍາໃຊ້ ໂປຣແກຣມນີ້ບໍ່ອະນຸຍາດຕື່ມກໍລະນີເຂົ້າອີກ - ບໍ່ມີຂໍ້ມູນ ເຫດການ ໜ້າຫຼັກ - ຂໍ້ມູນກ່ຽວກັບໂປຣແກຣມ ອອກຈາກລະບົບ - ໄລຍະປີດການນໍາໃຊ້ ສະແດງ%sເພີ່ມເຕີມ ສະແດງໃຫ້ເຫັນໜ້ອຍລົງ - 1 ນາທີ - 15 ນາທີ 30 ນາທີ 1 ຊົ່ວໂມງ 6h 1 ມື້ (ຄ່າມາດຕະຖານ) 12h - ສົ່ງຂໍ້ມູນໂອໂຕມະຕິກ ແຕ່ລະ: ຄູ່ມື - ປັບປຸງການຕັ້ງຄ່າແບບໂອໂຕມຕິກ ທຸກໆ: 1 ອາທິດ ຂອບເຂດທີ່ຕັ້ງໄວ້ໃຫ້ລະບົບທໍາງານຮ່ວມກັນໃນປະຈຸບັນ ປັບລະບົບໃຫ້ທໍາງານໃນຄ່າປົກະຕິ + ຖານຂໍ້ມູນຈະຖືກລືບ.\nທ່ານຈະໄດ້ເຂົ້າລະບົບໃໝ່ ຍອມຮັບ ຍົກເລີກ ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນນະຕອນນີ້ ປັບການຕັ້ງຄ່າດຽວນີ້ ຕັ້ງຄ່າ - ອັບເດດຂໍ້ມູນຄັ້ງສຸດທ້າຍເມື່ອ:%s - ອັບເດດຂໍ້ມູນຄັ້ງສຸດທ້າຍເມື່ອ:%s ໃສ່ອີກ - ໂມດູນ SMS: ເສັ້ນທາງການນຳສົ່ງຂໍ້ຄວາມ ຜູ້ສົ່ງຜົນຂໍ້ຄວາມ - ວິນາທີ - ຈຳນວນ - ຈຳນວນ ເປິດໃຊ້ການສົ່ງຂໍ້ຄວາມ ລໍຖ້າການຕອບກັບທາງຂໍ້ຄວາມ - - ມີ%s ສາຍງານທີ່ມີການຕັ້ງຄ່າສະເພາະ - ຈໍາກັດການຕັ້ງຄ່າ ໃນທົ່ວໂລກ ຕໍ່ແຜນງານ @@ -218,17 +134,12 @@ ບັນທຶກນີ້ມີຂໍ້ຜິດພາດທັ້ງໝົດທີ່ເກີດຈາກການຊິ້ງຂໍ້ມູນ ແລະ metadata ຂອງທ່ານ. ລົບຂໍ້ມູນໃນເຄື່ອງ ຂໍ້ມູນແອັບ Capture ທັງໝົດທີ່ເກັບໄວ້ໃນອຸປະກອນຂອງທ່ານຈະຖືກລຶບອອກ. ຂໍ້​ມູນ​ທີ່​ບໍ່​ໄດ້​ຊິ້ງຂໍ້ມູນກັບຄອມພິວເຕີ້ໜ່ວຍແມ່​ຈະ​ສູນ​ເສຍ​ໄປ. - ຕັ້ງຄ່າໂປຣແກຣມຄືນໃໝ່ - ການຕັ້ງຄ່າ ແລະ ຂໍ້ມູນແອັບທີ່ເກັບໄວ້ໃນອຸປະກອນຈະຖືກລຶບອອກ. ທ່ານຈະຖືກຖາມໃຫ້ເຂົ້າສູ່ລະບົບອີກຄັ້ງ ແລະ ຂໍ້ມູນທີ່ບໍ່ໄດ້ຊິງຄ໌ກັບເຄື່ອງຄອມພິວເຕີ້ໜ່ວຍແມ່ຈະສູນເສຍໄປ. ຕັ້ງຄ່າຂໍ້ຄວາມ ອັບເດດຊັອບແວ - ຊອບແວ ກວດສອບຄວາມຖືກຕ້ອງແບບ 2 ຂັ້ນ ສະຖານະ - ວັນທີ່ລົງທະບຽນ - ຄົນຫາ @@ -249,29 +160,15 @@ ປະສານງານຫາ - ລາຍຊື່ຂອງແຕ່ລະພາກ - %s ຄໍາຖາມໃນ %s ທີ່ສໍາເລັດແລ້ວ - %s ໃນ %s ທີ່ສໍາເລັດແລ້ວ - ກົດສໍາເລັດ ແລະ ອອກ - ມື້ - ເດືອນ - ປີ - ອາທິດ ວັນທີເດືອນປີທີ່ເຫດການເກີດຂື້ນ - ຜົນໄດ້ຮັບໃນໂທລະສັບ - ຜົນໄດ້ຮັບທາງອອນລາຍ ໂປຣແກຣມທີ່ຜ່ານມາ ທ່ານບໍ່ໄດ້ຮັບອະນຸຍາດໃນການດໍາເນີນການໜ້າວຽກນີ້ - ເພີ່ມເຂົ້າເປັນ ... - ລຶບ ໄລຍະ ເວລາ - (%d) ຂອງ ສະຖານທີ່ - ລະດັບ %d ຍີນດີຕ້ອນຮັບເຂົ້າສູ່ ລະບົບ DHIS2 ທີ່ນີ້, ທ່ານສາມາດເບີ່ງກິດຈະກໍາ ຂອງທ່ານໄດ້ ທ່ານສາມາດນໍາໃຊ້ເຄື່ອງມືຄັດຈ້ອນ... @@ -280,16 +177,11 @@ ກວດເບີ່ງໜ້າວຽກອື່ນໆໃນລາຍການຫຼັກ - ສ້າງສາຍພົວພັນ ຕໍ່ໄປ ທ່ານຕ້ອງປ້ອນຢ່າງໜອຍ %d ຂອງ ຄຸນລັກສະນະເຂົ້າເພື່ອຊອກຫາ - ທ່ານຕ້ອງໃສ່ຢ່າງຫນ້ອຍຫນຶ່ງ ຂອງຄຸນລັກສະນະເພື່ອເຮັດການຄົ້ນຫາ - ມີຫຼາຍກ່ວາ %d ຂອງຜົນໄດ້ຮັບ. ກະລຸນາເພີ່ມເງື່ອນໄຂໃນການຄົ້ນຫາເຂົ້າຕື່ມ - ປ້ອນເງື່ອນໄຂການຊອກຫາຂອງທ່ານ ນໍາເອົາຂໍ້ມູນ QR ເຂົ້າ - ບັນທຶກ ​ຕົກ​ລົງ ເອກະລັກສ່ວນຕົວ Attributes @@ -301,31 +193,19 @@ ບໍໍ່ມີຂໍ້ມູນ ລະຫັດ QR ບໍ່ຖືກ ຄຸນລັກສະນະອັນນີ້ບໍ່ສາມາດປ້ອນເຂົ້າໄດ້ - ການຂື້ນທະບຽນນີ້ບໍ່ສາມາດປ້ອນເຂົ້າໄດ້ ກ່ຽວກັບ ໂປຣແກຣມເພື່ອລົງທະບຽນ - ເລືອກທັງໝົດ - ລຶບການເລືອກ - ບໍ່ມີຂໍ້ມູນສໍາລັບການຄັດຈ້ອນທີ່ເລືອກມາ. ກະລຸນາພະຍາຍາມປ່ຽນຕົວຄັດຈ້ອນເບີ່ງ ແລກປ່ຽນ ແລກປ່ຽນ QR ຄ່າໃຫ່ຍສຸດ ປະຈຸບັນ ຈໍານວນກໍລະນີທີ່ປ້ອນເຂົ້າ - ຄັດຈ້ອນຕາມສະຖານທີ - ຈຳກັດໂດຍສາຍງານ - ລາຍຊື່ຂອງໂປຣແກຣມ ຕັ້ງຄ່າໂປຣແກຣມຄືນໃໝ່ - ເປັນໄປບໍ່ໄດ້ທີ່ຈະຕື່ມສາຍພົວພັນນີ້ເຂົ້າໄປ ກວດເບີ່ງອັນນີ້ - ແຈ້ງເຕືອນ - ເກີດການຜິດພາດ ບັນທຶກ ຄ່າລິຂະສິດ - ທ່ານຕ້ອງການເພີມຂໍ້ມູນຕື່ມຈາກລະຫັດ QR ນີ້ອີກບໍ? - ໂປຣແກຣມລຸ້ນ: %s SDK ລຸ້ນ: %s @@ -336,138 +216,44 @@ ຖືກພັດທະນາ ແລະ ບໍາລຸງຮັກສາໂດຍ DHIS2 ໂຄງການເຊີ່ງເປັນພາກສ່ວນໜື່ງຂອງມຫາວິທະຍາໄລ ໂອສໂລ, ກົມຂໍ້ມູນຂ່າວສານdepartment of informatics. ສໍາລັບຂໍ້ມູນເພີມຕື່ມ ຫຼື ມີຄໍາຖາມກ່ຽວກັບໂປຣແກຣມນີ້, ກະລຸນາ ຂຽນໄປສອບຖາມໄດ້ທີ android\@dhis2.org - ບໍ່ມີລະຫັດຜ່ານ. ກະລຸນາ ໃສ່ລະຫັດຜ່ານ - ບໍ່ມີຊື່ຜູ້ໃຊ້. ກະລຸນາ ໃສ່ຊື່ຜູ້ໃຊ້ - ຄອມພີວເຕີໜ່ວຍແມ່ກໍາລັງທໍາງານກັບລະບົບ DHIS2 ລຸ້ນສູງກ່ວາ 2.29. ກະລຸນາ ນໍາໃຊ້ລຸນທີ່ສູງກ່ວາ ຫຼື ນໍາໃຊ້ລະບົບທົດລອງແທນ - ຄອມພິວເຕີໜ່ວຍແມ່ເຄື່ອງນີ້ ໃຊ້ເວີຊັ່ນdhisທີ່ແຕກຕ່າງຈາກ %s. ໃຫ້ອັບເດດເວີຊັ່ນຂອງທ່ານ ຫຼື ໃຊ້ສະພາບແວດລ້ອມການທົດລອງ ເພື່ອດຳເນີນການຕໍ່ - ມີບາງສີ່ງບາງຢ່າງທໍາງານບໍ່ຖືກຕ້ອງ. ກະລຸນາກວດເບີ່ງ ທີ່ຢູ່ຂອງຄອມພີວເຕີໜ່ວຍແມ່, ຊື່ຜູ້ນໍາໃຊ້ ແລະ ລະຫັດຜ່ານວ່າຖືກຕ້ອງແລ້ວບໍ, ແລ້ວພະຍາຍາມເຂົ້າອີກເບີ່ງ - ເຄື່ອງຄອມພີວເຕີໜ່ວຍແມ່ບໍ່ສາມາດດໍາເນີນການຕາມຄໍາຮ້ອງຂໍນີ້ໄດ້ - ມີບັນຫາເກີດຂື້ນໃນຂະນະທີ່ພະຍາຍາມເຂົ້າຫາຄອມພີວເຕີໜ່ວຍແມ່ - ຄໍາຜິດທີ່ເກີດຈາກການເຂົ້ານໍາໃຊ້ລະບົບ - ຫ້ອງປ້ອນຂໍ້ມູນທີ່ຈໍາເປັນ ບໍ່ເຫັນ - ຄໍາຜິດເກີດຂື້ນໂດຍບໍ່ຄາດຄິດໃນຂະນະທີ່ກໍາລັງຊອກຫາທາງອອນລາຍ - ມີຄໍາຜິດໃນຂະນະທີ່ສົ່ງຜົນໄດ້ຮັບ - ການຄັົ້ນຫາທາງອອນລາຍໄດ້ສົ່ງຄໍາຜິດກັບຄືນມາ - ບາງສີ່ງບາງຢ່າງໃນຄອມພີວເຕີໜ່ວຍແມ່ທໍາງານຜິດພາດ ເລືອກໂປຣແກຣມເພື່ອໃຫ້ສາມາດຂື້ນທະບຽນໄດ້ ທ່ານບໍ່ໄດ້ຮັບອະນຸຍາດຕາມທີ່ໄດ້ຮ້ອງຂໍ - ໜ້າທີ່ປະຕິບັດການນີ້ຍັງບໍ່ທັນກຽມພ້ອມ - ຄໍາຜິດທີ່ເກີດຈາກສາຍພົວພັນຂອງການຕັ້ງຊື່ ທັງໝົດຂອງ %s ເບີໂທລະສັບບໍ່ຖືກຕ້ອງ - ຊື່ອີແມ໋ວບໍ່ຖືກ - ອະນຸຍາດໃຫ້ປ້ອນໄດ້ພຽງແຕ່ຄ່າລົບເທົ່ານັ້ນ ອະນຸຍາດໃຫ້ປ້ອນໄດ້ພຽງແຕ່ຄ່າບວກ ຫຼື ສູນເທົ່ານັ້ນ - ອະນຸຍາດໃຫ້ປ້ອນແຕ່ຄ່າບວກເທົ່ານັ້ນ - ອະນຸຍາດໃຫ້ປ້ອນແຕ່ຄ່າ 0 ຫາ 1 ເທົ່ານັ້ນ - ອະຍາດໃຫ້ປ້ອນແຕ່ຄ່າ 0 ຫາ 100 ເທົ່ານັ້ນ - ເລືອກວັນທີເພື່ອຄັດຈ້ອນ ເລືອກທາງເລືອກ - ບັນຫາຖືກລາຍງານແລ້ວ - ປ້ອນເຫດການໃໝ່ - ທ່ານຕ້ອງການປ້ອນຂໍ້ມູນກໍລະນີອື່ນບໍ? ກໍລະນີໄດ້ປ້ອນເຂົ້າສໍາເລັດແລ້ວ ກໍລະນີທັງໝົດໃນໂປຣແກຣມນີ້ໄດ້ຖືກດໍາເນີນການສໍາເລັດແລ້ວ. ທ່ານຕ້ອງການປິດໂປຣແກຣມນີ້ບໍ່? - ທ່ານຕ້ອງການລົງທະບຽນໃຫ້ສຳເລັດຫຼືບໍ? ກວດເບີ່ງ ໜ້າຫຼັກ ຕົວຢ່າງ - ເພີມອົງປະກອບຂອງໂປຣແກຣມ ຊ່ວຍເຫຼືອ - ສົ່ງຂໍ້ມູນຂອງທ່ານ - ...ການປັບແລະຕັ້ງຄ່າຂອງທ່ານ... - ...ຕັ້ງຄ່າຂີດຄວາມສາມາດໃນການບັນທຶກຂອງໂທລະສັບຂອງທ່ານ... - ກວດສອບ ແລະ ຈັດການມູນຄ່າສຳລອງທີຍັງເຫຼືອ - ກວດເບິ່ງຄືນການເຊື່ອມຕໍ່ກັບຄອມພິວເຕີໜ່ວຍແມ່ວ່າລົ້ມເຫລວບໍໍ່ - ... ຫຼືລືບທຸກສີ່ງທຸກຢ່າງເພື່ອເລີ່ມໃໝ່ - ຊອກຫາໜ່ວຍງານຂອງທ່ານ ທັ້ງໃນອຸປະກອນຟື້ນທີ່ຂອງທ່ານ ແລະ ເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່ - ທ່ານສາມາດປ່ຽນໄປເລືອກເອົາໂປຣແກຣມອື່ນ - ຄົ້ນຫາ ຫຼືເພີ່ມຫົວໜ່ວຍໃໝ່. ທ່ານຕ້ອງຄົ້ນຫາກ່ອນທ່ານຈື່ງສາມາດເພີ່ມອັນໃຫມ່ໄດ້! - ຄລິກບ່ອນນີ້ເພື່ອລ້າງ ແລະ ເລີ່ມການຄົ້ນຫາໃໝ່. ນີ້ແມ່ນກະດານຂ່າວສໍາລັບຂໍ້ມູນສ່ວນຕົວ ເບີ່ງຂໍ້ມູນກ່ຽວກັບປະຫັວດສ່ວນຕົວ - ...ແບ່ງປັນຂໍ້ມູນຜ່ານທາງ QR ຫຼື ຂໍ້ຄວາມ SMS... - ...ໝາຍຂໍ້ມູນສ່ວນຕົວເພື່ອຕິດຕາມ - ກົດເພື່ອສ້າງ ຫຼື ເຮັດແຜນສໍາລັບ ກໍລະນີ ຫຼື ເພື່ອນໍາສົ່ງໄປສະຖານທີ່ອື່ນ ທີ່ນີ້, ທ່ານຈະເຫັນກໍລະນີທັງໝົດ. ກົດເພື່ອເບີ່ງລາຍລະອຽດ ຊອກຜ່ານລາຍການເພື່ອເພີ່ມສາຍພົວພັນເຂົ້າ, ເບີ່ງຄ່າຕົວປ່ຽນ ຫຼື ບັນທຶກໄວ້ - ກົດເພື່ອເບີ່ງການຂື້ນທະບຽນຂໍ້ມູນທັງໝົດ ຂອງ ທ່ານ ຫຼື ຂື້ນທະບຽນໃໝ່ - ກົດເພື່ອລືບກໍລະນີນີ້. ພຶດຕິກໍານີ້ສາມາດຍົກເລີກໄດ້ - ບັນທຶກ ກໍລະນີໃຫ້ສໍາເລັດ - ກໍລະນີທັງໝົດທີ່ກ່ຽວຂ້ອງກັບໂປຣແກຣມນີ້ຈະປະກົດຂື້ນທີ່ນີ້ - ກົດເພື່ອເພີ່ມກໍລະນີໃໝ່ເຂົ້າ ປ້ອນຂໍ້ມູນທັງໝົດເຂົ້າເພື່ອສ້າງເປັນກໍລະນີ ກົດເພື່ອກວດເບີ່ງການເຮັດສໍາເລັດການບັນທຶກກໍລະນີ - ກົດເພື່ອຢັ້ງຢືນການເຮັດສໍາເລັດ ຫຼື ເປີດກໍລະນີຄືນໃໝ່ເພືອດັດແປງ - ອ່ານ ແລະ ບັນທຶກຂໍ້ມູນຈາກໂທລະສັບໜ່ວຍອື່ນຜ່ານ QR - ສົ່ງຂໍ້ມູນຂອງທ່ານໄປຫາຄອມພີມເຕີໜ່ວຍແມ່ - ປິດໄລຍະການນໍາໃຊ້ດ້ວຍລະຫັດ4ໂຕ. ຢ່າລືມລະຫັດດັ່ງກ່າວ - Lອອກຈາກໂປຣແກຣມເພື່ອປ່ຽນຜູ້ໃຊ້ຫຼືທີ່ຢູ່ຂອງຄອມພີວເຕີໜ່ວຍແມ່. ທ່ານຈະສາມາດເຂົ້າໂປຣແກຣມຄືນໄດ້ເຖີງແມ່ນວ່າບໍ່ມີການເຊື່ອມຕໍ່ອີນເຕີແນັດກໍ່ຕາມ. - %s: ສາຍພົວພັນ ບໍ່ມີເຫດການທີ່ລົງທະບຽນໃວ້, ຄລິກ \"+\" ເພື່ອເພີ່ມເຫດການໃໝ່ - ບໍໍມີເຫດການທີ່ລົງທະບຽນ Data values ຂໍ້ມູນ ອ່ານໄອດີນີ້ບໍໄດ້ ລົບຂໍ້ມູນໃນເຄື່ອງ ພາບລວມ - ສາຍພົວພັນ - ຕົວຊີ້ວັດ - ການວິເຄາະ ບໍ່ມີຕົວຊີ້ບອກສຳລັບສາຍງານນີ້ - ຂໍ້ຄວາມ - ບໍ່ສາມາດສຳເລັດໄດ້ - ບໍ່ສາມາດບັນທຶກໄດ້ - ມີການແຈ້ງເຕືອນຢູ່ໃນຟອມ - ເຈົ້າ​ຕ້ອງ​ການ​ເຮັດ​ຫຍັງ? - ຂໍ້ມູນມີຂໍ້ຜິດພາດ. ກະລຸນາ, ກວດເບິ່ງເພື່ອໃຫ້ສາມາດເຮັດສໍາເລັດ ການລົງທະບຽນທັງໝົດ ສະແດງກະດານຂ່າວພ້ອມສາຍງານທີ່ເຮັດວຽກທັ້ງໝົດ - ເປິດບັນທຶກຂໍ້ຜິດພາດໃນການຊິ້ງ ບັນທຶກຂໍ້ຜິດພາດໃນການຊິ້ງ ຮ່ວມກັບ - ການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນຂອງທ່ານລົ້ມເຫຼວ ອາດມີຂໍ້ຜິດພາດໃນເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່ຂອງທ່ານ. ຄລິກບ່ອນນີ້ສຳລັບຂໍ້ມູນເພີ່ມເຕີມ - ຕໍ່ໄປ - ລາຍລະອຽດ - ຕົວຊີ້ວັດ - ຂໍ້ຄວາມ - - ສຳເລັດພາຍຫຼັງ - - ສີ້ນສຸດ - ຂໍ້ມູນມີຂໍ້ຜິດພາດ - ບາງຊ່ອງຂໍ້ມູນມີຂໍ້ຜິດພາດ. ຍັງບໍໄດ້ບັນທຶກ ກະລຸນາກວດສອບ ຄ່າບໍໍຄວນຊ້ຳກັນ ບັງເອີນໄດ້ຖຶກພົບເຫັນ. ມັນບໍໄດ້ບັນທຶກ. ກະລຸນາກວດສອບ - ໄດ້ພົບເຫັນຊ່ອງປ້ອນຂໍ້ມູນທີ່ເປັນເອກະລັກ. ກະລຸນາກວດສອບແບບຟອມເພື່ອບັນທຶກການລົງທະບຽນ ກະລຸນາລໍຖ້າ ຂ້າມ - ກຳນົດເວລາໃໝ່ ບໍ່ມີຂໍ້ມູນສຳລັບໜ້າຈໍນີ້ - ກາຍເວລາທີ່ກໍານົດ - ມີກົດລະບຽບຂອງສາຍງານໜື່ງຂໍ້ທີບໍໄດ້ຮອງຮັບ. ກະລຸນາກວດສອບເອກະສານ - ບໍ່ມີຂໍ້ມູນສຳລັບຫ້ອງຂໍ້ມູນນີ້ - ຂໍ້ມູນປະຈຳຕົວຫວ່າງເປົ່າ - ເພື່ອໃຫ້ສາມາດເຂົ້າສູ່ລະບົບໂດຍໃຊ້ເຄື່ອງສະແກນລາຍນິ້ວມື້ ທ່ານຕ້ອງໃຊ້ວິທີເລີ່ມຕົ້ນກ່ອນ - ຕາຕະລາງຂໍ້ມູນ - ຕາຕະລາງຂໍ້ມູນ ຂໍ້ມູນທີ່ບໍ່ໄດ້ຮັບການຊິ້ງຈະສູນເສຍໄປ. ທ່ານຕ້ອງການສືບຕໍ່ຫຼືບໍ? - ຍອມຮັບ ບໍ່ສາມາດລົບຂໍ້ມູນໄດ້ ກະລຸນາລອງໃໝ່ອີກຄັ້ງ ລົບຂໍ້ມູນໃນເຄື່ອງຮຽບຮ້ອຍແລ້ວ - ຕ້ອງເລືອກຢ່າງຫນ້ອຍໜື່ງໂຄງຮ່າງການຈັດຕັ້ງ - ສົ່ງຂໍ້ມູນ - ຊ່ວຍ DHIS2 ປັບປຸງແອບນີ້ດ້ວຍການແບ່ງປັນຂໍ້ຜິດພາດທີ່ກຽ່ວຂ້ອງກັບໄອດີຜູ້ໃຊ້ຂອງທ່ານ - ທ່ານຕ້ອງການຊ່ວຍພວກເຮົາປັບປຸງແອັບນີ້ບໍ? - ກວດເບີ່ງຫ້ອງປ້ອນຂໍ້ມູນ - ທ່ານສູນເສຍການເຊື່ອມຕໍ່ອິນເຕີເນັດຂອງທ່ານໃນຂະນະທີ່ດໍາເນີນການຊິ້ງຂໍ້ມູນການຕັ້ງຄ່າຂອງທ່ານ. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ - ຍິນຍອມ - ຕໍ່ໄປ - ທີ່ຢູ່ຂອງເຄື່ອງຄອມພີວເຕີໜ່ວຍແມ່ - ລືມລະຫັດຂອງທ່ານ? - ທ່ານຕ້ອງເຮັດຫຍັງສໍາລັບເຫດການນີ້? - ສິ້ນສຸດ %s ບໍ່ພົບເຫັນ ບັນທຶກທີ່ທ່ານກຳລັງເຂົ້າເຖີງຍັງບໍ່ສາມາດເຂົ້າໄດ້ ທ່ານຕ້ອງການຄົ້ນຫາຫຼືບໍ? ຊິ້ງຂໍ້ມູນດ້ວຍຂໍ້ຄວາມ @@ -475,33 +261,16 @@ ກຳລັງອັບໂຫຼດ ຄວາມສໍາພັນ ສົ່ງຂໍ້ຄວາມ - ສາຍງານນີ້ບໍມີຂໍ້ຂັດແຍ້ງ - ບໍ່ມີການເຊື່ອຕໍ່ກັບເຄື່ອຂ່າຍ, ແຕ່ເບີ່ງຄືວ່າມີບໍລິການໂທລະສັບ - ທ່ານສາມາດໃຊ້ຂໍ້ຄວາມໄດ້ - ການເຊື່ອມຕໍ່ກັບເຄື່ອຂ່າຍບໍສາມາດໃຊ້ໄດ້ ປິດ ຕອນນີ້ທ່ານສາມາດກວດສອບການຊິ້ງຂອງຂໍ້ມູນ ໂດຍການກົດໃສ່ - ຂໍ້ມູນນີ້ຈັດເກັບໃວ້ໃນອຸປະກອນຂອງທ່ານເທົ່ານັ້ນ, ບໍ່ມີການຊິ້ງກັບເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ - ທ່ານອັບເດດແລ້ວ! ຂໍ້ມູນຂອງທ່ານທັງຫມົດຖືກສົ່ງໄປຫາເຄື່ອງຄອມພິວເຕີ້ໜ່ວຍແມ່. ທ່ານບໍ່ສາມາດເຊື່ອມໂຍງບັນທຶກກັບຕົວມັນເອງໄດ້ - ໄລຍະເປິດໃຫ້ນຳໃຊ້ - ສຳເລັດແລ້ວ - ເປີດ - ປະເພດຄ່າບໍ່ຮອງຮັບ ປ່ຽນຮູບແບບປະຕິທິນ - 15 ນາທີ - ທຸກໆຊົ່ວໂມງ - ທຸກໆມື້ - ເປັນອາທິດ - ອາທິດ - ການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນ %s ບັນທຶກທີ່ທ່ານພະຍາຍາມເຂົ້າບໍ່ມີການລົງທະບຽນ. ທ່ານບໍສາມາດເຂົ້າເຖີງໜ້າກະດານຂ່າວໄດ້ ຂໍ້ຜິດພາດໃນການຈັດຮູບແບບ ລົບ%s ທ່ານບໍສາມາດທີ່ຈະລົບບັນທຶກນີ້ໄດ້ - ຊື່ url ບໍ່ຖຶກຕ້ອງ ກຳລັງປັບຟອມ... - ຕົວເລກຜູ້ຮັບ ທ່ານຍອມຮັບທີ່ຈະສົ່ງ %d ຂໍ້ຄວາມຫຼືບໍ? ກຳລັງສົ່ງ %1$d/%2$d ການຢືນຢັນຈຳນວນທີ່ລໍຖ້າ%d @@ -519,50 +288,27 @@ ບໍ່ໄດ້ຕັ້ງຄ່າການນຳສົ່ງ ຜູ້ໃຊ້ບໍໄດ້ເຂົ້າລະບົບ ໂມດູນຂໍ້ຄວາມຖືກປິດໃຊ້ງານ - ການສົ່ງຂໍ້ຄວາມອື່ນກຳລັງດຳເນີນຢູ່ ບໍ່ໄດ້ເລີ່ມໂມດູນຂອງຂໍ້ຄວາມ ບໍພົບ Metadata ທີ່ຕ້ອງການ - ສໍາເລັດແລ້ວ ບໍ່ຍອມຮັບການນັບຂໍ້ຄວາມ ເລີ່ມສົ່ງຂໍ້ຄວາມ ຂໍ້ມູນການອ່ານ ແລະ ແປງ. ລະຫັດການສົ່ງ: %d - ເກີດຂໍ້ຜິດພາດໃນການສົ່ງຂໍ້ຄວາມ ການລົງທະບຽນ ແລະ ລົງທະບຽນຂໍ້ຄວາມ TEI ສົ່ງເຫດການທາງຂໍ້ຄວາມ ການສົ່ງຊຸດຂໍ້ມູນຂໍ້ຄວາມ - ເລີ່ມຕົ້ນ - ກໍາລັງດໍາເນີນການ - ສົ່ງແລ້ວ - Failed - ກຳລັງເລີ່ມການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນ ກຳລັງຊິ້ງຂໍ້ມູນ - ການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນສຳເລັດແລ້ວ - ເກີດຂໍ້ຜິດພາດລະຫວ່າງການຊິ້ງຂໍ້ມູນ - ມີຂໍ້ຜິດພາດເກີດຂຶ້ນໃນລະຫວ່າງການຕັ້ງຄ່າການຊິ້ງຂໍ້ມູນ ຄລິກທີ່ນີ້ເພື່ອກວດສອບເບິ່ງຂໍ້ຜິດພາດ - ຍົກເລິກການຊິ້ງຂໍ້ມຸນແລ້ວ ສຳເນົາແລ້ວ - ເລືອກຮູບແລ້ວ - ສະແດງແຜ່ນທີ - ສະແດງລາຍການ - %s ບັງຄັບແຕ່ບໍໄດ້ລະບຸໃວ້ - ບັນທຶກຮູບຫຼາຍຫຼ່ຽມ Polygon - ລາຍນິ້ວມື - ກະລຸນາ, ວາງນິ້ວມືຂອງທ່ານໃນເຄື່ອງອ່ານລາຍນິ້ວມື ຄວາມປອດໄພ - ມີຂໍ້ຜິດພາດບາງຢ່າງ. ກະລຸນາແກ້ໄຂກ່ອນທີ່ຈະສຶບຕໍ່ - ກຳລັງນຳໃຊ້ຕົວກອງ ບໍ່ໄດ້ໃຊ້ຕົວກອງ ຊ່ວງເວລາ ຫົວ​ໜ່ວຍ​ການ​ຈັດ​ຕັ້ງ - ການຈັດຮຽງ - ສະຖານະ ໃຫ້ລະບົບທໍາງານຮ່ວມກັນ ມອບໝາຍໃຫ້ຂ້ອຍ ມື້ນີ້ @@ -577,8 +323,6 @@ ຈາກ - ເຖິງ ອື່ນໆ ທຸກເວລາ - ການຊິ້ງລົ້ມເຫຼວ. ການຊິ້ງລົ້ມເຫຼວ. ກວດສອບເບີ່ງຄ່າຂໍ້ມູນເພື່ອລອງແກ້ໄຂບັນຫາ ແລະ ລອງໃໝ່ອີກຄັ້ງ. ຖ້າຫາກຍັງລົ້ມເຫຼວຢູ່ໃຫ້ຕິດຕໍ່ຜູ້ດູແລຮັກສາລະບົບຂອງທ່ານ - ຂໍ້ມູນນີ້ຖຶກສົ່ງໄປຫາເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່ແລ້ວ ແຕ່ບໍໄດ້ຮັບປະກັນວ່າຈະໄດ້ຮັບການຮັກສາໃວ້ ເປີດ ກຳນົດເວລາ ກາຍເວລາທີ່ກໍານົດ @@ -594,7 +338,6 @@ ເສັ້ນທາງການນຳສົ່ງຂໍ້ຄວາມຕ້ອງບໍ່ຫວ່າງເປົ່າ ບໍ່ມີການລົງທະບຽນທີ່ຈະອັບໂຫຼດ ຍັງບໍ່ມີການຮອງຮັບຫນ້າວຽກການແປງນີ້ - ບໍ່ຕ້ອງສະແດງລັອກໜ້າຈໍອີກ ຕັ້ງລະຫັດຜ່ານ ຂັດແຍ້ງ ປ່ຽນວັນທີ @@ -618,11 +361,7 @@ ເລືອກຫົວໜ່ວຍອົງກອນ ປ້ອນ url ປ້ອນຄ່າ - ເລືອກຮູບພາບ - ສະແກນລະຫັດ - ເລືອກຄໍາຕອບ ດາວໂຫຼດຄ່າລິຂະສິດຕາມຄຸນລັກສະນະTEI - ດາວໂຫຼດຄ່າລິຂະສິດຕາມຄຸນລັກສະນະTEI ຈັດການຄ່າລິຂະສິດ ດາວໂຫຼດເຫດການ ດາວໂຫຼດ TEIs @@ -641,39 +380,19 @@ ບໍ່ໄດ້ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ ຄໍາຜິດທີ່ເກີດຈາກການທໍາງານຮ່ວມກັນຂອງລະບົບ ຂໍ້ຄວາມອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ - ລາຍລະອຽດ - ຄຳເຫັນ ບັນທຶກແລ້ວ! - ທ່ານຕ້ອງການກວດສອບຄຸນນະພາບຂໍ້ມູນນີ້ບໍ? - ທຸກຢ່າງຮຽບຮ້ອຍດີ - ທ່ານຕ້ອງການເຮັດຊຸດຂໍ້ມູນໃຫ້ຄົບຖ້ວນບໍ? - ເຮັດໃຫ້ສຳເລັດແລ້ວ - ຊື່ - Formula - ແນວໃດກໍ່ສຳເລັດຂໍ້ມູນ ບໍ່ຄົບຖ້ວນ%s%sຈາກ%s%s ສະຖານະຂອງເຫດການ - ວັນທີ່ລົງທະບຽນ ບໍ່ມີຂໍ້ມູນ. ກົດ \"+\" ເພື່ອເພີ່ມບັນທຶກໃຫມ່ ບໍ່ມີຂໍ້ມູນ. ທ່ານບໍ່ໄດ້ຮັບອະນຸຍາດໃຫ້ເພີ່ມໃຫມ່. ບໍ່ມີຄໍາອະທິບາຍຫຼືຄໍາແນະນໍາໃຫ້ ຂໍ້ມູນທີ່ຕ້ອງທົບທວນ - ບັນ​ທຶກ​ແລະ​ສໍາ​ເລັດ​! ມີການລົງທະບຽນທີ່ໃຊ້ງານຢູ່ແລ້ວ ເປີດ ສຳເລັດແລ້ວ ຍົກເລີກແລ້ວ - ຂໍ້ມູນ ບໍ່ສາມາດດາວໂຫຼດສິ່ງທີ່ເລືອກໄດ້%s - ສຳເລັດໂດຍ %s:%s - ອັບເດດແລ້ວ: %s - ສຳເລັດ - ພວກເຮົາບໍ່ສາມາດກວດສອບຄຸນນະພາບຂໍ້ມູນໄດ້. ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ - ທ່ານ​ແນ່​ໃຈ​ບໍ່? - ທ່ານຕ້ອງການເປີດຊຸດຂໍ້ມູນຄືນໃໝ່ບໍ? ວັນທີ່ - ວັນທີເຫດການ - %sບໍ່ມີຄຸນສົມບັດສາທາລະນະ. ກະລຸນາເລືອກສາຍງານ ນະໂຍບາຍຄວາມເປັນສ່ວນຕົວ ກົດບ່ອນນີ້ເພື່ອກວດສອບນະໂຍບາຍຄວາມເປັນສ່ວນຕົວຂອງພວກເຮົາ ປະສານງານບໍ່ຖືກຕ້ອງ @@ -681,12 +400,7 @@ ເຫດຜົນດ້ານຄວາມປອດໄພ, ແອັບເວີຊັ່ນນີ້ບໍ່ອະນຸຍາດໃຫ້ໃຊ້ກັບ adb. ກະລຸນາໃຊ້ເວີຊັ່ນທີ່ໃຊ້ສະເພາະການຝຶກອົບຮົມ. ກັບຄືນ - ປິດແອັບ - ລາຍ​ລະ​ອຽດ​ເພີ່ມ​ເຕີມ - ລາຍ​ລະ​ອຽດ​ເພີ່ມ​ເຕີມ - ປິດ ສຳເນົາໄປຫາ ຄິບບອດ - ຂໍ້​ມູນ​ຜິດ​ພາດ​ ພວກເຮົາພົບເຫັນຂໍ້ຜິດພາດໃນເວລາຊິ້ງກັບເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ຂອງທ່ານ. ແບ່ງປັນຂໍ້ຜິດພາດກັບຜູ້ດູແລລະບົບຂອງທ່ານຖ້າເປັນໄປໄດ້. ເຫດການທີ່ວາງແຜນໄວ້ຈະບໍ່ຖືກກຳນົດເວລາໃໝ່ ແຜນວາດຂໍ້ມູນ @@ -735,44 +449,27 @@ ຜົນຕອບຮັບ ຕົວຊີ້ວັດ Value + ການ​ອະ​ນຸ​ຍາດ​ໃຊ້ກ້ອງ​ຖ່າຍ​ຮູບ​ຖືກ​ປະ​ຕິ​ເສດ. ທ່ານ​​ຕ້ອງ​ເປີດ​ໃຊ້​ງານເພື່ອ​ໃຊ້​ຄຸນ​ສົມ​ບັດ​ນີ້​. ແຜນວາດ ແລະ ຕົວຊີ້ວັດ ຖ່າຍຮູບ - ເລືອກຈາກຄັງຮູບ - ສຳເລັດແລ້ວ! ໂປຣແກຣມນີ້ໄດ້ຮັບການຄຸ້ມຄອງ ອະທິບາຍເຫດຜົນຢູ່ບ່ອນນີ້ - ກຳລັງກວດສອບຄວາມຖືກຕ້ອງ - ທີ່ຢູ່ຂອງເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ທີ່ໃສ່ ບໍ່ແມ່ນຕົວຢ່າງຂອງ dhis - ຄວາມຜິດພາດຈາກຢັ້ງຢືນເອກະລັກບຸກຄົນ - ບໍ່ຮູ້ເຄື່ອງຄອມພິວເຕິ້ໜ່ວຍແມ່ - ຄ່າຕ້ອງເປັນຈຳນວນຖ້ວນ ໄລຍະເວລາການສື່ສານໄດ້ສິ້ນສຸດລົງແລ້ວ ເຊດຊັນປັດຈຸບັນຂອງທ່ານໝົດອາຍຸແລ້ວ. ດ້ວຍເຫດຜົນດ້ານຄວາມປອດໄພ, ທ່ານຈະຕ້ອງໃຫ້ຂໍ້ມູນປະຈໍາຕົວຂອງທ່ານອີກເທື່ອຫນຶ່ງ - ຄ່າ ອາທິດ%d%sຫາ%s ບໍ່ຕ້ອງສະແດງອີກ ການຕັ້ງຄ່າຄຸນສົມບັດ ສະແດງເປັນ Radar - ບໍ່ສາມາດກຳນົດປະເພດການປະສານງານໄດ້ ຕິດຕາມ%s - %sບໍ່ສາມາດວິເຄາະເປັນວັນທີໄດ້ ບໍ່ສາມາດອັບເດດຫ້ອງປ້ອນຂໍ້ມູນນີ້ໄດ້. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ - ສີ່ງທີ່ມີ ຂໍ້ມູນນີ້ບໍ່ສາມາດແກ້ໄຂໄດ້ເນື່ອງຈາກຖືກໝາຍວ່າສຳເລັດແລ້ວ ຂໍ້ມູນນີ້ບໍ່ສາມາດແກ້ໄຂໄດ້ເນື່ອງຈາກເວລາຂອງມັນໝົດອາຍຸແລ້ວ ທ່ານບໍ່ໄດ້ຮັບອານຸຍາດໃນການແກ້ໄຂຂໍ້ມູນນີ້ ຂໍ້ມູນນີ້ບໍ່ສາມາດແກ້ໄຂໄດ້ເນື່ອງຈາກຫົວຫນ່ວຍການຈັດຕັ້ງຖືກປິດໃວ້ຢູ່ ທ່ານບໍ່ໄດ້ຮັບອານຸຍາດໃນການແກ້ໄຂຂໍ້ມູນນີ້ - ທ່ານບໍ່ສາມາດແກ້ໄຂຂໍ້ມູນຈາກຫົວຫນ່ວຍການຈັດຕັ້ງນີ້ໄດ້ - ລີເຊັດການຄົ້ນຫາ + ທ່ານບໍ່ສາມາດແກ້ໄຂຂໍ້ມູນຈາກຫົວຫນ່ວຍການຈັດຕັ້ງນີ້ໄດ້ ການຄົ້ນຫາອອນໄລນ໌ສົ່ງຂໍ້ຄວາມຕໍ່ໄປນີ້: - ຂໍ້ຄວນລະວັງກົດຂອງສາຍງານ - ມີບັນຫາການກຳນົດຄ່າທີ່ເຮັດໃຫ້ເກີດມີຊ່ອງຫວ່າງໃນກົດລະບຽບ. ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. - ເລີ່ມໃໝ່ - ບໍ່ຮູ້ - ໂຫຼດຂໍ້ມູນທັງໝົດຄືນໃໝ່ ລົບບັນຊີ - ຈັດການບັນຊີ ການ​ແກ້​ໄຂ​ບັນ​ຫາ​ການ​ກຳນົດຄ່າ Language ແຕະບ່ອນນີ້ເພື່ອ ປ່ຽນພາສາຂອງແອັບພິເຄຊັ່ນ @@ -789,36 +486,22 @@ ບໍ່ມີຜົນລັບສຳລັບການຄົ້ນຫານີ້ ຜົນໄດ້ຮັບຫຼາຍເກີນໄປ ລອງປ່ຽນການຄົ້ນຫາຂອງທ່ານເພື່ອຈຳກັດຜົນລັບໃຫ້ແຄບລົງ. - ສ້າງໃໝ່ - ທ່ານຕ້ອງປ້ອນຢ່າງໜ້ອຍ %s ຂອງຄຸນລັກສະນະເຂົ້າເພື່ອຄົ້ນຫາ ຕົກລົງ ເລີ່ມການຄົ້ນຫາເພື່ອຊອກຫາອັນໃດນຶ່ງ%s ທ່ານສາມາດຄົ້ນຫາຫຼືສ້າງໃຫມ່%s ທ່ານຕ້ອງການໝາຍແບບຟອມນີ້ວ່າສຳເລັດແລ້ວຫຼືບໍ? ຖ້າທ່ານອອກດຽວນີ້, ການປ່ຽນແປງຈະຖືກຍົກເລີກ. + ທ່ານມີຂໍ້ຜິດພາດໃນບາງຂໍ້ຄວາມ.\nທ່ານຕ້ອງການກວດເບິ່ງຄືນບໍ? ເປີດຄືນໃໝ່ ບັນຊີຖືກປິດ ບັນຊີຂອງທ່ານຖືກປິດການນຳໃຊ້ໂດຍຜູ້ຮັກສາລະບົບ. ວັນທີປະຈຸບັນນີ້: %s ອຸປະກອນ: %s Os ລຸ້ນ: %s - ເປິດ %s ລາຍລະອຽດ - %s ລາຍລະອຽດ ກຳລັງອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ %s... - ຂໍ້ຄວາມໄດ້ສົ່ງແລ້ວ. ກຳລັງລໍຖ້າໃຫ້ເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່ຕອບກັບ. - ສົ່ງຂໍ້ຄວາມແລ້ວ. ກຳລັງອັບເດດສະຖານະ. - ບໍ່ໄດ້ສົ່ງຂໍ້ຄວາມ ສົ່ງຂໍ້ຄວາມກັບ ທ່ານໄດ້ສົ່ງຂໍ້ຄວາມແລ້ວບໍ? - ກຳລັງລໍຖ້າການຢຶນຢັນຄູ່ມື - ອັບເດດເຫດການໃຫ້ຄືກັນ - ອັບເດດຫົວໜ່ວຍທີ່ຕິດຕາມໃຫ້ຄືກັນ - ອັບເດດຊຸດຂໍ້ມູນໃຫ້ຄືກັນ - ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນສຳເລັດແລ້ວ - ການ​ອັບເດດຂໍ້ຄວາມໃຫ້ຄືກັນ ແມ່ນ​ມີ​ຢູ່​ແຕ່​ມີ​ການ​ເຂົ້າ​ເຖິງ​ສໍາ​ລັບ​ການ​ບັນ​ທຶກ​ຂອງ​ແຕ່​ລະ​ລາຍການ ກຳລັງດາວໂຫຼດຮູບພາບ... - ລາຍລະອຽດຂອງສາຍງານ - ລະຫັດຜ່ານຜິດພາດ ຈຳເປັນຕ້ອງອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ ມີຂໍ້ຜິດພາດໃນການອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນແລ້ວ @@ -833,9 +516,6 @@ ສົ່ງ Refresh ບໍ່​ແມ່ນ​ຕອນ​ນີ້ - - %s ຂໍ້ຜິດພາດ - ອັບເດດຊັອບແວ ດາວໂຫຼດດຽວນີ້ ກວດສອບເພື່ອປັບປຸງ @@ -856,7 +536,6 @@ ປ້ອນ%1$s ຂັ້ນຂອງສາຍງານ ເຫດການ - ​ຢ່າງ​ຕ່ຳ ມີ​ ທັງ​ໝົດ 8 ຄຳ ໃນ​ນັ້ນ​ຕ້ອງ​ມີ​ໂຕ​ໃຫ​ຍ່ 1 ໂຕ ແລະ ໂຕ​ເລກ 1 ໂຕ ສະແດງໃຫ້ເຫັນຫຼາຍຂຶ້ນ ສະແດງໃຫ້ເຫັນໜ້ອຍລົງ ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ @@ -864,4 +543,11 @@ ສະແດງຄຳອະທິບາຍ ເຊື່ອງຄຳອະທິບາຍ ໂອນ - + + %s ຂໍ້ຜິດພາດ + + + + ມີ%s ສາຍງານທີ່ມີການຕັ້ງຄ່າສະເພາະ + + diff --git a/app/src/main/res/values-my/strings.xml b/app/src/main/res/values-my/strings.xml index 8b5fedc6759..bce5f17f333 100644 --- a/app/src/main/res/values-my/strings.xml +++ b/app/src/main/res/values-my/strings.xml @@ -1,107 +1,77 @@ အမှား + + Events - ပြီးဆုံးသည် - အသစ်ထည့်ခြင်း - ထပ်ထည့်သည် - အသစ်ချိန်းဆိုခြင်း + ညွှန်းပို့ခြင်း - အသုံးပြုသူအမည် - လျှို့ဝှက်နံပါတ် ပြန်လည်အသုံးပြုနိုင်ရန်သိမ်းဆည်းခြင်း + နေ့စဉ် အပတ်စဉ် လစဉ် နှစ်စဉ် ရက်စွဲ ကိုရွေးချယ်ပါ + + + Open ဖတ်ရန်အတွက်သာ - ပြီးဆုံးသည် လုပ်ဆောင်နိုင်သည် အမှား + + + - ဖန်တီးသည် ‌နောက်ဆုံးသတင်းကိုရစေခြင်း - ရက်စွဲ ဖွဲ့စည်းပုံယူနစ် - category option အစီရင်ခံစာကာလအပိုင်းအခြား Дата выполнения - နေ့/လ/နှစ် ပယ်ဖျက်သည် ဖျက်ဆီးသည် - အသေးစိတ်ကြည့်ရန် တည်းဖြတ်သည် - - အချက်အလက်မရှိပါ။ ပင်မစာမျက်နှာ ထွက်ရန် ပယ်ဖျက်သည် တည်ဆောက်ထားသည့်အနေအထား အခြေအနေ - - - Дата зачисления - မှတ်စုအသစ်ရေးရန် - လများ - နှစ်များ + + + Incident date - ရှင်းလင်းသည် အချိန်ကာလ + ရှေ့သို့ - သိမ်းဆည်းသည် လက်ခံသည် Events အကြောင်းအရာ - အများဆုံး - လက်ရှိ - သတိ - အမှား သိမ်းဆည်းသည် ပင်မစာမျက်နှာ အကူအညီ data တန်ဖိုးများ အကျဥ်း - အညွှန်းများ - မှတ်စု - အသေးစိတ်များ - အညွှန်းများ - မှတ်စု - မှတ်သားမည် ‌ကျေးဇူးပြု၍စောင့်ပါ သတိ ပိတ်သည် - ပြည့်စုံသည်။ - Open - နေ့စဉ် - အပတ်စဉ် အချိန်ကာလ - အခြေအနေ Last week ယခင်လ အခြား Open ပြန်သွားပါ စီမံချက် - အသေးစိတ်များ - အမည် - Дата зачисления Open ပြည့်စုံသည်။ - အချက်အလက် - Done - သေချာပါသလား။ ရက်စွဲ ပြန်သွားပါ - ပိတ်သည် ဂရပ် ပုံစံများ Programs ဂရပ် ပုံစံများ @@ -113,11 +83,6 @@ ယခင်နှစ် အကြောင်းပြန်ကြားချက် အညွှန်းများ - တန်ဖိုး - တန်ဖိုးသည် ကိန်းဂဏန်းသာ ဖြစ်ရမည်။ - ရနိုင်သည် - မသိပါ ပို့သည် Programs - မဖြစ်မနေလုပ်ရန်မလိုပါ။ diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index cad1a18384a..b853f6ebaaa 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1,58 +1,26 @@ Dhis2 - dhis - 2 Feil - - Feil - Feil - - Feil server URL - Feil opplysninger - Uventet feil - Intern serverfeil - ingen verdi Det er ingen flere reserverte verdier. Kontakt din systemadministrator - Det er ingen flere reserverte verdier. Koble til nettverket for å fylle på dem - feil Noe gikk galt - Synkroniser nå - Synkroniserer med server - Synkroniserer nå - Metadata Hendelser - Sporede enheter - - Fullført - Synkronisering fullført Synkroniseringsfeil En feil har oppstått under synkronisering. Vennligst prøv på nytt - ADVARSEL: Mesteparten av dine data er synkroniserte men noen felter presenterte konflinkter. Synkroniser din konfigurasjon og prøv igjen. Hvis advarselen vedvarer, sjekk deres synkroniseringsstatus: programmer, TEI\'s og hendelser med konflikter er merket som @. - %sresultater funnet - Legg til ny - Legg til - Planlegg ny Henvisning - Server URL - Brukernavn - Passord - Logg inn - Lås opp sesjonen Synkroniserer konfigurasjonen din Dataene dine vil snart bli synkronisert Synkroniserer dataene dine Din konfigurasjon er klar Dine data er klare - Synkronisering av konfigurasjonen mislyktes - Synkronisering av data mislyktes Kontogjenoppretting + Daglig Ukentlig @@ -69,128 +37,78 @@ Legg inn tekst Legg inn lang tekst Legg inn nummer - Legg inn tall Legg inn et negativt tall Legg inn et positivt tall eller null Legg inn positivt tall - Filteralternativ Velg dato - Endre kalender - Programmet er fullført - Programmet er inaktivt Åpne Hendelse fullført Skrivebeskyttet - Gjør ferdig og fullfør Fullført Kansellert - Aktiver Aktiv Synk Å sende Å oppdatere - Å slette Feil - SYNK - SEND - SYNK SMS - Ferdigstilling - Opprett Oppdater - Dato Org enhet Det er ingen åpne organisasjonsenheter for den valgte datoen. Vennligst velg en annen dato eller organisasjonsenhet. - Kategorivalg Breddegrad Lengdegrad - Ny hendelse - En gang - Permanent Rapportperiode - Kat alternativ - Hvis du setter en verdi, så trenger hele raden alle verdier for å fullføre datasettet. - Noen verdier som er påkrevd mangler. Du må sette obligatoriske verdier for å fullføre datasettet. - Det mangler obligatoriske felt. Vennligst fyll dem for å fullføre datasettet. - Verdien er lagret! - Det er ingen tilgjengelige perioder å velge. Forfallsdato Kat kombo - DD/MM/ÅÅÅÅ Avbryt Slett - Totalt Siste hendelsesdato Ingen alternativer er tilgjengelige Aktive programmer - Programmer å registrere Registrere - Siste besøk: - Bruk - Se detaljer - VIS MER Rediger - Oppfølging - Oppfølging er aktivert - Oppfølging er deaktivert Deaktiver Dette programmet tillater ikke flere hendelser - Ingen data hendelser Hjem - App Info Logg ut - Blokker sesjon - 1 minutt - 15 minutt 30 minutt 1 time 6t 1 dag (Standard) 12t - Synkroniser data automatisk hver: Manuelt - Synkroniser konfigurasjon automatisk hver: 1 uke Gjeldende synkroniseringsparametre Tilbakestill til standard + Databasen vil bli slettet. Du vil bli spurt om å logge inn igjen. Aksepter Avbryt SYNK data nå SYNK konfigurasjon nå Innstillinger - Siste synk på: %s - Siste synk på: %s Fyll på - SMS modul: SMS inngangsport SMS resultat sender - sekunder - nummer - nummer Aktiver innsending av SMS Vent på SMS resultatsvar - - Det er %s program med spesifikke innstillinger - Det er %s programmer med spesifikke innstillinger - Begrensede innstillinger Globalt etter program @@ -208,15 +126,11 @@ Denne loggen inneholder alle feil som er forårsaket av synkronisering av data og metadata. Slett lokaldata Alle Capture APP data er lagret i din enhet og vil bli slettet. Data som ikke er synkronisert til server vil bli borte. - Resett app data & konfigurasjon - Din konfigurasjon og App data lagret på enheten vil bli slettet. Du vil bli bedt om å logge inn på nytt, og data som ikke er synkronisert til serveren vil bli borte. SMS innstillinger Sjekk og rediger parametre relatert til SMS inngangsport Status - Registreringsdato - Søk @@ -225,11 +139,6 @@ Skriv nytt notat tøm Legg til notat - Reverser - Aktivert - Obligatoriske felter mangler - Noen obligatoriske felt mangler. Hvis du vil gå tilbake nå, all lagret informasjon vil bli slettet. - Noen obligatoriske felt mangler. Hvis du vil gå tilbake, vennligst fyll inn dem. Dine data er ikke lagret Hvis du går tilbake nå, vil all informasjon som har blitt lagt inn bli slettet. Slett og gå tilbake @@ -237,29 +146,15 @@ Koordinater for - Seksjonliste - %sspørsmål av %ser fullført - %sav %sfullført - Fullfør og lukk - Dager - Måneder - År - Uker Forekomstdato - lokale resultater - Tilkoblede resultater Tidligere programmer Du har ikke tilstrekkelig tillatelse for å utføre denne handlingen - Legg til som... - Fjern Periode - (%d) Org Enhet - Nivå %d Velkommen til dhis2 Her kan du se dine aktiviteter Du kan bruke et filter... @@ -268,16 +163,11 @@ Sjekk andre handlinger i hovedmenyen - Opprett en relasjon Neste Du må legge inn minst %dav attributtene for å foreta et søk - Du må legge inn minst en av attributtene for å foreta et søk - Det er mer enn%dresultater. Vennligst legg inn flere søkekriterier - Skriv inn søkekriterier Importer QR data - Lagre OK Identifikasjon Egenskaper @@ -289,32 +179,20 @@ ingen data Ugyldig QR kode Denne attributten kan ikke legges til - Denne registreringen kan ikke legges til Om Programmer å registrere - velg alle - tøm valg - Det er ikke noe informasjon for de valgte filtre. Vennligst prøv å endre dem. Del Del QR Max Nåværende TEI TEIs - Begrens etter organisasjonsenhet - Begrens etter program - Programliste Resett app data & konfigurasjon - Det er ikke mulig å legge til relasjon Sjekk dette! - Advarsel - Feil Lagre Reserverte verdier - Vil du legge til mer data fra QR? - App versjon: %s SDK versjon: %s @@ -325,169 +203,58 @@ Utviklet og vedlikeholdt av DHIS2-prosjektet, del av Universitetet i Oslo, Institutt for informatikk. For mer informasjon eller spørsmål om denne applikasjonen, vennligst skriv tilandroid@dhis2.org - Passord mangler. Vennligst fyll det feltet - Brukernavn mangler. Vennligst fyll inn det feltet. - Denne serveren kjører en dhis versjon som er forskjellig fra 2.29. Oppdater din serverversjon eller bruk testmiljøet for å fortsette. - Denne serveren kjører en dhis versjon forskjellig fra %s. Oppdater serverversjonen din eller bruk testmiljøet for å fortsette. - Noe gikk galt. Vennligst sjekk at server URL, brukernavn og passord er korrekt og prøv igjen. - Serveren kunne ikke fullføre denne forespørselen - Det har oppstått et problem ved pålogging til serveren. - Påloggingsfeil - Obligatoriske felter mangler - Uventet feil ved søking online - Feil under analyse av resultater - Søk online returnerte en feil - Noe gikk galt på serveren Velg et program for å aktivere registrering Du har ikke tilstrekkelig tillatelse - Denne funksjonaliteten er ikke klar ennå. - Feil ved henting av relasjonsetikett Alle %s Dette er ikke et gyldig telefonnummer - Dette er ikke en gyldig e-post - Bare negative siffer er tillatt Bare positive siffer eller null er tillatt - Bare positive siffer er tillatt - Bare verdier fra 0 til 1 er tillatt - Bare verdier fra 0 til 100 er tillatt - Velg datoer for å filtrere Velg et alternativ - Problemer rapportert - Generer en ny hendelse - Vil du opprette en ny hendelse? Hendelser er fullført Alle hendelser i dette programmet er fullført. Vil du lukke programmet også? - Vil du fullføre registreringen også? Sjekk Hjem EKSEMPEL - Legg til moduler Vis hjelp - Synkroniser dine data... - ... din konfigurasjon... - ...sett hvor mange oppføringer som skal lastes ned fra server... - Sjekk og administrer hvor mange reserverte verdier som står igjen - Sjekk om tilkobling til serveren mislyktes - Frigjør diskplass - ...eller slett alt og start på nytt. - Finn dine forekomster både på din lokale enhet og på onlineserver. - Du kan bytte til andre programmer - Søk eller legg til nye forekomster. Du må søke før du kan legge til en ny! - Klikk her for å tømme feltene og starte et nytt søk. Dette er dashbord for forekomster Se forekomstprofil informasjon... - ....Del informasjon via QR eller SMS.... - ...Eller merk forekomsten for oppfølging - Klikk for å opprette eller planlegge en hendelse eller referer til en annen organisasjonsenhet. Her vil du se alle hendelser. Klikk for å se en hendelsedetalj. Naviger gjennom menyen for å legge til relasjoner, se indikatorer eller skrive notater. - Klikk for å se alle registreringer av din forekomst eller opprett en ny. - Klikk for å slette denne hendelsen. Denne handlingen kan ikke angres. - Fullfør hendelsen. - Alle hendelser relatert til dette programmet vil bli opplistet her. - Klikk for å legge til ny hendelse Fyll inn all data for å opprette hendelsen Klikk for å sjekke status for fullføring av hendelsen. - Klikk for å fullføre eller gjenåpne en hendelse - Les og lagre informasjon fra andre enheter via QR - Synkroniser dine data med serveren - Blokk sesjon med en 4 sifret kode. Ikke glem koden din! - Logg ut for å endre bruker på server. Du vil kunne logge inn igjen selv om det ikke er noen internettilkobling. - %s: relasjoner Det er ingen hendelser registrert, klikk \"+\" for å legge til en ny. - Det er ingen hendelser registrert. Dataverdier Info Kunne ikke lese denne ID-en Slett lokaldata Oversikt - Relasjoner - Indikatorer - Analyser Det er ingen indikatorer for dette programmet. - Notater - Kunne ikke fullføre - Noen felt har feil. Vennligst sjekk dem for å kunne fullføre Alle registreringer Vis dashbord med alle aktive programmer - Åpne synk feillogg Synk feillogg Del med... - Din konfigurasjons synk har mislyktes. Det kan være feil på serveren din. Klikk her for mer informasjon. - FORRIGE - NESTE - Detaljer - Indikatorer - Notater - - Fullfør senere - - Fullfør - Felt som inneholder feil - Noen felter inneholder feil. De har ikke blitt lagret. Vennligt, sjekk dem. Verdien skal være unik, et sammentreff ble funnet. Det har ikke blitt lagret. Vennligst sjekk det. - Et sammentreff i et unikt felt ble funnet. Vennligst sjekk skjema for å lagre registrering. Vennligst vent Hopp over - Planlegg på nytt Det er ingen informasjon for denne skjermen - Forfalt - Det er ingen programregel som ikke er støttet. Vennligst sjekk dokumentasjonen - Ingen info for dette feltet - Tomme opplysninger - For å kunne logge inn ved bruk av fingerskanner må du først logge inn på standard måte - Biometrisk sikkerhet - Tabeller - Tabell - https:// Ikke synkroniserte data vil bli bort. Vil du virkelig fortsette? - Aksepter Data kunne ikke slettes. Vennligst prøv på nytt. Lokal data ble slettet. - Minst en organisasjonsenhet må merkes - Send data - Hjelp DHIS2 å forbedre denne appen ved å dele feillogger assosiert med din bruker ID. - Sjekk felter - Du mistet internettilkoblingen mens du utførte din konfigurasjonssynk. Vennligst prøv igjen senere - Enig - Fortsett - Server URL - Har du glemt koden? - Hva vil du gjøre for denne hendelsen? - SLUTT %sikke funnet Oppføringen du prøver å gå inn på er ikke tilgjengelig. Vil du søke etter den? Synkronisert ved SMS Advarsel Relasjon - Det er ingen konflikter for dette programmet - Nettverkstilkobling er ikke tilgjengelig, men det ser ut som det er telefonservice - du kan bruke SMS. - Internettilkoblingen er ikke tilgjengelig Lukk Nå kan du sjekke synk informasjon ved å klikke her. - Denne dataen er lagret kun på din enhet, det er ikke synkronisert med serveren. - Du er oppdatert! Alle dataene dine er sendt til serveren. Du kan ikke relatere en oppføring med seg selv - Fjern blokkering på sesjonen - Fullfør - Åpne - Verditypen er ikke støttet Endre kalendervisning - 15 minutter - Hver time - Daglig - Ukentlig - Uke - Synkroniseringsinnstillinger: %s Oppføringen du prøver å gå inn på har ingen registrering. Du kan ikke gå til dashbord. Formateringsfeil Du har ikke autoritet til å slette denne oppføringen - Dette er ikke en gyldig URL Justerer fra... - Mottakernummer Aksepterer du å sende %dmeldinger? Sending %1$d/%2$d Bekreftelse av ventet mengde - %d @@ -505,51 +272,29 @@ Ingen inngangsport satt Bruker er ikke innlogget SMS modul er deaktivert - Enda en SMS-innsending pågår SMS-modul ikke startet, ikke funnet obligatoriske metadata - Fullført SMS-telling ikke akseptert SMS innsending er startet Data lest og konvertert. Innsendings ID: %d - Feil ved sending av SMS innsending SMS TEI registrering og registrering SMS hendelseinnsending SMS datasett innsending - Starter - Pågående - Sendt - Mislyktes - Starter synkronisering. Synkroniserer... - Synkroniseringen er fullført. - En feil har oppstått under synkronisering. - Det har skjedd en feil under synkroniseringen. Klikk her for å sjekke feillrapport - Avbrutt synkronisering. Kopiert - Bilde er valgt - Vis i kart - Vis liste - %ser obligatorisk men har ikke blitt oppgitt. - Polygon oppfanget Polygon - Fingeravtrykk - Vennligst, plasser fingeren din på fingeravtrykkleseren. Sikkerhet Av sikkerhetsgrunner, så er denne versjonen av appen ikke tillatt til å bli brukt i rotenheter. Vennligst bruk treningsversjon. - Det er noen feil. Vennligst fiks dem før du fortsetter. Filtere som brukes Ingen filter brukt Periode Org. Enhet - Sorterer - Status Synk Tildelt til meg I dag @@ -564,8 +309,6 @@ Fra - Til Annet Når som helst - Synkronisering mislyktes. Sjekk dataverdiene for å prøve å fikse problemet og prøv på nytt. Hvis det fortsatt mislykkes, kontakt din systemadministrator. - Denne data har blitt sent til serveren men det er ikke sikkert den har blitt lagret Åpne Planlegg Forfalt @@ -581,7 +324,6 @@ SMS inngangsport kan ikke være tom Det er ingen registreringer å laste opp Denne konverteringsoppgaven er ikke støttet - Ikke vis igjen, lås skjerm Sett pin Konflikt Endre dato @@ -606,11 +348,7 @@ Velg organisasjonsenhet Legg inn URL Legg inn verdi - Velg bilde - Skann kode - Velg alternativ Reserverte verdier lastet ned per TEI attributt - Reserverte verdier lastet ned per TEI attributt %sreserverte verdier igjen Administrer reserverte verdier Hendelser å laste ned @@ -630,39 +368,19 @@ Ikke synkronisert Synkroniseringsfeil SMS synkronisert - Detaljer - Kommentarer Lagret! - Vil du sjekke datakvaliteten? - Alt ser bra ut - Vil du også fullføre datasettet? - Merk som fullført - Navn - Formel - Fullfør likevel Mangler %s%sfra %s%s Hendelsesstatus - Registreringsdato Det er ingen data. Klikk på \"+\" for å legge til en ny oppføring Det er ingen data. Du har ikke tillatelse til å legge til nye oppføringer. Ingen beskrivelse eller instruksjon gitt Data til gjennomgang - Lagret og fullført! Det er allerede en aktiv registrering Åpne Fullfør Kansellert - Data Den valgte %s kunne ikke lastes ned - Fullført av %s: %s - Oppdatert: %s - Fullfør - Vi kunne ikke sjekke datakvaliteten. Kontakt administratoren din - Er du sikker? - Vil du åpne datasettet på nytt? Dato - Hendelsesdato - %shar ingen offentlige attributter. Velg et program Personvernregler Klikk her for å sjekkepersonvernreglene våre Koordinatverdiene er ikke gyldige @@ -670,13 +388,7 @@ Av sikkerhetsgrunner, så er denne versjonen av appen ikke tillatt til å bli brukt med adb. Vennligst bruk treningsversjonen. Gå tilbake - Lukk app - Flere detaljer - Flere detaljer - Lukk Kopier til utklippstavle - Kopiert til utklippstavle - Informasjon om feil Det er planlagte arrangementer som ikke vil bli planlagt på nytt Diagrammer Vis @@ -705,17 +417,9 @@ Siste regnskapsår Tilbakemelding Indikatorer - Verdi - Fullført! Dette programmet er beskyttet - Feil opplysninger - Verdien må være et heltall - Verdier - Tilgjengelig - Ukjent Språk For mange resultat - Opprett ny Ok Synkronisert Synkroniserer @@ -732,11 +436,14 @@ Programfase hendelse Tidslinje - Valgfri Vis mer Vis mindre Synk Vis beskrivelse Skjul beskrivelse Overfør - + + Det er %s program med spesifikke innstillinger + Det er %s programmer med spesifikke innstillinger + + diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 2eb92cf551b..4463bcc4e0e 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -1,26 +1,22 @@ - थप्नुहोस् दैनिक स्वीकार खोज + + खोल्नुहोस् - - सिर्जना गर्नुहोस् - मिति रद्द गर्नुहोस् स्वीकार रद्द गर्नुहोस् स्थिती + + खोज अबधि - स्वीकार - खोल्नुहोस् - दैनिक अबधि - स्थिती मलाई निर्दिष्ट गरिएको आज हिजो diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fcc4441995e..10c26f41b09 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,62 +1,26 @@ Dhis2 - dhis - 2 Fout - - Fout - Fouten - - Verkeerde server-URL - Verkeerde inloggegevens - Onverwachte fout - Interne Server Fout - geen waarde Er zijn geen gereserveerde waarden meer. Neem contact op met uw systeembeheerder - Er zijn geen gereserveerde waarden meer. Maak verbinding met het netwerk om ze bij te vullen - fouten Er is iets fout gegaan - Synchroniseer nu - Synchroniseren met server - Nu synchroniseren - Metagegevens Gebeurtenissen - Getraceerde entiteiten - - Afgerond - Synchronisatie voltooid Synchronisatiefout Er is een fout opgetreden tijdens de synchronisatie. Probeer het opnieuw. De synchronisatie is voltooid, maar we hebben geen serverbevestiging ontvangen voor alle records. Die gegevens zijn nog steeds gemarkeerd als \"offline\" in de app. We raden aan om de synchronisatie opnieuw te proberen. - WAARSCHUWING: de meeste van uw gegevens zijn gesynchroniseerd, maar sommige velden leverden problemen op. Synchroniseer uw configuratie en probeer het opnieuw. Als de waarschuwing aanhoudt, controleer dan hun synchronisatiestatus: programma\'s, TEI\'s en gebeurtenissen met conflicten zijn gemarkeerd als @. - Er is een fout opgetreden en de server heeft niet gereageerd. Neem contact op met uw beheerder. - - %s resultaten gevonden - Nieuw toevoegen - Toevoegen - Nieuw schema Verwijzing - Server-URL - Gebruikersnaam - Wachtwoord - Log in - Sessie ontgrendelen Synchroniseer uw configuratie Uw gegevens worden binnenkort gesynchroniseerd synchroniseer uw gegevens Uw configuratie is klaar Uw gegevens zijn klaar - Configuratiesynchronisatie mislukt - Gegevenssynchronisatie mislukt Accountherstel - Er is een netwerkverbinding nodig om uw account te herstellen. Dagelijks @@ -74,127 +38,77 @@ Tekst invoeren Voer lange tekst in Voer nummer in - Voer een geheel getal in Voer een negatief geheel getal in Voer een positief geheel getal of nul in Voer een positief geheel getal in - Filter opties Kies datum - Kalender wijzigen - Programma voltooid - Programma inactief Open Evenement voltooid Alleen lezen - Afwerken en voltooi Voltooid Geannuleerd - Activeren Actief Synchroniseren Versturen Updaten - Verwijderen Fout - SYNCHRONISEREN - VERSTUREN - SYNCHRONISEERDE SMS - Voltooiing - Creëren Bijwerken - Datum Organisatieëenheid Er zijn geen open organisatie-eenheden voor de geselecteerde datum. Selecteer een andere datum of een andere organisatie-eenheid. - Categorie optie Breedtegraad Lengtegraad - Nieuw evenement - Een keer - permanent Verslagperiode - Optie computer ondersteund vertalen - Als u een waarde instelt, heeft de hele rij alle waarden nodig om de gegevensset te voltooien. - Sommige vereiste waarden ontbreken. U moet verplichte waarden instellen om de gegevensset te voltooien. - Er ontbreken verplichte velden. Gelieve deze in te vullen om de dataset compleet te maken. - Waarde is opgeslagen! - Er zijn geen beschikbare periodes om te selecteren. Deadline Katten combinatie - DD/MM/JJJJ Annuleren Verwijder - Totaal Datum meest recente evenement Actieve programma\'s - Programma\'s om in te schrijven Inschrijven - Laatste bezoek: - toepassen - Zie de details - BEKIJK MEER Bewerk - Volg op - Vervolg ingeschakeld - Opvolging uitgeschakeld Deactiveren Dit programma staat niet meer evenementen toe - Geen gegevens evenementen Thuis - App informatie Uitloggen - Sessie blokkeren - 1 minuut - 15 minuten 30 minuten 1 uur 6 uur 1 dag (standaard) 12 uur - Automatische synchronisatiegegevens elke: Handmatig - Automatische synchronisatieconfiguratie elke: 1 week Huidige synchronisatieparameters: Resetten naar standaard + Database wordt verwijderd.\nU wordt gevraagd opnieuw in te loggen. Aanvaarden Annuleren SYNCHRONISATIE gegevens nu SYNCHRONISATIE-configuratie nu Instellingen - Laatste synchronisatie op: %s - Laatste synchronisatie op: %s Bijvullen - SMS-module: SMS-poort SMS resultaat afzender Time-out sms-resultaat (sec) - seconden - nummer - nummer Sms-verzending inschakelen Wacht op de reactie van het sms-resultaat - - Er is een %s-programma met specifieke instellingen - Er zijn %s-programma\'s met specifieke instellingen - Instellingen beperkt Wereldwijd per programma @@ -212,18 +126,13 @@ Dit logboek bevat alle fouten die zijn veroorzaakt door het synchroniseren van uw gegevens en metagegevens. Verwijder lokale gegevens Alle Capture App-gegevens die op uw apparaat zijn opgeslagen, worden verwijderd. Gegevens die niet met de server zijn gesynchroniseerd, gaan verloren. - Reset app-gegevens &-configuratie - Uw configuratie en app-gegevens die op het apparaat zijn opgeslagen, worden verwijderd. U wordt gevraagd om opnieuw in te loggen en gegevens die niet met de server zijn gesynchroniseerd, gaan verloren. SMS-instellingen Controleer en bewerk parameters met betrekking tot de sms-gateway Software-update - Software Twee-factor-authenticatie status - Inschrijvingsdatum - Zoek @@ -244,29 +153,15 @@ Coördinaten voor de - Sectie lijst - %s vragen van %s voltooid - %s van %s voltooid - Voltooien en afsluiten - Dagen - Maanden - Jaren - Weken Incident datum - Lokale resultaten - Online resultaten Eerdere programma\'s U heeft niet de vereiste rechten om deze actie uit te voeren - Toevoegen als… - Verwijder Periode - (%d) Organisatie-eenheid - Niveau %d Welkom bij dhis2 Hier kunt u uw activiteiten zien U kunt een filter toepassen... @@ -275,16 +170,11 @@ Controleer andere acties in het hoofdmenu - Relatie creëren Volgende U moet minimaal %d van de attributen invoeren om een zoekopdracht uit te voeren - U moet ten minste één van de kenmerken invoeren om een zoekopdracht uit te voeren - Er zijn meer dan %d resultaten. Vul a.u.b. verdere zoekcriteria in - Voer uw zoekcriteria in QR-gegevens importeren - Sla op OK Identificatie attributen @@ -296,32 +186,20 @@ geen gegevens Ongeldige QR-code Dit kenmerk kan niet worden toegevoegd - Deze inschrijving kan niet worden toegevoegd Over Programma\'s om in te schrijven - selecteer alles - Verwijder selectie - Er is geen informatie voor de geselecteerde filters. Probeer ze te veranderen. Deel QR delen Max Huidig TEI TEI\'s - Beperken per organisatie-eenheid - Beperk per programma - Programma lijst Reset app-gegevens &-configuratie - Het is niet mogelijk om deze relatie toe te voegen Check dit! - Waarschuwing - Fout Sla op Gereserveerde waarden - Wilt u meer gegevens uit QR toevoegen? - App-versie: %s SDK-versie: %s @@ -332,128 +210,42 @@ Ontwikkeld en onderhouden door het DHIS2 -project, onderdeel van de Universiteit van Oslo, afdeling informatica . Voor meer informatie of vragen over deze toepassing kunt u schrijven naar android@dhis2.org - Wachtwoord ontbreekt. Vul dit veld in. - Gebruikersnaam ontbreekt. Vul dit veld in. - Deze server draait een andere versie dan 2.29. Werk uw serverversie bij of gebruik de testomgeving om verder te gaan. - Deze server draait een andere versie dan %s. Werk uw serverversie bij of gebruik de testomgeving om verder te gaan. - Er is iets fout gegaan. Controleer of de server-URL, gebruikersnaam en wachtwoord correct zijn en probeer het opnieuw. - De server kan dit verzoek niet voltooien. - Er is een probleem opgetreden bij het inloggen op de server. - Login fout - Verplichte velden ontbreken - Onverwachte fout tijdens online zoeken - Fout bij het parseren van resultaten - Online zoeken leverde een fout op - Er is iets misgegaan in de server Selecteer een programma om inschrijven mogelijk te maken U heeft niet de vereiste toestemming - Deze functionaliteit is nog niet gereed. - Fout bij ophalen relatielabel Alle %s dit is geen geldig telefoonnummer - Dit is geen geldig e-mailadres - Alleen negatieve getallen zijn toegestaan Alleen positieve getallen of nul toegestaan - Alleen positieve getallen zijn toegestaan - Alleen waarden van 0 tot 1 zijn toegestaan - Alleen waarden van 0 tot 100 zijn toegestaan - Selecteer datums om te filteren Selecteer optie - Probleem gemeld - Nieuw evenement genereren - Wil je nog een evenement aanmaken? Evenementen voltooid Alle evenementen in dit programma zijn voltooid. Wilt u het programma ook afsluiten? - Wil je de inschrijving ook afronden? Controleer Thuis VOORBEELD - Widget toevoegen Hulp laten zien - Synchroniseer uw gegevens... - ...uw configuratie... - ...stel in hoeveel records van de server moeten worden gedownload... - Controleer en beheer hoeveel gereserveerde waarden er nog over zijn - Controleer of een verbinding met de server is mislukt - Maak wat schijfruimte vrij - …of verwijder alles om opnieuw te beginnen. - Vind uw entiteiten zowel op uw lokale apparaat als op de online server. - U kunt overschakelen naar andere programma\'s - Zoek of voeg nieuwe entiteiten toe. U moet zoeken voordat u een nieuwe kunt toevoegen! - Klik hier om de velden op te schonen en de zoekopdracht opnieuw te starten. Dit is het entiteitsdashboard Profielgegevens van entiteiten bekijken... - …Deel informatie via QR of SMS… - …Of markeer de entiteit voor opvolging - Klik om een evenement te maken of te plannen of om naar een andere organisatie-eenheid te verwijzen. Hier zie je alle evenementen. Klik om een gebeurtenisdetail te zien. Navigeer door het menu om relaties toe te voegen, indicatoren te zien of notities te schrijven. - Klik om alle inschrijvingen van uw entiteit te zien of maak een nieuwe aan. - Klik om dit evenement te verwijderen. Deze actie kan niet ongedaan gemaakt worden. - Voltooi het evenement. - Alle evenementen gerelateerd aan dit programma worden hier vermeld. - Klik om een nieuw evenement toe te voegen Voer alle gegevens in om het evenement aan te maken Klik om de voltooiingsstatus van het evenement te controleren. - Klik om een evenement te voltooien of opnieuw te openen - Lees en bewaar informatie van andere apparaten via QR - Synchroniseer uw gegevens met de server - Blokkeer sessie met een 4-cijferige code. Vergeet je code niet! - Log uit om gebruiker of server te wijzigen. U kunt opnieuw inloggen, ook als er geen internetverbinding is. - %s: relaties Er zijn geen evenementen geregistreerd, klik op \"+\" om een nieuwe toe te voegen. - Er zijn geen evenementen geregistreerd. Gegevens waarden Info Kan dit ID niet lezen Verwijder lokale gegevens Overzicht - Relaties - Indicatoren - Analyse Er zijn geen indicatoren voor dit programma. - Notities - Kan niet voltooien - Kan niet opslaan - Er staan waarschuwingen in het formulier - Wat wil je doen? - Sommige velden bevatten fouten. Controleer ze alstublieft om ze te kunnen voltooien Alle inschrijvingen Toon dashboard met alle actieve programma\'s - Open logboek met synchronisatiefouten Fout logboek synchroniseren Delen met... - Uw configuratiesynchronisatie is mislukt. Er kunnen fouten in uw server zitten. Klik hier voor meer informatie. - VORIGE - VOLGENDE - Details - Indicatoren - Notities - - Voltooi later - - Finish - Velden met fouten - Sommige velden bevatten fouten. Ze zijn niet opgeslagen. Controleer ze alsjeblieft. De waarde moet uniek zijn, er is een toeval gevonden. Het is niet opgeslagen. Controleer het alstublieft. - Er is een toeval gevonden in een uniek veld. Controleer het formulier om de inschrijving op te slaan. Even geduld alstublieft Overslaan - Opnieuw plannen Er is geen informatie voor dit scherm - Verlopen - Er is één programmaregel die niet wordt ondersteund. Controleer de documentatie - Geen info voor dit veld - Lege inloggegevens - Om in te kunnen loggen met de vingerafdrukscanner moet je eerst de standaardmanier gebruiken - Biometrische beveiliging - Tafels - Tafel - https:// Niet-gesynchroniseerde gegevens gaan verloren. Wil je echt doorgaan? - Aanvaarden Gegevens konden niet worden verwijderd. Probeer het opnieuw. Verwijderen van lokale gegevens is voltooid. Er moet ten minste één organisatie-eenheid worden geselecteerd @@ -466,6 +258,7 @@ Mee eens zijn Doorgaan Server-URL + Voer de DHIS 2-server-URL voor uw organisatie in zoals verstrekt door uw systeembeheerder.\n\nU kunt ook een QR-code scannen met de URL van uw server Ben je je code vergeten? Wat zou je willen doen voor dit evenement? EINDE @@ -476,33 +269,16 @@ Uploaden Relatie Per sms verzonden - Er zijn geen conflicten voor dit programma - Netwerkverbinding is niet beschikbaar, maar het lijkt erop dat er telefoondiensten zijn - u kunt sms gebruiken. - Netwerkverbinding is niet beschikbaar. Sluit Nu kunt u de synchronisatie-informatie controleren door hier te klikken - Deze gegevens worden alleen op uw apparaat opgeslagen, ze worden niet met de server gesynchroniseerd. - Je bent up-to-date! Al uw gegevens worden naar de server gestuurd. U kunt een record niet met zichzelf in verband brengen - Sessie deblokkeren - Compleet - Open - Niet-ondersteund waardetype Agendaweergave wijzigen - 15 minuten - Elk uur - Dagelijks - Wekelijks - Week - Synchronisatie-instelling: %s Het record dat u probeert te openen heeft geen inschrijving. U heeft geen toegang tot het dashboard. Formatteringsfout Verwijder %s U bent niet bevoegd om dit record te verwijderen - Dit is geen geldige URL Formulier aanpassen… - Ontvanger nummer Accepteert u het verzenden van %d-berichten? Verzenden van %1$d/%2$d Bevestiging wachtend bedrag - %d @@ -520,51 +296,29 @@ Geen gateway ingesteld Gebruiker niet ingelogd SMS-module is uitgeschakeld - Er is nog een SMS-inzending aan de gang SMS-module niet gestart, vereiste metadata niet gevonden - Voltooid Aantal sms\'jes niet geaccepteerd Sms-verzending gestart Gegevens gelezen en geconverteerd. Inzendings-ID: %d - Fout bij verzenden van sms-inzending SMS TEI registratie en inschrijving Sms-gebeurtenis indienen Indiening van een sms-gegevensset - Beginnend - Bezig - Verstuurd - Mislukt - Synchronisatie starten. Synchroniseren... - Synchronisatie voltooid. - Er is een fout opgetreden tijdens de synchronisatie. - Er is een fout opgetreden tijdens de synchronisatie. Klik hier om foutlogboeken te controleren - Geannuleerde synchronisatie. Gekopieerd - Afbeelding geselecteerd - Toon op kaart - Toon lijst - %s is verplicht maar is niet verstrekt. - Polygoon vastgelegd Veelhoek - Vingerafdruk - Steek alstublieft uw vinger in uw vingerafdruklezer. Beveiliging Om veiligheidsredenen mag deze versie van de app niet worden gebruikt op geroote apparaten. Gebruik de trainingsversie. - Er zijn enkele fouten. Corrigeer ze alstublieft voordat u verder gaat. Filters van toepassing Geen filters toegepast Periode Org. Eenheid - Sorteren - status Synchroniseren Toegewezen aan mij Vandaag @@ -579,8 +333,6 @@ Van - naar Anders Altijd - Synchronisatie mislukt. Synchronisatie mislukt. Controleer de gegevenswaarden om te proberen het probleem op te lossen en probeer het opnieuw. Neem contact op met uw systeembeheerder als het blijft mislukken - Deze gegevens zijn naar de server verzonden, maar het is niet gegarandeerd dat ze zijn opgeslagen Open Schema Verlopen @@ -596,7 +348,6 @@ SMS-gateway mag niet leeg zijn Er zijn geen inschrijvingen om te uploaden Deze convertTask wordt niet ondersteund - Vergrendelscherm niet meer weergeven Speld instellen Conflict Verander de datum @@ -621,11 +372,7 @@ Kies organisatie-eenheid URL invoeren Voer waarde in - Kies afbeelding - Scancode - Kies optie Gereserveerde waarden gedownload per TEI-attribuut - Gereserveerde waarden gedownload per TEI-attribuut %s gereserveerde waarden over Beheer gereserveerde waarden Evenementen om te downloaden @@ -645,55 +392,29 @@ Niet gesynchroniseerd Synchronisatiefout SMS gesynchroniseerd - Details - Opmerkingen Opgeslagen! - Wilt u de datakwaliteit controleren? - Alles ziet er goed uit! - Wil je ook de dataset compleet maken? - Markeer als voltooid - Naam - Formule - In ieder geval compleet Ontbrekende %s %s van %s %s Gebeurtenisstatus - Inschrijvingsdatum Er zijn geen gegevens. Klik op \"+\" om een nieuw record toe te voegen Er zijn geen gegevens. U heeft geen rechten om nieuwe records toe te voegen. Geen beschrijving of instructie verstrekt Gegevens om te bekijken - Opgeslagen en voltooid! Er is al een actieve inschrijving Open Compleet Geannuleerd - Gegevens De geselecteerde %s kon niet worden gedownload. - Aangevuld door %s: %s - Bijgewerkt: %s - Klaar - We konden de gegevenskwaliteit niet controleren. Neem contact op met uw beheerder - Weet je het zeker? - Wilt u de dataset opnieuw openen? Datum - Evenement datum - %s heeft geen openbare attributen. Selecteer een programma Privacybeleid Klik hier om ons privacybeleid te bekijken Coördinatenwaarden zijn niet geldig + U kunt een %s van andere organisatie-eenheden niet inschrijven in dit programma omdat deze beveiligd is.\nNeem contact op met uw beheerder. Dit veld is verplicht Om veiligheidsredenen mag deze versie van de app niet worden gebruikt met adb. Gebruik de trainingsversie. Ga terug - App sluiten - Meer details - Meer details - Sluit Kopieer naar klembord - Gekopieerd naar het klembord - Fout informatie We hebben een fout gevonden bij het synchroniseren met uw server. Deel de fout indien mogelijk met uw systeembeheerder. - We kunnen de sessie met uw gebruiker niet starten Er zijn geplande evenementen die niet opnieuw worden gepland Grafieken Lijst @@ -741,46 +462,27 @@ Feedback Indicatoren Waarde + Cameratoestemming is geweigerd.\Je moet deze inschakelen om deze functie te gebruiken. Grafieken en indicatoren Neem foto - Kies uit gallerij - Klaar! Dit programma is beveiligd Omschrijf hier de reden - Verificatie - De opgegeven server-URL is geen dhis-instantie - Verkeerde inloggegevens - Onbekende server - Waarde moet een geheel getal zijn De sessie is verlopen Uw huidige sessie is verlopen. Om veiligheidsredenen moet u uw inloggegevens opnieuw opgeven - Waarden Week %d %s tot %s Niet meer laten zien Functieconfiguratie Weergeven als radar - Kan coördinaattype niet instellen Gevolgd %s - %s kan niet worden verwerkt als een datum Dit veld kan niet worden bijgewerkt. Probeer het opnieuw - Editie - Beschikbaar Deze gegevens kunnen niet worden bewerkt omdat ze als voltooid zijn gemarkeerd Deze gegevens kunnen niet worden bewerkt omdat de bewerkingstijd is verstreken U heeft geen toestemming om deze gegevens te bewerken Deze gegevens zijn niet bewerkbaar omdat de organisatie-eenheid gesloten is U heeft geen toestemming om deze gegevens te bewerken - U kunt gegevens van deze organisatie-eenheid niet bewerken - Zoeken resetten + U kunt gegevens van deze organisatie-eenheid niet bewerken De online zoekopdracht leverde het volgende bericht op: - Waarschuwing programmaregels - Er is een configuratieprobleem dat een lus in de regels veroorzaakt. Neem contact op met uw beheerder. - VERFRISSEN - Onbekend - Ververs alle gegevens - U heeft het maximale aantal accounts bereikt (%d). Om een nieuw account toe te voegen, logt u in en verwijdert u een van uw accounts Account verwijderen - Accounts beheren Problemen met configuratie oplossen Taal Tik hier om de taal van de applicatie te wijzigen @@ -798,13 +500,12 @@ Er zijn geen resultaten voor deze zoekopdracht Te veel resultaten Probeer uw zoekopdracht te wijzigen om de resultaten te verfijnen. - Maak nieuw - Het is noodzakelijk om minimaal %s attributen in te voeren om te zoeken OK Start een zoekopdracht om een %s U kunt zoeken of een nieuwe creëren %s Wilt u dit formulier als voltooid markeren? Als u nu afsluit, worden de wijzigingen verwijderd. + U heeft een aantal foutmeldingen.\nWilt u deze bekijken? heropend Account ongebruikelijk gemaakt Uw account is uitgeschakeld door een beheerder. @@ -814,27 +515,10 @@ Huidige datum: %s Apparaat: %s OS-versie: %s - Open %s-gegevens - %s gegevens %s synchroniseren... - SMS verzonden. Wachten op reactie van de server. - SMS bevestigd om de server te bereiken. Status bijwerken - We kunnen niet bevestigen dat de sms de server heeft bereikt. Status bijwerken - SMS verzonden. Status bijwerken. - Sms niet verzonden. - SMS-app openen. Wijzig het bericht alstublieft niet. Sms\'en met Heb je de sms gestuurd? - In afwachting van handmatige bevestiging - Evenementen synchroniseren - Bijgehouden entiteiten synchroniseren - Gegevenssets synchroniseren - Bronnen synchroniseren - Synchronisatie voltooid - SMS-synchronisatie is beschikbaar, maar moet voor elk afzonderlijk record worden geopend Afbeeldingen downloaden... - Programma beschrijving - Verkeerde speld Synchronisatie nodig Synchronisatiefout Gesynchroniseerd @@ -852,19 +536,10 @@ Vernieuwen Niet nu Tik hier om te verkennen - - %s-fout - %s fouten - - - %s waarschuwing - %s waarschuwingen - Software-update Later Download nu Controleer op updates - Controleren op updates Er zijn geen nieuwe updates App versie Er is iets misgegaan met de programmaconfiguratie, we kunnen dit evenement niet laden. Neem contact op met uw systeembeheerder. @@ -887,4 +562,18 @@ %s synchroniseren... Laat beschrijving zien Beschrijving verbergen - + + %s-fout + %s fouten + + + + %s waarschuwing + %s waarschuwingen + + + + Er is een %s-programma met specifieke instellingen + Er zijn %s-programma\'s met specifieke instellingen + + diff --git a/app/src/main/res/values-or/strings.xml b/app/src/main/res/values-or/strings.xml index ce483c05713..d9689d0b17a 100644 --- a/app/src/main/res/values-or/strings.xml +++ b/app/src/main/res/values-or/strings.xml @@ -6,28 +6,23 @@ ଧନାତ୍ମକ ପୂର୍ଣ୍ଣସଂଖ୍ୟା କିମ୍ବା ଶୂନ ପ୍ରବେଶ କରନ୍ତୁ ଧନାତ୍ମକ ପୂର୍ଣ୍ଣସଂଖ୍ୟା ପ୍ରବେଶ କରନ୍ତୁ ତାରିଖ ବାଛନ୍ତୁ + ଅକ୍ଷାଂଶ ଦ୍ରାଘିମା + ଗୃହ - ଆପ୍ ସୂଚନା - ୧ ମିନିଟ୍ - ୧୫ ମିନିଟ୍ ୩୦ ମିନିଟ୍ ୧ ଘଣ୍ଟା ୬ଘ ୧ ଦିନ (ଡିଫଲ୍ଟ) ୧୨ଘ ୧ ସପ୍ତାହ - କେବଳ ଋଣାତ୍ମକ ସଂଖ୍ୟା ଅନୁମୋଦିତ କେବଳ ଧନାତ୍ମକ ସଂଖ୍ୟା କିମ୍ବା ଶୂନ ଅନୁମୋଦିତ - କେବଳ ଧନାତ୍ମକ ସଂଖ୍ୟା ଅନୁମୋଦିତ ଗୃହ ସୂଚନା - ବିବରଣୀ - ସାପ୍ତାହିକ + ବହୁଭୂଜ - ସଜାଣି ଆଜି ଗତକାଲି ଆସନ୍ତାକାଲି @@ -37,8 +32,6 @@ ଗତ ମାସ ଏବେ ଗତକାଲି - ବିବରଣୀ - ହୋଇଗଲା ବିବରଣୀ ମତାମତ diff --git a/app/src/main/res/values-prs/strings.xml b/app/src/main/res/values-prs/strings.xml index 843fcc035be..7a00ed0a423 100644 --- a/app/src/main/res/values-prs/strings.xml +++ b/app/src/main/res/values-prs/strings.xml @@ -1,15 +1,14 @@ خطا + + پېښې - اضافه کردن تجدید - افزودن - تقسیم اوقات جدید + مراجعه - نام کاربر - کلید رمز بهبود حساب + روزنه هفته وار @@ -19,36 +18,36 @@ اشتراک جستجو + + باز کردن تنها قابل مطالعه میباشد تکمیل شد - فعال فعال خطا + + + - ایجاد تجدید - تاریخ - کتگوری گزینه ها عرض البلد طول البلد - رویداد جدید + معیاد گزارش موعد مقرر انصراف حذف - مجموع هیچ گزینه موجود نیست برنامه های فعال ثبت نام + + تجدید غیر فعال - - معلوماتی وجود ندارد صفحه اصلی پذیرش انصراف @@ -56,69 +55,36 @@ وضعیت - تاریخ شمولیت - جستجو تاریخ گزارش افزودن یاداشت + + - روزها - ماه ها - سالانه - هفته ها تاریخ رویداد/ واقعه - پاک کردن دوره + بعدی - ذخیره تأیید مشخصات پېښې وابستگی ها درباره اشتراک - بزرگتر - کنونی/ فعلی - اخطاریه - خطا ذخیره انتخاب گزینه صفحه اصلی مجموعه های اطلاعات - وابستگی ها - شاخص ها - تجزیه و تحلیل - یاداشت ها - جزئیات - شاخص ها - یاداشت ها لطفاً انتظار بکشید اسکیپ - زمانبندی دوباره - گذشته از موعد - جدول - پذیرش - ادامه اخطاریه رابطه/ وابستگی بستن - تکمیل - باز کردن - نوع پرونده پشتیبانی نمی‌شود. - روزنه - هفته وار - هفته - تکمیل شد - در حال پیشرفت - ارسال شده - ناکام - + چند ضلعی دوره - تنظیم کردن - وضعیت امروز دیروز هفته جاری @@ -134,20 +100,10 @@ برگشتن دیتا سیت ها دیروز - جزئیات - نظرات - نام - فورمول - تاریخ شمولیت باز کردن تکمیل - دیتا - انجام شد - آیا ډاډه یاست؟ تاریخ - تاریخ واقعه برگشتن - بستن چارت ها لیست نقشه @@ -168,10 +124,6 @@ سال گذشته مالی نظرات/فیدبک شاخص ها - مقدار - رقم باید کامل باشد - موجود - نا معلوم زبان او کی\! فرستادن @@ -183,7 +135,6 @@ تقسیم اوقات زمان بندی شده مراحل برنامه - گزینه ها نمایش دادن بیشتر نمایش دادن کم diff --git a/app/src/main/res/values-ps/strings.xml b/app/src/main/res/values-ps/strings.xml index ddd8d5e949d..382c45bcba6 100644 --- a/app/src/main/res/values-ps/strings.xml +++ b/app/src/main/res/values-ps/strings.xml @@ -1,15 +1,14 @@ تېروته + + پېښې - د نویو مالوماتو ورعلاوه کول - اضافه یې کړئ - د نویو مهالوېش وټاکئ + مراجعه - کارېدونکی نوم - پټنوم حساب بېرته ترلاسه کړئ + ورځني [ د مودې ډول ] اوونیز @@ -19,37 +18,37 @@ شریک یې کړئ جستجو + + خلاص یې کړئ یوازې یې ولولئ بشپړ شوی لغوه شوی - فعال دی فعال تېروته + + + - جوړ یې کړئ تجدید یا نوي یې کړئ - نېټه - د کټګورۍ غوروی لېټېټيوډ لانګېټيوډ - نوې پېښه + د راپور ورکولو موده ټاکلې نېټه رد یې کړئ [ ژباړه ـ ردول ] خذف یې کړئ - ټولټال هېڅ انتخاب شتون نه لري فعال پروګرامونه نوم لیکنه یا شمولیت + + درست یې کړئ غېرې فعال یې کړئ - - د مالوماتو (ډاټا) نشتوالۍ کور پاڼه د منلو وړ رد یې کړئ [ ژباړه ـ ردول ] @@ -57,64 +56,31 @@ وضعیت - د نوم ثبتونې نېټه - جستجو د راپور نېټه نوټ ور اضافه کړئ + + - ورځې - میاشت - کلونه - اونۍ د پېښې نېټه - پاک یې کړئ معیاد + راتلونکی - ثبت یې کړئ [ ژباړه - ثبت کړئ ] سم دی پېښې اړیکې په هکله شریک یې کړئ - حداعظم - اوسنۍ - خبرداری - تېروته ثبت یې کړئ [ ژباړه - ثبت کړئ ] کور پاڼه - اړیکې - شاخصونه - تحلیلي - یادداښتونه - جزیات [ ژباړه ـ جزیات ] - شاخصونه - یادداښتونه لطفاً صبر وکړئ ترې تېر شئ - د مهالوېش له سره ټاکل - ځنډول - جدول - د منلو وړ - دوام ورکړئ خبرداری اړیکه بند یې کړئ - بشپړ یې کړئ - خلاص یې کړئ - د ارزښت نه منل کېدونکی ډول - ورځني [ د مودې ډول ] - اوونیز - اونۍ - بشپړ شوی - دوام لري - لېږل شوي - ناکام شوي - معیاد - وضعیت نن ورځ پرونۍ ورځ هفته هذا @@ -130,21 +96,12 @@ شا ته وګرځئ د مالوماتو یا ډاټا ټولګې پرونۍ ورځ - جزیات [ ژباړه ـ جزیات ] - څرګندونې - نوم د پېښې وضعیت او څرنګوالۍ - د نوم ثبتونې نېټه خلاص یې کړئ بشپړ یې کړئ لغوه شوی - مالومات (ډاټا) - ترسره شو - آیا ډاډه یاست؟ نېټه - د پېښې نېټه شا ته وګرځئ - بند یې کړئ چارټونه لېسټ نقشه @@ -166,10 +123,6 @@ تېر مالي کال د فیډبک یا نظر ورکول شاخصونه - ارزښت یا رقم - ارقام باید صحیح عدد وي - د لاس رسي وړ دي - ناپېژنده لېږل له پامه غورځول شوی پروګرامونه @@ -178,7 +131,6 @@ مهالوېش یې وټاکئ پروګرام شوی د پروګرام پړاو - اختیاري دی ډېر ښکاره کړئ لږ ښکاره کړئ diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9291ff729a4..a7fb14fc1ad 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,25 +1,10 @@ Erro - - Erros - Erros - Erros - - Url do servidor errado - Credenciais erradas - Erro inesperado - Erro interno do servidor - sem valor - Metadados - Adicionar novo - Adicionar - Novo agendamento Transferência - Utilizador - Password Recuperar Conta + Diário Semanal @@ -29,36 +14,35 @@ Partilhar Pesquisar - Opções de filtro + + Abrir Apenas leitura Concluído Cancelado - Ativar Activo Erro + + + - Criar Actualizar - Data - Opcções de categorias Latitude Longitude - Novo evento + Período de relatório Data de vencimento Cancelar Apagar - Total Programas ativos Registrar + + Editar Desativar - - Não ha dados Origem Cancelar Configurações @@ -66,63 +50,38 @@ Situação - Data de inscrição - Pesquisar Data do relatório Adicionar uma nota + + - Dias - Meses - Anos Data do incidente - Limpar Período + Próximo - Salvar Aprovado Atributos Relacionamentos Acerca Partilhar - Máx - Corrente - Advertência - Erro Salvar Origem Valor de Dados Visão geral - Relacionamentos - Indicadores - Notas - Detalhes - Indicadores - Notas Por favor aguarde... Pular - Reprogramar - Atrasado - Continuar Advertência Relação Fechar - Completo - Abrir - Tipo de valor não suportado - Diário - Semanal - Semana - Concluído - Em andamento - Enviar - Polígono capturado + Poligono + + Segurança Período - Estado Hoje Ontém Última semana @@ -136,20 +95,11 @@ Voltar Agregação de Dados Ontém - Detalhes - Nome - Fórmula - Data de inscrição Abrir Completo Cancelado - Dados - Concluído - Tem certeza? Data - Data do evento Voltar - Fechar Copiar para área de transferência Lista Mapa @@ -166,11 +116,6 @@ último ano financeiro Retroinformacão Indicadores - Valor - Credenciais erradas - Valor deve ser um número inteiro - Disponível - Desconhecido Esta bem Enviar Actualizar @@ -181,5 +126,4 @@ Programar Agendado Estágio do programa - Opcional diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2af82023cec..d2186a9d0a1 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,65 +1,28 @@ Dhis2 - dhis - 2 Erro - - Erro - Erros - Erros - - URL do servidor errado - Credenciais erradas - Erro inesperado - Erro do Servidor Interno - sem valor Não há mais valores reservados. Entre em contato com o administrador do sistema - Não há mais valores reservados. Conecte-se à rede para recarregá-los - erros Ocorreu algo de errado - Sincronize agora - Sincronizando com o servidor - Sincronizar agora - Metadados Eventos - Entidades rastreadas - - Acabado - Sincronização concluída Erro de sincronização Ocorreu um erro durante a sincronização. Por favor tente novamente. A sincronização foi concluída, mas não recebemos a confirmação do servidor para todos os registos. Esses registos ainda estão marcados como “offline” na aplicação. Recomendamos que tente novamente a sincronização. - AVISO: A maioria dos seus dados é sincronizada, mas alguns campos apresentaram conflitos. Sincronize sua configuração e tente novamente. Se o aviso persistir, verifique o status de sincronização: programas, TEIs e eventos com conflitos são marcados como @. - Ocorreu um erro e o servidor não respondeu. Contacte o seu administrador. - - %s resultados encontrados - Novo - Adicionar Entrar %s Introduzir / Saltar %s - Nova agenda Transferência - url do servidor - Nome de usuário - Senha - Conecte-se - Desbloquear sessão Sincronizando sua configuração Seus dados serão sincronizados em breve Sincronizando seus dados Sua configuração está pronta Seus dados estão prontos - Falha na sincronização da configuração - Falha na sincronização de dados Recuperar Conta - É necessária uma ligação à rede para recuperar a sua conta. Diário @@ -80,139 +43,87 @@ Inserir texto Inserir texto longo Inserir numeros - Digite inteiro Digite um número inteiro negativo Digite um número inteiro positivo ou zero Digite um número inteiro positivo - Opções de filtro Escolha a data - Alterar calendário - Programa concluído - Programa inativo Abrir Não concluído Evento concluído Apenas leitura - Terminar e concluir Completo Cancelado Reabrir - Reabrir o formulário - Activar Activo Sincronizar Para enviar Atualizar - Suprimir Erro - SINCRONIZAR - ENVIAR - SINCRONIZAR SMS - Conclusão - Criar Actualizar - Data Unidade organizacional Não há unidades de organização abertas para a data selecionada. Selecione outra data ou outra unidade organizacional. - Opções de categorias Latitude Longitude - Novo evento - Um tempo - Permanente Período de relatório - Opção de gato - Se você definir um valor, a linha inteira precisa de todos os valores para concluir o conjunto de dados. - Alguns valores necessários estão ausentes. Você precisa definir valores obrigatórios para concluir o conjunto de dados. - Estão faltando campos obrigatórios. Por favor, preencha-os para completar o conjunto de dados. - O valor é salvo! - Não há períodos disponíveis para seleção. Data de vencimento Próximo %1$s Combinação de categoria - DD/MM/AAAA Cancelar Apagar - Total Data do evento mais recente Nenhuma opção disponível Lista de inscrições Programas activos - Programas para inscrição Registrar - Ultima visita: - aplique - Veja detalhes - VEJA MAIS Editar - Acompanhamento - Acompanhamento ativado - Acompanhamento desativado Desactivar Este programa não permite mais eventos - Sem dados eventos Início - Informações do aplicativo Sair - Bloquear Sessão Mostrar%s mais… Mostrar menos - 1 Minuto - 15 Minutos 30 Minutos 1 Hora 6h 1 dia (Padrão) 12h - Dados de sincronização automática a cada: Manual - Configuração de sincronização automática a cada: 1 Semana Parâmetros de sincronização atuais: Restaurar ao padrão + O banco de dados será excluído. \nVocê será solicitado a fazer login novamente. Aceitar Cancelar SINCRONIZAR Dados agora SINCRONIZAR Configuração agora Configuração - Última sincronização ativada: %s - Última sincronização ativada: %s Recarga - Módulo SMS: Porta de entrada SMS: remetente do resultado SMS Tempo limite do resultado SMS (seg) - segundos - número - número Ativar envio de SMS Aguardar resposta do resultado do SMS - - Há sim %s programa com configurações específicas - Há%s programas com configurações específicas - Há%s programas com configurações específicas - Configurações limitadas Globalmente por programa @@ -230,20 +141,15 @@ Este registro contém todos os erros causados ​​pela sincronização de seus dados e metadados. Suprimir dados locais Todos os dados do Capture App armazenados no seu dispositivo serão excluídos. Os dados que não são sincronizados com o servidor serão perdidos. - Redefinir dados do aplicativo e configuração - Sua configuração e dados do aplicativo armazenados no dispositivo serão excluídos. Você será solicitado a fazer login novamente e os dados que não forem sincronizados com o servidor serão perdidos. Configurações de SMS Verifique e edite os parâmetros relacionados ao porta de entrada sms Atualização do software - Software Exportar base de dados Exporte a sua base de dados e partilhe-a com o seu administrador Autenticação de dois factores Estado - Data de inscrição - Pesquisar @@ -264,29 +170,15 @@ Coordenadas para o - Lista de seções - %s perguntas de %s concluída - %s de %s concluído - Concluir e fechar - Dias - Meses - Anos - Semanas Data do incidente - Resultados locais - Resultados online Programas anteriores Você não tem a permissão necessária para executar esta ação - Adicionar como… - Limpar Período - (%d) Unidade Org - Nível %d Bem-vindo ao dhis2 Aqui você pode ver suas atividades Você pode aplicar um filtro... @@ -295,16 +187,11 @@ Verifique outras ações no menu principal - Criar relação Próximo Você precisa inserir pelo menos %d dos atributos para fazer uma pesquisa - Você precisa inserir pelo menos um dos atributos para fazer uma pesquisa - Há mais de %d resultados. Digite mais critérios de pesquisa - Digite seu critério de pesquisa Importar dados QR - Salvar Aprovado Identificação Atributos @@ -316,32 +203,20 @@ sem dados Código QR inválido Este atributo não pode ser adicionado - Esta inscrição não pode ser adicionada Acerca Programas para inscrição - selecionar tudo - seleção clara - Não há informações para os filtros selecionados. Por favor, tente alterá-los. Partilha Compartilhe QR Máx Corrente TEI TEIs - Limite por unidade organizacional - Limitar por programa - Lista de programas Redefinir dados do aplicativo e configuração - Não é possível adicionar esse relacionamento Verifique isso! - Advertência - Erro Salvar Valores Reservados - Deseja adicionar mais dados do QR? - Versão da aplicação: %s Versão do SDK: %s @@ -352,131 +227,43 @@ Desenvolvido e mantido pela DHIS2 projeto, parte da Universidade de Oslo, departamento de informática. Para mais informações ou perguntas sobre este aplicativo, please write to android@dhis2.org - Falta a senha. Por favor preencha este campo. - O nome de usuário está ausente. Por favor preencha este campo. - Este servidor está executando uma versão dhis diferente de 2.29. Atualize a versão do servidor ou use o ambiente de teste para continuar. - Este servidor está executando uma versão dhis diferente de %s. Atualize a versão do servidor ou use o ambiente de teste para continuar. - Algo deu errado. Verifique se o URL, o nome de usuário e a senha do servidor estão corretos e tente novamente. - O servidor não pôde concluir esta solicitação. - Ocorreu um problema ao tentar fazer login no servidor. - Erro de login - Faltam campos obrigatórios - Erro inesperado ao pesquisar online - Erro ao analisar resultados - A pesquisa online retornou um erro - Algo deu errado no servidor Selecione um programa para ativar a inscrição Você não tem a permissão necessária - Essa funcionalidade ainda não está pronta. - Erro ao obter o rótulo do relacionamento Tudo %s Este não é um número de telefone válido - Este não é um email válido - Somente números negativos são permitidos Somente números positivos ou zero são permitidos - Somente números positivos são permitidos - Somente valores de 0 a 1 são permitidos - Somente valores de 0 a 100 são permitidos - Selecione as datas para filtrar Selecionar opção - Problema relatado - Gerar novo evento - Deseja criar outro evento? Eventos Concluídos Todos os eventos deste programa estão concluídos. Deseja encerrar o programa também? - Deseja concluir a inscrição também? Gostaria de completar também as %s Verifica Início EXEMPLO - Adicionar widget Mostre ajuda - Sincronize seus dados... - ...sua configuração... - ...definir quantos registros baixar do servidor... - Verifique e gerencie quantos valores reservados restam - Verifique se uma conexão com o servidor falhou - Libere espaço em disco - ...ou exclua tudo para começar de novo. - Encontre suas entidades no dispositivo local e no servidor online. - Você pode mudar para outros programas - Pesquise ou adicione novas entidades. Você precisa pesquisar antes de poder adicionar um novo! - Clique aqui para limpar os campos e reiniciar a pesquisa. Este é o painel da entidade Ver informações do perfil de entidades... - ...Compartilhe informações via QR ou SMS... - ...Ou marque a entidade para acompanhamento - Clique para criar ou agendar um evento ou se referir a outra Unidade Org. Aqui você verá todos os eventos. Clique para ver um detalhe do evento. Navegue pelo menu para adicionar relacionamentos, ver indicadores ou escrever notas. - Clique para ver todas as inscrições da sua entidade ou crie uma nova. - Clique para excluir este evento. Essa ação não pode ser desfeita. - Complete o evento. - Todos os eventos relacionados a este programa serão listados aqui. - Clique para adicionar um novo evento Digite todos os dados para criar o evento Clique para verificar o status de conclusão do evento. - Clique para concluir ou reabrir um evento - Leia e armazene informações de outros dispositivos via QR - Sincronize seus dados com o servidor - Bloqueie a sessão com um código de 4 dígitos. Não esqueça seu código! - Faça logout para alterar usuário ou servidor. Você poderá fazer login novamente, mesmo se não houver conexão com a Internet. - %s: Relacionamentos Não há eventos registrados, clique em \"+\" para adicionar um novo. - Não há eventos registrados. Valor de Dados Informações Não foi possível ler este ID Suprimir dados locais Visão global - Relacionamentos - Indicadores - Google Analytics Não há indicadores para este programa. - Notas - Não foi possível concluir - Não é possível guardar - Existem avisos no formulário - O que quer fazer? - Alguns campos possuem erros. Por favor, verifique-os para poder concluir Todas as inscrições Mostrar painel com todos os programas ativos - Registro de erros de sincronização aberta Sincronizar registro de erros Compartilhar com... - Sua sincronização de configuração falhou. Pode haver erros no seu servidor. Clique aqui para obter mais informações. - ANTERIOR - PRÓXIMO - Detalhes - Indicadores - Notas - - Conclua mais tarde - - Terminar - Campos com erros - Alguns campos possuem erros. Eles não foram salvos. Por favor, verifique-os. O valor deve ser único, uma coincidência foi encontrada. Não foi salvo. Por favor verifique isto. - Foi encontrada uma coincidência em um campo único. Verifique o formulário para salvar a Inscrição. Por favor aguarde... Saltar - Reprogramar Não há informações para esta tela - Atrasado - There is one program rule which is not supported. Por favor verifique a documentação - Nenhuma informação para este campo - Credenciais Vazias - Para poder fazer login usando o scanner de impressão digital você precisa primeiro usar a maneira padrão - Segurança biométrica - Início de sessão rápido - Gostaria de utilizar a sua impressão digital ou outro método biométrico para iniciar sessão? - Tabelas - Tabela - https:// Dados não sincronizados serão perdidos. Deseja mesmo continuar? - Aceitar Não foi possível excluir os dados. Por favor tente novamente. Exclua os dados locais concluídos com sucesso. Pelo menos uma unidade organizacional deve ser selecionada @@ -489,6 +276,7 @@ Aceita Continuar url do servidor + Digite o URL do servidor DHIS 2 da sua organização, conforme fornecido pelo administrador do sistema.\n\nAlternativamente, você pode digitalizar um código QR contendo o URL do seu servido Esqueceu seu código? O que você gostaria de fazer para este evento? FIM @@ -499,33 +287,16 @@ A carregar Relação Enviado por SMS - Não há conflitos para este programa - A conexão de rede não está disponível, mas parece que existem serviços telefônicos - você pode usar o SMS. - A conexão de rede não está disponível. Fechar Agora você pode verificar as informações de sincronização clicando aqui - Esses dados são armazenados apenas no seu dispositivo, não está sincronizado com o servidor. - Você esta atualizado! Todos os seus dados são enviados para o servidor. Você não pode relacionar um registro consigo mesmo - Desbloquear sessão - Completo - Abrir - Tipo de valor não suportado Alterar visualização da agenda - 15 minutos - Toda hora - Diário - Semanal - Semana - Configuração de sincronização: %s O registro que você está tentando acessar não tem inscrição. Você não pode acessar o painel. Erro de formatação Apagar%s Você não tem autoridade para suprimir este registro - Este url não é válido Ajustando formulário... - Número do receptor Você aceita enviar %d mensagens? Enviando %1$d/%2$d Aguardando confirmação do valor - %d @@ -543,51 +314,29 @@ Nenhum conjunto de porta de entrada Usuário não logado O módulo SMS está desativado - Outro envio de SMS em andamento Módulo SMS não iniciado, não encontrado metadados necessários - Completo Contagem de SMS não aceita Iniciado envio de sms Dados lidos e convertidos. ID da submissão: %d - Erro ao enviar envio de sms Registro e inscrição de SMS TEI Envio de evento por SMS envio de conjunto de dados SMS - Iniciando - Em curso - Enviar - Falhou - Iniciando a sincronização. Sincronizando... - Sincronização concluída. - Ocorreu um erro durante a sincronização. - Ocorreu um erro durante a sincronização. Clique aqui para verificar os logs de erros - Sincronização cancelada. Copiado - Imagem selecionada - Mostrar no mapa - Mostrar lista - %s é obrigatório mas não foi fornecido. - Polígono capturado Poligono - Impressão digital - Por favor, coloque o dedo no seu leitor de impressões digitais. Segurança Por razões de segurança, esta versão do aplicativo não pode ser usada em dispositivos raiz. Por favor, use a versão de treinamento. - Existem alguns erros. Por favor corrija-os antes de continuar. Aplicação de filtros Nenhum filtro aplicado Período Unidade Org. - Ordem - Estado Sincronizar Atribuído a mim Hoje @@ -602,8 +351,6 @@ De - Para Outro A qualquer momento - Falha na sincronização. Falha na sincronização. Verifique os valores dos dados para tentar corrigir o problema e tente novamente. Se continuar falhando, entre em contato com o administrador do sistema. - Esses dados foram enviados ao servidor mas não é garantido que tenham sido armazenados Abrir Programar Atrasado @@ -619,7 +366,6 @@ A porta de entrada do SMS não pode estar vazio Não há inscrições para envio Este convertTask não é suportado - Não mostrar novamente a tela de bloqueio Definir pino Conflito Mudar data @@ -644,11 +390,7 @@ Escolha a unidade organizacional Digite o URL Digite o valor - Escolha a imagem - Código de digitalização - Escolha a opção Valores reservados baixados por atributo TEI - Valores reservados baixados por atributo TEI %s valores reservados restantes Gerenciar valores reservados Eventos para baixar @@ -668,55 +410,29 @@ Não sincronizado Erro de Sincronização SMS sincronizado - Detalhes - Comentarios Gravado! - Você quer verificar a qualidade dos dados? - Tudo parece bem! - Você também deseja completar o conjunto de dados? - Marcar como completado - Nome - Fórmula - Completar mesmo assim Ausência de %s %s de %s %s Estado do evento - Data de inscrição Não existem dados. Clique em \"+\" para adicionar um novo registro Não existem dados. Você não tem permissão para adicionar novos registros. Nenhuma descrição ou instrução fornecida Dados para revisar - Salvo e concluído! Já existe uma inscrição ativa Abrir Completo Cancelado - Dados O selecionado %s não pôde ser baixado. - Completado por %s: %s - Atualizado: %s - Feito - Não foi possível verificar a qualidade dos dados. Contate seu administrador - Tem certeza? - Você deseja reabrir o conjunto de dados? Data - Data de incidente - %s não tem atributos públicos. Selecione um programa Política de Privacidade Clique aqui para conferir nosso política de privacidade Os valores das coordenadas não são válidos + Não é possível inscrever um %s de outras unidades organizacionais neste programa porque está protegido.\nContacte o seu administrador. Este campo é obrigatório Por razões de segurança, esta versão do aplicativo não pode ser usada com o adb. Por favor, use a versão de treinamento. Voltar - Fechar aplicativo - Mais detalhes - Mais detalhes - Fechar Copiar para área de transferência - Copiado para área de transferência - Informação de erro Encontramos um erro ao sincronizar com seu servidor. Compartilhe o erro com o administrador do sistema, se for possível. - Não foi possível fazer login com seu nome de usuário Existem eventos planejados que não serão reprogramados Gráficos Lista @@ -767,46 +483,27 @@ Retroinformacão Indicadores Valor + A permissão da câmara é negada.\nPrecisa de a ativar para utilizar esta funcionalidade. Gráficos e indicadores Tirar fotografia - Selecionar a partir da galeria - Feito! Este programa está protegido Descreva aqui o motivo - Autenticando - O URL do servidor fornecido não é uma instância dhis - Credenciais erradas - Servidor desconhecido - Valor deve ser um número inteiro A sessão expirou A sua sessão atual expirou. Por motivos de segurança, terá de fornecer novamente as suas credenciais - Valores Semana %d %s a%s Não voltar a mostrar Configuração de funções Mostrar como radar - Não é possível definir o tipo de coordenadas Seguimento%s - %s não pode ser analisado como uma data Não foi possível atualizar este campo. Por favor, tente novamente - Edição - Disponível Estes dados não são editáveis porque estão marcados como concluídos Estes dados não são editáveis porque o seu tempo de edição expirou Não tem permissão para editar estes dados Estes dados não são editáveis porque a unidade organizacional está encerrada Não tem permissão para editar estes dados - Não é possível editar dados desta unidade organizacional - Repor a pesquisa + Não é possível editar dados desta unidade organizacional A pesquisa em linha deu a seguinte mensagem: - Aviso de regras do programa - Existe um problema de configuração que está a provocar um ciclo nas regras. Contacte o seu administrador. - ACTUALIZAR - Desconhecido - Atualizar todos os dados -  Atingiu o número máximo de contas (%d). Para adicionar uma nova conta, inicie sessão e elimine uma das suas contas Eliminar conta - Gerir contas Resolução de problemas de configuração Idioma Toque aqui para alterar o idioma da aplicação @@ -824,15 +521,14 @@ Não existem resultados para esta pesquisa Muitos resultados Tente alterar a sua pesquisa para restringir os resultados. - Crie um novo Novo %s - É necessário introduzir pelo menos %s atributos para pesquisar Esta bem Iniciar uma pesquisa para encontrar qualquer %s É possível pesquisar ou criar um novo %s Tem algumas mensagens de aviso. Deseja marcar este formulário como completo? Se sair agora, as alterações serão rejeitadas. + Tem algumas mensagens de erro. Quer revê-las? Reaberto Conta desactivada A sua conta foi desactivada por um administrador. @@ -842,27 +538,10 @@ Data actual: %s Dispositivo: %s Versão do sistema operativo: %s - Abrir detalhes de %s - %s detalhes A sincronizar %s… - SMS enviado. A aguardar resposta do servidor. - SMS confirmado para chegar ao servidor. Actualização do estado - Não podemos confirmar se o SMS chegou ao servidor. Actualização do estado - SMS enviado. Atualização do estado. - SMS não enviado. - Abrir a aplicação SMS. Não modificar a mensagem, por favor. Enviar Sms com Enviou a sms? - A aguardar confirmação manual - Sincronização de eventos - Sincronização de entidades monitorizadas - Sincronização de conjuntos de dados - Sincronização de recursos - Sincronização efectuada - A sincronização de SMS está disponível, mas tem de ser acedida para cada registo individual Descarregar imagens... - Descrição do programa - Pin errado Inscrito em Propriedade de Sincronização necessária @@ -882,22 +561,11 @@ Actualizar Não agora Toque aqui para explorar - - %s erro - %s erros - %s Erros - - - %s aviso - %s avisos - %s Alertas - A versão %s aplicação já está disponível. Quer baixá-lo? Atualização do software Mais tarde Descarregar agora Verifique se existem atualizações - Verificação de actualizações Não há novas actualizações Versão da aplicação Algo correu mal com a configuração do programa, não é possível carregar este evento. Contacte o administrador do sistema. @@ -934,8 +602,6 @@ Estagio de programa evento Linha do tempo - Limpar pesquisa - Opcional Banco de dados descarregado Importando base de dados Mostre mais @@ -957,4 +623,21 @@ Transferido com sucesso %1$s cancelado Data de vencimento actualizada - + + %s erro + %s erros + %s Erros + + + + %s aviso + %s avisos + %s Alertas + + + + Há sim %s programa com configurações específicas + Há%s programas com configurações específicas + Há%s programas com configurações específicas + + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c777a674a2e..c55464225ca 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1,8 +1,6 @@ Eroare - Adăugare nou - Adăugare Lunar Anual Accept @@ -15,16 +13,18 @@ Activ Eroare + + + - Creare Actualizare - Data - Eveniment nou Perioada raportului Anulare Lichidare + Editare + evenimente Acasă Accept @@ -32,37 +32,22 @@ Stare - Data înrolării - Căutare Data raportului + + - Zile - Luni - Ani - Săptămâni Data incidentului + Următorul - salva Relații Partajare - Max - Avertizare - Eroare salva Acasă - Relații - Indicatori - Indicatori - Finalizare - Accept - Continuare Avertizare Închidere - Complet - Stare Astăzi Ieri Săptămâna aceasta @@ -71,12 +56,8 @@ Luna trecută Complet Ieri - Comentarii - Data înrolării Anulat - Terminat Data - Închidere Ultimul trimestru Săptămâni în anul curent Luni în anul curent diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bbf63ec9bdb..462c583a6b6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,66 +1,28 @@ Dhis2 - dhis - 2 Ошибка - - Ошибка - Ошибки - Ошибки - Ошибки - - Неправильный веб-адрес - Неверные учетные данные - Непредвиденная ошибка - Внутренняя ошибка сервера - нет значения Больше нет зарезервированных значений. Обратитесь к системному администратору - Зарезервированных значений больше нет. Подключитесь к сети, чтобы пополнить их - ошибки Что-то пошло не так - Синхронизировать сейчас - Синхронизация с сервером - Синхронизация сейчас - Метаданные События - Отслеживаемые объекты - - Завершено - Синхронизация завершена Ошибка синхронизации Во время синхронизации произошла ошибка. Пожалуйста, повторите попытку. Синхронизация завершена, но мы не получили подтверждения сервера для всех записей. Эти записи по-прежнему отмечены в приложении как \"оффлайн\". Мы рекомендуем повторить синхронизацию. - ВНИМАНИЕ: Большая часть данных синхронизирована, но некоторые поля представлены с конфликтами. Синхронизируйте конфигурацию и повторите попытку. Если предупреждение сохраняется, проверьте статус синхронизации: программы, отслеживаемые объекты и события с конфликтами отмечены как @. - Произошла ошибка, и сервер не ответил. Обратитесь к своему администратору. - - %s результатов найдено - Добавить новое - Добавить Введено %s Введено / пропущено%s - Распланировать новое Направление - Адрес веб-страницы сервера - Имя пользователя - Пароль - Вход - Разблокировать сессию Синхронизация Вашей конфигурации Ваши данные вскоре будут синхронизированы Синхронизация данных Ваша конфигурация готова Ваши данные готовы - Не удалось синхронизировать конфигурацию - Сбой синхронизации данных Восстановить учетную запись - Для восстановления учетной записи необходимо подключение к сети. Ежедневно @@ -81,134 +43,82 @@ Введите текст Введите длинный текст Введите номер - Введите целое число Введите отрицательное целое число Введите целое положительное число или ноль Введите целое положительное число - Параметры фильтров Выберите дату - Изменить календарь - Программа завершена - Программа неактивна Открыть Не завершено Событие завершено Только читать - Закончить и завершить Завершенный Отмененный Открыть заново - Открыть форму заново - Активировать Действующий Синхронизировать Отправить Обновить - Удалить Ошибка - СИНХРОНИЗАЦИЯ - ОТПРАВИТЬ - СИНХРОНИЗАЦИЯ ПО SMS - Завершение - Создать Обновить - Дата Организационная единица На выбранную дату нет свободных организационных единиц. Пожалуйста, выберите другую дату или другую организационную единицу. - Опции категории Широта Долгота - Новое событие - Один раз - Постоянно Отчет за период - Если вы задали значение, то для заполнения набора данных в строке должны быть все значения. - Некоторые необходимые значения отсутствуют. Для заполнения набора данных необходимо установить обязательные значения. - Не заполнены обязательные поля. Пожалуйста, заполните их, чтобы завершить набор данных. - Значение сохранено! - Нет доступных периодов для выбора. Дата выполнения Следующий %1$s - ДД/ММ/ГГГГ Отмена Удалить - Всего Дата последнего события Нет доступных вариантов Список регистраций Действующие программы - Программы для регистрации Регистрировать - Последнее посещение: - применить - Посмотреть детали - ПОСМОТРЕТЬ БОЛЬШЕ Редактировать - Отслеживать - Последующие действия включены - Последующие действия выключены Деактивировать Эта программа не позволяет иметь больше событий - Нет данных события/й Главная - Информация о приложении Выйти из системы - Заблокировать сессию Показать %s больше... Показать меньше... - 1 минута - 15 минут 30 минут 1 час 6 часов 1 день (по умолчанию) 12 часов - Автоматическая синхронизация данных каждый: Вручную - Автоматическая синхронизации конфигурации каждый: 1 неделя Текущие параметры синхронизации: Возвратить в исходное положение по умолчанию + База данных будет удалена.Вам будет предложено войти в систему снова. Принять Отмена СИНХРОНИЗИРОВАТЬ данные сейчас СИНХРОНИЗИРОВАТЬ конфигурацию сейчас Настройки - Последняя синхронизация: %s - Последняя синхронизация: %s - Модуль SMS: - секунд - число - число Включить отправку SMS Дождитесь ответа на SMS-сообщение - - Существует программа %s с определенными настройками. - Существуют программы %s с определенными настройками. - Существуют программы %s с определенными настройками. - Существуют программы %s с определенными настройками. - Настройки ограничены Глобально по программам @@ -226,19 +136,14 @@ Этот журнал содержит все ошибки, вызванные синхронизацией данных и метаданных. Удалить локальные данные Все данные приложения Capture App, хранящиеся на вашем устройстве, будут удалены. Данные, не синхронизированные с сервером, будут потеряны. - Сброс данных приложения & конфигурация - Ваша конфигурация и данные приложения, хранящиеся на устройстве, будут удалены. Вам будет предложено снова войти в систему, а данные, не синхронизированные с сервером, будут потеряны. Настройки SMS Проверка и редактирование параметров, связанных с SMS-шлюзом Обновление программного обеспечения - Программное обеспечение Экспорт базы данных Экспортируйте свою базу данных и передайте ее Вашему администратору Статус - Дата зачисления - Поиск @@ -259,29 +164,15 @@ Координаты для - Список разделов - %s вопросов %s завершены - %sиз %s завершено - Завершить и закрыть - Дни - Месяцы - Годы - Недели Дата происшествия - Локальные результаты - Результаты онлайн Прошлые программы У Вас нет необходимых полномочий для выполнения этого действия - Добавить как... - Очистить Период - (%d) Орг.подразделение - Уровень %d Добро пожаловать в dhis2 Здесь Вы можете увидеть свою деятельность Вы можете применить фильтр... @@ -290,16 +181,11 @@ Проверьте другие действия в главном меню - Создать взаимоотношения Следующая Для поиска необходимо ввести как минимум %d атрибутов. - Чтобы выполнить поиск, необходимо ввести хотя бы один из атрибутов - Есть больше, чем %d результатов. Пожалуйста, введите дополнительные критерии поиска - Введите критерии поиска Импорт данных через QR - Сохранить ОК Идентификация Атрибуты @@ -311,29 +197,19 @@ нет данных Неверный QR-код Этот атрибут не может быть добавлен - Эта регистрация не может быть добавлена Об Программы для регистрации - выбрать всё - очистить выбор - Для выбранных фильтров нет информации. Пожалуйста, попробуйте изменить их. Поделиться Поделиться QR Макс Текущий TEI - Список программ Сброс данных приложения & конфигурации - Невозможно добавить эту связь Проверьте это! - Предупреждение - Ошибка Сохранить Зарезервированные значения - Хотите добавить больше данных с помощью QR-кода? - Версия приложения: %s Версия SDK: %s @@ -344,125 +220,41 @@ Разработан и поддерживается проектом DHIS2, входящим в состав Университета Осло, департамент информатики. Для получения дополнительной информации или вопросов по данному приложению пишите по адресу android@dhis2.org. - Пароль отсутствует. Пожалуйста, заполните это поле. - Имя пользователя отсутствует. Пожалуйста, заполните это поле. - На этом сервере установлена версия dhis, отличная от 2.29. Обновите версию своего сервера или используйте тестовую среду для продолжения работы. - На этом сервере установлена версия dhis, отличная от %s. Обновите версию своего сервера или используйте тестовую среду для продолжения работы. - Что-то пошло не так. Пожалуйста, проверьте правильность url-адреса сервера, имени пользователя и пароля и повторите попытку. - Сервер не смог выполнить этот запрос. - При попытке войти на сервер возникла проблема. - Ошибка входа в систему - Не заполнены обязательные поля - Неожиданная ошибка при поиске онлайн - Ошибка при разборе результатов - Поиск онлайн дал ошибку - Что-то пошло не так на сервере Выберите программу для возможности регистрации У Вас нет требуемого разрешения - Эта функциональность еще не готова. - Ошибка при получении метки взаимоотношений Все %s Это недействительный номер телефона - Это недействительный адрес электронной почты - Допускаются только отрицательные числа Допускаются только положительные числа или ноль - Допускаются только положительные числа - Допускаются только значения от 0 до 1 - Допускаются только значения от 0 до 100 - Выберите даты для фильтрации Выберите опцию - Сообщается о проблеме - Создать новое событие - Хотите создать еще одно событие? Завершенные события Все события в этой программе завершены. Вы хотите закрыть и эту программу? - Вы хотите также завершить регистрацию? Вы хотите также завершить %s? Проверьте Главная ПРИМЕР - Добавить виджет Показать помощь - Синхронизация Ваших данных... - ...Вашей конфигурации... - ...задайте количество записей, которые нужно загрузить с сервера... - Проверяйте и управляйте количеством оставшихся зарезервированных значений - Проверьте, не нарушилось ли соединение с сервером - Освободите немного места на диске - ...или удалите все, чтобы начать сначала. - Вы можете переключиться на другие программы - Поиск или добавление новых объектов. Прежде чем добавить новый объект, необходимо выполнить поиск! - Нажмите здесь, чтобы очистить поля и заново начать поиск. - ...Делитесь информацией через QR или SMS... - Нажмите, чтобы создать или запланировать событие или обратиться к другому орг. подразделению. Здесь вы увидите все события. Нажмите, чтобы просмотреть подробности события. Перемещайтесь по меню, чтобы добавить связи, посмотреть индикаторы или написать заметки. - Нажмите, чтобы удалить это событие. Это действие нельзя отменить. - Завершить событие. - Все события, связанные с этой программой, будут перечислены здесь. - Нажмите, чтобы добавить новое событие Введите все данные для создания событие Нажмите, чтобы проверить состояние завершения события. - Нажмите, чтобы завершить или снова открыть событие - Считывайте и сохраняйте информацию с других устройств с помощью QR - Синхронизация Ваших данных с сервером - Заблокируйте сессию с помощью 4-значного кода. Не забывайте свой код! - Выйдите из системы, чтобы сменить пользователя или сервер. Вы сможете снова войти в систему даже при отсутствии подключения к Интернету. - %s: связи Нет зарегистрированных событий, нажмите \"+\", чтобы добавить новое. - Нет зарегистрированных событий. Значение данных Информация Невозможно прочитать этот Id Удаление локальных данных Обзор - Зависимости - Индикаторы - Аналитика Для этой программы не существует индикаторов. - Заметки - Невозможно завершить - Невозможно сохранить - Есть предупреждения в форме - Что Вы хотите делать? - В некоторых полях есть ошибки. Пожалуйста, проверьте их, чтобы заполнить Все регистрации Показать инфопанель со всеми активными программами - Открыть журнал ошибок синхронизации Журнал ошибок синхронизации Поделиться с... - Синхронизация Вашей конфигурации не удалась. Возможно, на Вашем сервере возникли ошибки. Нажмите здесь для получения дополнительной информации. - ПРЕДЫДУЩИЙ - СЛЕДУЮЩИЙ - Детали - Индикаторы - Заметки - - Завершить позже - Завершить - Поля с ошибками - Некоторые поля содержат ошибки. Они не были сохранены. Пожалуйста, проверьте их. Значение должно быть уникальным, совпадение найдено. Оно не было сохранено. Пожалуйста, проверьте его. - Найдено совпадение в уникальном поле. Пожалуйста, проверьте форму, чтобы сохранить регистрацию. Пожалуйста, подождите Пропустить - Перепланировать Информация для этого экрана отсутствует - Просроченный - Существует одно программное правило, которое не поддерживается. Пожалуйста, проверьте документацию - Нет информации для этого поля - Пустые учетные данные - Чтобы войти в систему с помощью сканера отпечатков пальцев, сначала нужно воспользоваться стандартным способом - Биометрическая безопасность - Быстрый вход в систему - Хотите использовать отпечаток пальца или другой биометрический метод для входа в систему? - Таблицы - Таблица - https:// Не синхронизированные данные будут потеряны. Вы действительно хотите продолжить? - Принять Данные не удалось удалить. Пожалуйста, повторите попытку. Удаление локальных данных успешно завершено. Необходимо выбрать хотя бы одну Организационную единицу. @@ -475,6 +267,7 @@ Согласен Продолжить Адрес веб-страницы сервера + Введите URL-адрес сервера DHIS 2 для Вашей организации, предоставленный Вашим системным администратором. Другой вариант - отсканировать QR-код, содержащий URL-адрес Вашего сервера. Забыли свой код? Что бы Вы хотели сделать для этого события? КОНЕЦ @@ -485,33 +278,16 @@ Загрузка Зависимость Отправлено по SMS - Для этой программы нет конфликтов - Подключение к сети недоступно, но, похоже, есть телефонная связь - можно использовать SMS. - Сетевое подключение недоступно. Закрыть Теперь Вы можете проверить информацию о синхронизации, нажав здесь - Эти данные хранятся только на Вашем устройстве, они не синхронизированы с сервером. - Вы обновлены! Все Ваши данные отправлены на сервер. Вы не можете связать запись с самой собой - Разблокировать сессию - Завершенный - Открыть - Неподдерживаемый тип значения Изменить вид календаря - 15 минут - Каждый час - Ежедневно - Еженедельно - Неделя - Настройка синхронизации: %s Запись, к которой Вы пытаетесь получить доступ, не зарегистрирована. Вы не можете получить доступ к инфопанели. Ошибка форматирования Удалить %s Вы не имеете права удалять эту запись - Это не правильный url Настройка формы... - Номер приемника Согласны ли Вы отправлять сообщения на %d? Отправка %1$d/%2$d Ожидание подтверждения количества - %d @@ -529,48 +305,25 @@ Шлюз не установлен Пользователь не вошел в систему Модуль SMS отключен - Продолжается отправка очередного SMS-сообщения Модуль SMS не инициирован, не найдены необходимые метаданные - Завершенный SMS не принимаются Запуск отправки смс Данные прочитаны и преобразованы. Идентификатор заявки: %d - Ошибка отправки смс - Начало - Выполняется - Отправлено - Сбой - - Запуск синхронизации. Синхронизация... - Синхронизация завершена. - Во время синхронизации произошла ошибка. - Во время синхронизации произошла ошибка. Нажмите здесь, чтобы проверить журналы ошибок - Синхронизация отменена Скопировано - Выбранное изображение - Показать на карте - Показать список - %s является обязательным, но не был предоставлен. - Полигон зафиксирован Полигон - Отпечаток пальца - Пожалуйста, приложите палец к считывателю отпечатков пальцев. Безопасность По соображениям безопасности эта версия приложения не может быть использована на корневых устройствах. Пожалуйста, используйте учебную версию. - Здесь есть несколько ошибок. Пожалуйста, исправьте их, прежде чем продолжить. Применение фильтров Фильтры не применены Период Орг. единица - Сортировка - Статус Синхронизировать Назначен мне Сегодня @@ -585,18 +338,13 @@ От - до Другое Любое время - Синхронизация не удалась. Синхронизация не удалась. Проверьте значения данных, чтобы попытаться устранить проблему, и повторите попытку. Если попытка не удалась, обратитесь к системному администратору. - Эти данные были отправлены на сервер, но не гарантируется, что они были сохранены Открыть Расписание Просроченный Посещено Завершенный Пропущенный - Скрыть данные регистрации - Показать данные регистрации Нет регистраций к загрузке - Не показывать снова экран блокировки Установить пин Конфликт Изменить дату @@ -621,9 +369,6 @@ Выберите Организационную единицу Введите url Введите значение - Выбрать изображение - Сканировать код - Выберите опцию Управление зарезервированными значениями События для загрузки Отслеживаемые объекты для загрузки @@ -642,55 +387,29 @@ Не синхронизировано Ошибка синхронизации Синхронизированное SMS - Детали - Комментарии Сохранено! - Вы хотите проверить качество данных? - Все выглядит хорошо! - Вы также хотите завершить набор данных? - Отметить как завершенное - Название - Формула - Завершить все равно Отсутствует %s %s из %s %s Статус события - Дата зачисления Нет никаких данных. Нажмите \"+\", чтобы добавить новую запись Нет данных. У Вас нет разрешения на добавление новых записей. Описание и инструкция отсутствуют Данные для просмотра - Сохранено и завершено! Уже есть активная регистрация Открыть Завершенный Отмененный - Данные Не удалось загрузить выбранные %s. - Завершено на %s: %s - Обновлено: %s - Готово - Мы не смогли проверить качество данных. Свяжитесь с Вашим администратором - Вы уверены? - Хотите ли Вы снова открыть набор данных? Дата - Дата происшествия - %s не имеет публичных атрибутов. Пожалуйста, выберите программу Политика конфиденциальности Нажмите здесь, чтобы ознакомиться с нашей политикой конфиденциальности . Значения координат не действительны + Вы не можете зарегистрировать %s из других организационных подразделений в этой программе, потому что она защищена. Свяжитесь с Вашим администратором. Это поле обязательно для заполнения По соображениям безопасности эта версия приложения не может быть использована с adb. Пожалуйста, используйте учебную версию. Вернуться - Закрыть приложение - Подробнее - Подробнее - Закрыть Копировать в буфер обмена - Скопировано в буфер обмена - Информация об ошибках Мы обнаружили ошибку при синхронизации с Вашим сервером. По возможности сообщите об ошибке своему системному администратору. - Мы не смогли инициировать сессию с Вашим пользователем Есть запланированные события, которые не будут перенесены на другой день Диаграммы Список @@ -740,44 +459,25 @@ Обратная связь Индикаторы Разрешение камеры запрещено.\nЧтобы использовать эту функцию, ее необходимо разрешить. - Значение Диаграммы и индикаторы Сделать фото - Выбрать из галереи - Готово! Данная программа защищена Опишите причину здесь - Автентификация - Предоставленный адрес сервера не является экземпляром dhis - Неверные учетные данные - Неизвестный сервер - Значение должно быть целым числом Сессия истекла Срок действия Вашей текущей сессии истек. По соображениям безопасности Вам нужно будет снова предоставить свои учетные данные - Значения Неделя %d %s до %s Больше не показывать Показать как радар Отмеченные для последующего наблюдения %s Это поле не удалось обновить. Пожалуйста, попробуйте еще раз - Издание - Доступно Эти данные нельзя редактировать, поскольку они помечены как завершенные Эти данные нельзя редактировать, так как время их редактирования истекло У Вас нет прав на редактирование этих данных Эти данные нельзя редактировать, потому что организационное подразделение закрыто У вас нет прав на редактирование этих данных - Вы не можете редактировать данные из этой Организационной единицы - Сброс поиска + Вы не можете редактировать данные из этой Организационной единицы Онлайн поиск дал следующее сообщение: - Предупреждение о правилах программы - Существует проблема конфигурации, вызывающая зацикливание правил. Обратитесь к администратору. - ОБНОВИТЬ - Неизвестный - Обновить все данные - Вы достигли максимального количества учетных записей (%d). Чтобы добавить новую учетную запись, войдите в систему и удалите одну из своих учетных записей Удалить аккаунт - Управление учетными записями Устранение неисправностей конфигурации Язык Нажмите здесь, чтобы изменить язык приложения @@ -796,9 +496,7 @@ Для этого поиска нет результатов Слишком много результатов Попробуйте изменить поиск, чтобы сузить результаты. - Создать новый Новый %s - Для поиска необходимо ввести как минимум %s атрибутов. Ok Начните поиск, чтобы найти любой %s Вы можете найти или создать новый %s @@ -807,7 +505,6 @@ В некоторых полях есть ошибки, и они не сохраняются. \nВы хотите просмотреть форму\? Хотите ли Вы отметить эту форму как завершенную? У Вас есть несколько сообщений об ошибках.\nХотите просмотреть их\? - Если Вы выйдете сейчас, изменения будут отменены. Открыть заново Аккаунт дезактивирован Ваша учетная запись была отключена администратором. @@ -816,18 +513,8 @@ Текущая дата: %s Устройство: %s Версия Os: %s - Открыть %s деталей - %s деталей Синхронизация %s... - Ожидает подтверждения вручную - Синхронизация событий - Синхронизация отслеживаемых объектов - Синхронизация наборов данных - Синхронизация ресурсов - Синхронизация выполнена Загрузка изображений... - Описание программы - Неправильный пин Зарегистрирован в Владелец Необходима синхронизация @@ -846,24 +533,11 @@ Обновить Не сейчас Нажмите здесь, чтобы посмотреть - - %s ошибка - %s ошибки - %s ошибки - %s ошибок - - - %s предупреждение - %s предупреждения - %s предупреждения - %s предупреждений - Версия приложения %s уже доступна. Хотите загрузить ее? Обновление программного обеспечения Позже Скачать сейчас Проверить наличие обновлений - Проверка наличия обновлений Новые обновления отсутствуют Версия приложения Что-то пошло не так в конфигурации программы, мы не можем загрузить это событие. Пожалуйста, свяжитесь с Вашим системным администратором. @@ -900,8 +574,6 @@ Этап программы событие Временная шкала - Очистить поиск - Опционно База данных загружена Импорт базы данных Показать больше @@ -926,4 +598,25 @@ Завершить %s Деактивировать %s Окрыть повторно %s + + + %s ошибка + %s ошибки + %s ошибки + %s ошибок + + + + %s предупреждение + %s предупреждения + %s предупреждения + %s предупреждений + + + + Существует программа %s с определенными настройками. + Существуют программы %s с определенными настройками. + Существуют программы %s с определенными настройками. + Существуют программы %s с определенными настройками. + diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 6293b752657..535e27f892f 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -1,7 +1,6 @@ දෝෂය - රහස්‍ය පදය දිනපතා සතිපතා @@ -10,35 +9,23 @@ පිළිගන්න සොයන්න + + විවෘත කරන්න දෝෂය - දිනය + ඉවත් කරන්න - එකතුව + වෙනස් කරන්න පිළිගන්න + + සොයන්න - - දින - මාස - අවුරුදු - සති - සුරකින්න - උපරිම - අවවාදයයි - දෝෂය සුරකින්න දත්ත අගයන් - පිළිගන්න අවවාදයයි වසන්න - විවෘත කරන්න - දිනපතා - සතිපතා - සතිය - අසාර්ථකයි - අද ඊයේ මෙම සතිය @@ -48,12 +35,8 @@ වෙනත් විවෘත කරන්න ඊයේ - නම විවෘත කරන්න - දත්ත - නිම කළා දිනය - වසන්න ප්‍රස්ථාර සිතියම ප්‍රස්ථාර @@ -61,8 +44,6 @@ අගයක් ලෙස නරඹන්න මෙම වසර පසුගිය වසර - අගය - අගයන් යවන්න වැඩියෙන් පෙන්වන්න විස්තරය පෙන්වන්න diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 44ba1862386..23e02b2e7d9 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1,50 +1,25 @@ DHIS2 - DHIS - 2 Fel - Fel serveradress - Felaktiga uppgifter - Oväntat fel - Internt serverfel - inget värde Något gick fel - Synkronisera nu - Synkroniserar med server - Synkronisera nu - Metadata Händelser - Tracked entities - - Klar - Synkronisering färdig Synkroniseringsfel Ett fel har inträffat under synkroniseringen. Försök igen. - %s resultat hittades - Lägg till ny - Lägg till - Planera nytt Remiss - Server URL - Användarnamn - Lösenord - Logga in - Lås session Synkronisera din konfiguration Dina data kommer snart att synkroniseras Synkronisera dina data Din konfiguration är klar Dina data är klara - Konfigurationssynkroniseringen misslyckades - Data synkronisering misslyckades kontoåterställning + Dagligen Varje vecka @@ -58,15 +33,12 @@ Ange text Ange långtext Ange ett nummer - Ange ett heltal Ange ett negativt heltal Ange ett positivt heltal eller noll. Ange ett positivt heltal - Filteralternativ Välj datum + - Program avslutat - Programm inaktivt Öppen read only @@ -77,76 +49,48 @@ Synkronisera Att uppdatera - Att radera Fel - SYNKRONISERA + - Komplettering - Skapa Uppdatera - Datum Organisationsenhet - Kategorialternativ Latitud Longitud - Ny händelse - En gång - Permanent Förfallodatum Kategori kombination - DD/MM/ÅÅÅÅ Avbryt Radera - Totalt Aktiva program - Program för att registrera Registrera - Senaste besök: - verkställ - Se detaljer - VISA MER Redigera - Följ upp - Uppföljning aktiverad - Uppföljning avaktiverad Inaktivera Det här programmet tillåter inte fler händelser + händelser Hem - App Info Logga ut - Blockera sessionen - - 1 minut - 15 minuter 1 timme 1 dag (Standard) - Automatisk synkronisering varje dag Manuell - Automatisk konfigurationssynkronisering varje: 1 vecka Nuvarande synkroniseringsparametrar Återställ till standardvärden + Databasen kommer att raderas\nDu måste logga in igen. Acceptera Avbryt Synkronisera data nu Synkronisera konfigurationen nu Inställningar - Senaste synkronisering %s - Senaste synkronisering %s - Återställ app Status - inskrivning datum - Sök @@ -155,34 +99,16 @@ Skapa ny notering ta bort Lägg till anteckning - Återställ - Aktiverad - Obligatoriska fält saknas - Några obligatoriska fält saknas. Om du vill gå tillbaka nu raderas alla sparade uppgifter. - Ta bort och gå tillbaka - Sektion lista - %s frågor av %s slutförd - %s av %s klart - Slutför och stäng - Dagar - Månader - År - Veckor infall datum - Resultat på denna enhet - Online resultat Tidigare program Du har antagligen inte den nödvändiga behörigheten för att utföra åtgärden. - Lägg till som ... - Klar Period - (%d) Organisationsenhet Välkommen till DHIS2 Här kan du se dina program Du kan använda ett filter... @@ -191,15 +117,11 @@ Kontrollera andra åtgärder i huvudmenyn - Skapa relation Nästa Du måste ange minst %d av attributen för att göra en sökning - Det finns mer än %d resultat. Vänligen ange ytterligare sökkriterier - Definiera sökkriterier Importerar QR data - Spara ok Identifikation Attribut @@ -211,84 +133,38 @@ inget data Ogiltig QR-kod Det här attributet kan inte läggas till - Denna inskrivning kan inte läggas till Handla om Program för att registrera - markera allt - Det finns ingen information för de valda filteren. Var god försök ändra dem. Dela med sig Dela QR max AKTUELL TEI - Begränsa per organisationsenhet - Programlista Återställ app - Det är inte möjligt att lägga till denna relationen Kolla detta! - Varning - Fel Spara - Vill du lägga till fler data från QR? - App version: %s SDK Version: %s Ansluten till: %s Aktuell användare: %s - Lösenordet saknas. Vänligen fyll i detta fält. - Användarnamnet saknas. Vänligen fyll i detta fält. - Den här servern kör en dhis-version som skiljer sig från 2.29. Uppdatera din serverversion eller använd testmiljön för att fortsätta. - Något gick fel. Kontrollera att serveradressen, användarnamnet och lösenordet är korrekta och försök igen. - Servern kunde inte slutföra denna begäran. - Ett problem uppstod när du försökte logga in på servern - Inloggningsfel - Obligatoriska fält saknas - Oväntat fel vid sökning online - Fel vid tolkning av resultat - Online-sökning gav ett fel - Något gick fel på servern Välj ett program för att aktivera registrering Du har inte behörig behörighet - Den här funktionaliteten är inte klar än. - Ett fel uppstod vid återhämtning av relationsbeskrivning Alla %s Detta är inte ett giltigt telefonnummer - Det här är inte en giltig e-postadress - Endast negativa siffror är tillåtna Endast positiva tal eller noll tillåts - Endast positiva siffror är tillåtna - Endast värden från 0 till 1 är tillåtna - Endast värden från 0 till 100 är tillåtna - Välj datum för att filtrera Välj alternativ - Problem rapporterat - Skapa ny händelse - Vill du skapa en annan händelse? Avslutade händelser Alla händelser i detta program är avslutade. Vill du stänga programmet också? Kontrollera Hem relationer - Relationer - indikatorer - detaljer - indikatorer Vänta - Tabell - Acceptera - Server URL Varning Stänga - Färdigt - Öppen - Dagligen - Varje vecka - Avklarad Period - Status Synkronisera Denna vecka Förra veckan @@ -300,18 +176,12 @@ Gå tillbaka dataset Synkroniseringsfel - detaljer - Namn Händelsestatus - inskrivning datum Öppen Färdigt Avbruten - Data Datum - Händelse datum Gå tillbaka - Stänga diagram Karta program @@ -328,18 +198,11 @@ Föregående räkenskapsår Återkoppling indikatorer - Värde - Felaktiga uppgifter - Värdet måste vara ett heltal - Tillgängliga - Okänd - Skapa ny Skicka program Synkroniseringsfel koordinater Schemalagd - Frivillig Visa mer Visa mindre Synkronisera diff --git a/app/src/main/res/values-tet/strings.xml b/app/src/main/res/values-tet/strings.xml index 540830eb8e2..a8fc31e8316 100644 --- a/app/src/main/res/values-tet/strings.xml +++ b/app/src/main/res/values-tet/strings.xml @@ -1,11 +1,8 @@ Erru - Aumenta foun - Aumenta - Utilizador nia Naran - Seña Restaurasaun konta + Diáriu Semanal @@ -16,60 +13,40 @@ Ativu Erru + + + - Kria Atualiza - Data Kansela Soe + Edita - - Laiha dadus Pájina inisial Kansela Konfigurasaun Estadu - Fulan - Tinan - Semanas - Hamós Períodu + Próximu - Rai OK Konabá Partilla - Max - Avizu - Erru Rai Hili opsaun Pájina inisial - Indikadores - Detalles - Indikadores Favor hein - Tabela Avizu Taka - Kompletu - Diáriu - Semanal Períodu - Estadu Semana kotuk Fulan kotuk Seluk Fila ba kotuk Pakote dadus - Detalles - Naran Kompletu - Dadus - Itabo\'ot iha duni serteza kah?? Data Fila ba kotuk - Taka Mapa Detalles Tina ida ne\'e @@ -82,9 +59,5 @@ Tinan finanseiru kotuk Feedback Indikadores - Valor - Disponível - Deskoñesidu Haruka - Opsional diff --git a/app/src/main/res/values-tg/strings.xml b/app/src/main/res/values-tg/strings.xml index f892302b8c5..69f79dc603f 100644 --- a/app/src/main/res/values-tg/strings.xml +++ b/app/src/main/res/values-tg/strings.xml @@ -1,12 +1,12 @@ Хатогӣ + + Рӯйдодҳо - Иловаи нав - Илова кардан - Номи корбар - Парол + Барқароркунии ҳисоб + Ҳаррӯза Ҳафтаина @@ -20,71 +20,49 @@ Фаъол Хатогӣ + + + - Сохтан Навсозӣ - Сана - Имкони категория Тӯл Арз + Давраи ҳисобот Санаи пешбинишуда Бекор кардан Нест кардан + Таҳрир - - Ягон иттилоот нест Саҳифаи асосӣ Бекор кардан Танзимот Ҳолат - Санаи номнавис - Ҷустуҷӯ Санаи ҳисобот - Моҳҳо - Солҳо - Ҳафтаҳо - Пок кардан Давра + Навбатӣ - Захира кардан OK Нишонаҳо Рӯйдодҳо Дар бораи Мубодила - Макс - Ҷорӣ - Огоҳӣ - Хатогӣ Захира кардан Саҳифаи асосӣ Қиматҳои иттлоот - Индикаторҳо - Маълумоти муфассал - Индикаторҳо Лутфан мунтазир шавед - Ҷадвал - Идома додан Огоҳӣ Пӯшидан - Анҷом ёфт - Ҳаррӯза - Ҳафтаина - Анҷомёфта - Фиристодашуда - Иҷро нашуд - + Бисёркунҷа Давра - Ҳолат Имрӯз Дирӯз Ҳафтаи гузашта @@ -95,17 +73,9 @@ Ба ҷафо равед Маҷмӯъҳои иттилоот Дирӯз - Маълумоти муфассал - Ном - Формула - Санаи номнавис Анҷом ёфт - Иттилоот - Иҷро шуд - Оё боварӣ доред? Сана Ба ҷафо равед - Пӯшидан Харитаҳо Барномаҳо Ворид кардани иттилоот @@ -119,14 +89,9 @@ Соли молиявии гузашта Изҳори назар Индикаторҳо - Қимат - Қимат бояд адади бутун бошад - Мавҷуда - Номаълум Фиристодан Таҷдид Барномаҳо Координатаҳо Ҷадвал - Ихтиёрӣ diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 255156fec32..f6e17a02e9e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,66 +1,28 @@ Dhis2 - dhis - 2 Помилка - - Помилка - Помилки - Помилки - Помилки - - Помилка в URL адресі - Неправильні облікові дані - Неочікувана помилка - Помилка серверу - немає значення Більше немає зарезервованих значень. Зверніться до свого системного адміністратора - Більше немає зарезервованих значень. Щоб поповнити їх, підключіться до мережі - помилки Сталася помилка - Синхронізувати зараз - Синхронізація із сервером - Синхронізування - Метадані Події - Об’єкти, які відстежуються - - Завершено - Синхронізацію завершено Помилка синхронізації Під час синхронізації сталася помилка. Будь ласка, повторіть спробу. Синхронізацію завершено, але ми не отримали підтвердження від серверу для усіх записів. Ці записи все ще позначено як \"офлайн\" у програмі. Рекомендуємо повторити спробу синхронізації. - ПОПЕРЕДЖЕННЯ: Більшість Ваших даних синхронізовано, але виникли конфлікти в деяких полях. Синхронізуйте конфігурацію та повторіть спробу. Якщо попередження не зникає, перевірте стан синхронізації: програм, об’єктів, які відстежуються та випадків з конфліктами позначені як @. - Сталася помилка, і сервер не відповідає. Зверніться до свого адміністратора. - - %s результатів знайдено - Додати нове - Додати Введіть %s Ввести / Пропустити %s - Запланувати нове Направлення - URL-адреса серверу - Ім\'я користувача - Пароль - Увійти - Розблокувати сесію Синхронізація Вашої конфігурації Ваші дані незабаром буде синхронізовано Синхронізація Ваших даних Ваша конфігурація готова Ваші дані готові - Помилка синхронізації конфігурації - Помилка синхронізації даних Відновлення облікового запису - Для відновлення облікового запису потрібне підключення до мережі. Щодня @@ -80,134 +42,82 @@ Введіть текст Введіть довгий текст Введіть номер - Введіть ціле число Введіть ціле від’ємне число Введіть додатне число або нуль Введіть додатне число - Параметри фільтру Оберіть дату - Змінити календар - Програму завершено - Програма неактивна Відкрити Не завершено Подія завершена Тільки читати - Закінчити і завершити Завершено Скасовано - Активувати Активно Синхронізація Надіслати Оновити - Видалити Помилка - СИНХРОНІЗАЦІЯ - НАДІСЛАТИ - СИНХРОНІЗАЦІЯ ЧЕРЕЗ SMS - Завершення - Створити Оновити - Дата Організаційний підрозділ На вибрану дату немає відкритих організаційних підрозділів. Виберіть іншу дату або інший організаційний підрозділ. - Опція категорії Широта Довгота - Нова подія - Одноразово - Постійно Звітний період - Параметр категорії - Якщо Ви встановлюєте значення, то щодо усього рядка потрібно вказати всі значення, щоб завершити набір даних. - Деякі необхідні значення відсутні. Потрібно встановити обов’язкові значення, щоб заповнити набір даних. - Обов\'язкові поля не заповнені. Будь ласка, заповніть їх, щоб завершити набір даних. - Значення збережено! - Немає доступних періодів для вибору. Дата виконання Комбінація категорій - ДД/ММ/РРРР Скасувати Видалити - Підсумок Дата останнього випадку Немає доступних опцій Активні програми - Програми для реєстрації Зареєструвати - Останній візит: - застосувати - Подивитись подробиці - ДИВИТИСЬ БІЛЬШЕ Редагувати - Подальші дії - Подальші дії увімкнено - Подальші дії вимкнено Деактивувати Ця програма не дозволяє мати більше випадків - Немає даних випадки Головна - Інформація про додаток Вийти з системи - Заблокувати сесію Показати %s більше... Показати менше... - 1 хвилина - 15 хвилин 30 хвилин 1 година 6 год 1 день (за замовчуванням) 12 год - Автоматична синхронізація даних кожні: Вручну - Автоматична синхронізація конфігурації кожні: 1 тиждень Поточні параметри синхронізації: Установити за замовчуванням + Базу даних буде видалено. Вам буде запропоновано увійти знову. Прийняти Скасувати СИНХРОНІЗУВАТИ дані зараз СИНХРОНІЗУВАТИ конфігурацію зараз Налаштування - Дата останньої синхронізації: %s - Дата останньої синхронізації: %s Поповнити - SMS модуль: SMS шлюз Результат SMS відправнику - секунд - номер - номер Увімкнути надсилання SMS Дочекайтеся відповіді щодо результату SMS - - Є програма %s зі специфічними налаштуваннями - Є програми %s зі спеціальними налаштуваннями - Є програми %s зі спеціальними налаштуваннями - Є програми %s зі спеціальними налаштуваннями - Налаштування застосовуються Всюди за програмою @@ -225,8 +135,6 @@ Цей журнал містить усі помилки, спричинені синхронізацією Ваших даних і метаданих. Видалити локальні дані Усі дані, що зберігаються на Вашому пристрої, буде видалено. Дані, які не синхронізовані із сервером, буде втрачено. - Скинути дані програми та конфігурацію - Вашу конфігурацію та дані додатку, що зберігаються на пристрої, буде видалено. Вас потрібно увійти знову, і дані, які не синхронізовані із сервером, будуть втрачені. Налаштування SMS Перевірте та відредагуйте параметри, що стосуються SMS-шлюзу Оновлення додатку @@ -235,8 +143,6 @@ Статус - Дата реєстрації - Пошук @@ -257,29 +163,15 @@ Координати для - Список розділів - %s питань %s виконано - %s з %s завершено - Завершити та закрити - Дні - Місяці - Роки - Тижні Дата інциденту - Локальні результати - Онлайн результати Минулі програми Ви не маєте необхідного дозволу для виконання цієї дії - Додати як… - Очистити Період - (%d) Організаційний підрозділ - Рівень %d Ласкаво просимо до dhis2 Тут Ви можете бачити свою активність Ви можете застосувати фільтр… @@ -288,16 +180,11 @@ Перевірте інші дії в головному меню - Створити зв’язок Наступне Для пошуку потрібно ввести принаймні %d атрибутів - Для пошуку потрібно ввести принаймні один із атрибутів - Є більше ніж %d результатів. Будь ласка, введіть додаткові критерії пошуку - Введіть критерії пошуку Імпорт QR-даних - Зберегти ОК Ідентифікація Атрибути @@ -309,32 +196,20 @@ немає даних Невірний QR-код Цей атрибут не можна додати - Цю реєстрацію не можна додати Про Програми для реєстрації - обрати усі - очистити вибір - Немає інформації для вибраних фільтрів. Будь ласка, спробуйте змінити їх. Поділитися Поділитись QR Макс Поточний Об’єкт, який відстежується Об’єкти, які відстежуються - Обмеження за організаційним підрозділом - Обмеження за програмою - Список програм Скинути дані програми & конфігурацію - Неможливо додати цей зв’язок Перевірити це! - Попередження - Помилка Зберегти Зарезервовані значення - Бажаєте додати більше даних із QR? - Версія додатку: %s Версія SDK: %s @@ -345,128 +220,42 @@ Розроблено та підтримується DHIS2 проєктом, який є частиною Університету Осло, факультету інформатики . Щоб отримати додаткову інформацію або отримати відповіді на запитання щодо цієї програми, напишіть на android@dhis2.org - Пароль відсутній. Будь ласка, заповніть це поле. - Ім\'я користувача відсутнє. Будь ласка, заповніть це поле. - На цьому сервері працює інша версія dhis, ніж 2.29. Щоб продовжити, оновіть версію сервера або скористайтеся середовищем тестування. - На цьому сервері працює версія dhis, що відрізняється від %s. Щоб продовжити, оновіть версію сервера або скористайтеся середовищем тестування. - Сталася помилка. Будь ласка, перевірте правильність URL-адреси сервера, імені користувача та пароля і повторіть спробу. - Сервер не зміг виконати цей запит. - Виникла проблема під час спроби входу на сервер. - Помилка входу - Не заповнено обов\'язкові поля - Неочікувана помилка під час пошуку онлайн - Помилка аналізу результатів - Пошук онлайн спричинив помилку - Сталася помилка на сервері Оберіть програму, щоб розпочати реєстрацію Ви не маєте необхідного дозволу - Ця функція ще не готова. - Помилка при отриманні примітки зв’язку Усі %s Це недійсний номер телефону - Ця електронна адреса невірна - Допускаються лише від’ємні числа Дозволені лише додатні числа або нуль - Допускаються лише додатні числа - Допускаються лише значення від 0 до 1 - Допускаються лише значення від 0 до 100 - Обрати дати для фільтрування Обрати опцію - Про проблему повідомлено - Створити новий випадок - Ви хочете створити інший випадок? Завершені випадки Усі випадки цієї програми завершено. Ви бажаєте закрити також і програму? - Бажаєте також завершити реєстрацію? Перевірка Головна ПРИКЛАД - Додати віджет Показати допомогу - Синхронізація Ваших даних… - ...Вашої конфігурації... - …встановити, скільки записів завантажувати із сервера… - Перевірка та управління кількістю зарезервованих значень, які залишились - Перевірка, чи при підключенні до серверу сталась помилка - Звільнити місце на диску - …або видалити усе, щоб почати спочатку. - Знаходження усіх об\'єктів, які відстежуються, як на локальному пристрої, так і на онлайн-сервері. - Ви можете перемикатися на інші програми - Пошук або додавання нових об’єктів. Вам потрібно виконати пошук, перш ніж додати новий! - Натисніть тут, щоб очистити поля та перезапустити пошук. Це інформаційна панель об\'єкту, який відстежується Переглянути інформацію про профіль об\'єкту, який відстежується… - … Поділитись інформацією за допомогою QR-коду або SMS… - …Або позначити об\'єкт, який відстежується, для подальших дій - Натисніть, щоб створити або запланувати випадок або зробити перенаправлення до іншого організаційного підрозділу. Тут Ви побачите усі випадки. Натисніть, щоб переглянути деталі випадку. Скористайтесь меню, щоб додати зв’язки, переглянути індикатори або написати примітки. - Натисніть, щоб переглянути усі реєстрації Вашого об\'єкту, або створити нову. - Натисніть, щоб видалити цей випадок. Цю дію не можна скасувати. - Завершити випадок. - Тут будуть перераховані всі випадки, які пов’язані з цією програмою. - Натисніть, щоб додати новий випадок Введіть усі дані для створення випадку Натисніть, щоб перевірити статус завершення випадку. - Натисніть, щоб завершити або повторно відкрити випадок - Читайте та зберігайте інформацію з інших пристроїв за допомогою QR - Синхронізуйте свої дані з сервером - Заблокуйте сесію 4-значним кодом. Не забудьте свій код! - Вийдіть, щоб змінити користувача або адресу серверу. Ви зможете увійти знову, навіть якщо немає підключення до Інтернету. - %s: зв\'язки Немає зареєстрованих випадків, натисніть «+», щоб додати новий. - Немає зареєстрованих випадків. Значення даних Інформація Не вдалося прочитати цей ідентифікатор Видалити локальні дані Огляд - Зв\'язки - Показники - Аналітика Індикаторів для цієї програми немає. - Нотатки - Не вдалося завершити - Не вдалося зберегти - Форма містить попередження - Що би Ви хотіли зробити? - У деяких полях є помилки. Будь ласка, перевірте їх, щоб мати можливість завершити Усі реєстрації Показати інформаційну панель з усіма активними програмами - Відкрити журнал помилок синхронізації Журнал помилок синхронізації Поділитися з... - Помилка синхронізації конфігурації. На Вашому сервері можуть бути помилки. Натисніть тут для отримання додаткової інформації. - ПОПЕР - ДАЛІ - Деталі - Показники - Нотатки - - Завершити пізніше - - Закінчити - Поля з помилками - У деяких полях є помилки. Вони не збереглися. Будь ласка, перевірте їх. Значення має бути унікальним, знайдено збіг. Значення не збереглось. Будь ласка, перевірте це. - Виявлено збіг в унікальному полі. Будь ласка, перевірте форму, щоб зберегти реєстрацію. Будь ласка, почекайте Пропустити - Перепланувати Для цієї частини немає інформації - Прострочене - Є одне правило програми, яке не підтримується. Будь ласка, зверніться до інструкції - Немає інформації для цього поля - Порожні облікові дані - Щоб мати можливість увійти за допомогою сканера відбитків пальців, спочатку потрібно використовувати спосіб за замовчуванням - Біометрична безпека - Таблиці - Таблиця - https:// Несинхронізовані дані буде втрачено. Ви дійсно хочете продовжити? - Прийняти Не вдалося видалити дані. Будь ласка, повторіть спробу. Видалення локальних даних успішно завершено. Необхідно вибрати принаймні один організаційний підрозділ @@ -478,6 +267,7 @@ Погодитись Продовжити URL-адреса серверу + Введіть URL-адресу серверу DHIS 2 для Вашої організації, надану Вашим системним адміністратором. Альтернативно, Ви можете відсканувати QR-код, що містить URL-адресу Вашого сервера Забули код? Що б Ви хотіли зробити для цього випадку? КІН @@ -488,33 +278,16 @@ Завантаження Зв\'язок Надіслано через SMS - Для цієї програми немає конфліктів - Підключення до мережі недоступне, але, схоже, є телефонні послуги - можна використовувати SMS. - Підключення до мережі недоступне. Закрити Тепер Ви можете перевірити інформацію про синхронізацію, натиснувши тут - Ці дані зберігаються лише на Вашому пристрої, вони не синхронізуються із сервером. - Дані оновлено! Усі Ваші дані надіслано на сервер. Ви не можете пов\'язати запис із самим собою - Розблокувати сесію - Завершити - Відкрити - Тип значення, що не підтримується Змінити вид календаря - 15 хвилин - Щогодини - Щодня - Щотижня - Тиждень - Налаштування синхронізації: %s Запис, до якого Ви намагаєтеся отримати доступ, не зареєстрований. Ви не можете отримати доступ до інформаційної панелі. Помилка форматування Видалити %s Ви не маєте повноважень видаляти цей запис - Це недійсна URL-адреса Коригування форми… - Номер одержувача Чи підтверджуєте надсилання %dповідомлень ? Надсилання %1$d/%2$d Очікування обсягу підтвердження - %d @@ -532,51 +305,29 @@ Шлюз не встановлено Користувач не ввійшов у систему SMS модуль вимкнено - Триває надсилання ще одного SMS Модуль SMS не запущено, необхідні метадані не знайдені - Завершено Підрахунок SMS не приймається Розпочато відправку SMS Дані зчитано та конвертовано. Ідентифікатор подання: %d - Помилка надсилання SMS-повідомлення Реєстрація об\'єкту, який відстежується, та випадку за допомогою SMS Надсилання випадку через SMS Надсилання набору даних через SMS - Початок - Виконується - Надіслано - Помилка - Початок синхронізації. Синхронізація... - Синхронізацію завершено. - Під час синхронізації сталася помилка. - Під час синхронізації сталася помилка. Натисніть тут, щоб перевірити журнали помилок - Синхронізацію скасовано. Скопійовано - Зображення обрано - Показати на карті - Показати списком - %s є обов’язковим, але не заповнено. - Зафіксовано багатокутник Багатокутник - Відбиток пальця - Будь ласка, піднесіть палець до сканеру відбитків пальців. Безпека З міркувань безпеки цю версію додатку не можна використовувати на пристроях з кореневим доступом. Будь ласка, використовуйте навчальну версію. - Є деякі помилки. Перш ніж продовжити, виправте їх. Застосовано фільтри Фільтрів не застосовано Період Орг. підрозділ - Сортування - Статус Синхронізація Призначено мені Сьогодні @@ -591,8 +342,6 @@ Від - До Інше Будь-коли - Помилка синхронізації. Помилка синхронізації. Перевірте значення даних, щоб спробувати вирішити проблему, і повторіть спробу. Якщо збій продовжується, зверніться до системного адміністратора - Ці дані були надіслані на сервер, але їх збереження не гарантовано Відкрити Запланувати Прострочено @@ -608,7 +357,6 @@ SMS-шлюз не може бути порожнім Немає реєстрацій для завантаження Цей convertTask не підтримується - Більше не показувати екран блокування Встановити PIN Помилка Змінити дату @@ -633,11 +381,7 @@ Оберіть організаційний підрозділ Введіть URL-адресу Введіть значення - Оберіть зображення - Сканувати код - Оберіть варіант Зарезервовані значення, завантажені для атрибута об\'єкту - Зарезервовані значення, завантажені для атрибута об\'єкту %s залишилося зарезервованих значень Управління зарезервованими значеннями Випадків для завантаження @@ -657,55 +401,29 @@ Не синхронізовано Помилка синхронізації Синхронізовано через SMS - Деталі - Коментарі Збережено! - Хочете перевірити якість даних? - Все виглядає добре! - Ви бажаєте також завершити набір даних? - Позначити як виконане - Ім\'я - Формула - Все одно завершити Відсутній %s %s від %s %s Статус випадку - Дата реєстрації Немає даних. Натисніть «+», щоб додати новий запис Немає даних. Ви не маєте дозволу додавати нові записи. Немає опису чи інструкції Дані для перегляду - Збережено та завершено! Вже є активна реєстрація Відкрити Завершити Скасовано - Дані Не вдалося завантажити вибраний %s. - Виконано з %s: %s - Оновлено: %s - Обрати - Ми не змогли перевірити якість даних. Зверніться до свого адміністратора - Чи Ви впевнені? - Хочете знову відкрити набір даних? Дата - Дата випадку - %s не має публічних атрибутів. Виберіть програму Політика конфіденційності Натисніть тут, щоб ознайомитися з нашою політикою конфіденційності Значення координат недійсні + Ви не можете зареєструвати %s з інших організаційних підрозділів у цій програмі, оскільки вона закрита для Вас. Зверніться до свого адміністратора. Це поле обов\'язкове для заповнення З міркувань безпеки цю версію додатку не можна використовувати з adb. Будь ласка, використовуйте навчальну версію. Повернутися - Закрити додаток - Детальніше - Детальніше - Закрити Копіювати в буфер обміну - Скопійовано до буферу обміну - Інформація про помилку Ми знайшли помилку під час синхронізації з Вашим сервером. Якщо можливо, повідомте про помилку системному адміністратору. - Нам не вдалося почати сесію із Вашим користувачем Є заплановані випадки, які не будуть перенесені Діаграми Список @@ -754,46 +472,27 @@ Відгук Показники Значення + Відмовлено у використанні камери. Вам потрібно ввімкнути цей дозвіл, щоб використовувати цю функцію. Діаграми та індикатори Зробити фото - Обрати з галереї - Готово! Ця програма закрита для Вас Опишіть причину тут - Автентифікація - Надана URL-адреса сервера не є екземпляром dhis - Неправильні облікові дані - Невідомий сервер - Значення має бути цілим числом Сесія неактивна Ваша поточна сесія неактивна. З міркувань безпеки Вам потрібно знову ввести свої облікові дані - Значення Тиждень %d %s до %s Не показувати знову Конфігурація функції Показати як діаграму Радар - Неможливо встановити тип координат Для подальших дій %s - %s не можна проаналізувати як дату Це поле не вдалося оновити. Будь ласка спробуйте ще раз - Версія - Доступно Ці дані не можна редагувати, оскільки вони позначені як завершені Ці дані не можна редагувати, оскільки час їх редагування минув Ви не маєте дозволу редагувати ці дані Ці дані не можна редагувати, оскільки організаційний підрозділ закрито Ви не маєте дозволу редагувати ці дані - Ви не можете редагувати дані з цього організаційного підрозділу - Скинути пошук + Ви не можете редагувати дані з цього організаційного підрозділу Онлайн-пошук дав таке повідомлення: - Попередження про правила програми - Існує проблема конфігурації, яка спричиняє помилку у правилах. Зверніться до адміністратора. - ОНОВИТИ - Невідомо - Оновити всі дані - Ви досягли максимальної кількості акаунтів (%d). Щоб додати новий обліковий запис, увійдіть і видаліть один із своїх облікових записів Видалити обліковий запис - Управління обліковими записами Усунення несправностей конфігурації Мова Натисніть тут, щоб змінити мову додатку @@ -811,14 +510,13 @@ Для цього пошуку результатів немає Забагато результатів Спробуйте змінити пошук, щоб звузити результати. - Створити новий Новий 1%s - Для пошуку необхідно ввести хоча б %s атрибут OK Почніть пошук, щоб знайти будь-який %s Ви можете знайти або створити новий %s Ви бажаєте позначити цю форму як завершену? Якщо Ви зараз вийдете, зміни буде скасовано. + У вас є кілька повідомлень про помилки. Бажаєте їх переглянути? Відкрито знову Обліковий запис вимкнено Ваш обліковий запис було вимкнено адміністратором. @@ -828,18 +526,8 @@ Поточна дата: %s Пристрій: %s Версія ОС: %s - Відкрити деталі %s - %s Деталі Синхронізація %s… - SMS відправлено. Очікування відповіді серверу. - SMS підтверджено на сервері. Оновлення статусу - Ми не можемо підтвердити, що SMS надійшло на сервер. Оновлення статусу - SMS відправлено. Оновлення статусу. - SMS не відправлено. - Відкриття програми SMS. Будь ласка, не змінюйте повідомлення. Надіслати SMS за допомогою - Очікується підтвердження вручну - Синхронізація подій Зареєстровано в: Власник: Потрібна синхронізація @@ -849,7 +537,6 @@ Не зараз Оновлення додатку Перевірити оновлення - Перевірка оновлень Оновлення відсутні Версія додатку Не синхронізовано @@ -869,15 +556,17 @@ Введіть %1$s Етап програми подія - Очистити пошук - Необов\'язково Показати більше Показати менше Синхронізація - Показати поля - Приховати поля Синхронізація %s… Показати опис Приховати опис Перевести - + + Є програма %s зі специфічними налаштуваннями + Є програми %s зі спеціальними налаштуваннями + Є програми %s зі спеціальними налаштуваннями + Є програми %s зі спеціальними налаштуваннями + + diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 8afc2fd4f7a..21515732ec1 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -1,16 +1,14 @@ غلطی - میٹا ڈیٹا + + تقریبات - نیا شامل - اضافہ - نیا شیڈول + حوالہ دیتے ہیں - صارف کا نا٠- پاسورڈ اکاؤنٹ بحالی + روزانہ ہفتہ وار @@ -19,104 +17,70 @@ بانٹیں تلاش کریں + + کھلا صرف دیکھنے کی حد تک مکمل منسوخ - چالو کریں فعال غلطی + - پروگرام کا مرحلہ - تخلیق اپڈیٹ - تاریخ - اختیاری قسم طول لمبائی - نئی تقریب + اخری تاریخ منسوخ حذف - کُل کوئی اختیار دستیاب نہیں ہے فعال پروگرام اندراج کریں + + ترمیم غیر فعال - - کوئی مواد نہیں گھر منسوخ ترتیبات حالت - اندراج کی تاریخ - تلاش کریں رپورٹ کی تاریخ نوٹ شامل + + - دن - مہینے - سال - ہفتوں واقعات کی تاریخ - کلیئر مدت + اگلہ - محفوظ ٹھیک خصوصیات تقریبات تعلقات متعلق بانٹیں - زیادہ سے زیادہ - موجودہ - وارنگ - غلطی محفوظ گھر ڈیٹا اقدار - تعلقات - اشارے - تجزیات - نوٹس - تفصیلات - اشارے - نوٹس انتظار کیجئے چھوڑ دو - تجزیہ کریں - زیادہ سے زیادہ - ٹیبل - جاری رہے وارنگ تعلقات بند - مکمل - کھلا - غیر قابل قدر قیمت کی قسم - روزانہ - ہفتہ وار - ہفتہ - مکمل - کام جاری ہے - ناکام - + کثیر زبان مدت - ترتیب دیں - حالت آج کل اس ہفتے @@ -132,22 +96,12 @@ واپس ڈیٹا سیٹس کل - تفصیلات - تبصرے - نا٠- فارمولہ واقعہ کی حیثیت - اندراج کی تاریخ کھلا مکمل منسوخ - ڈیٹا - ہو گیا - کیا تمہیں یقین ہے؟ تاریخ - واقعہ کی تاریخ واپس - بند چارٹس فہرست نقشہ @@ -172,10 +126,6 @@ آخری مالیاتی سال رائے اشارے - قدر - ویلیو انٹیجر ہونی چاہیے - موجودہ - نامعلوم زبان ٹھیک ہے\! بھیجو @@ -187,7 +137,6 @@ شیڈول شیڈول کردہ پروگرام کا مرحلہ - اختیاری اور دیکھاو کم دکھائیں diff --git a/app/src/main/res/values-uz-rUZ/strings.xml b/app/src/main/res/values-uz-rUZ/strings.xml index 05321637d82..7586c57baf5 100644 --- a/app/src/main/res/values-uz-rUZ/strings.xml +++ b/app/src/main/res/values-uz-rUZ/strings.xml @@ -1,52 +1,26 @@ DHIS2 - DHIS - 2 Xatolik - Server URLi notoʼgʼri - Notoʼgʼri maʼlumot - Kutilmagan xatolik - Serverdagi ichki xatolik Saqlangan boshqa qiymatlar yoʼq. Tizim administratoriga murojaat qiling. - Saqlangan boshqa qiymatlar yoʼq. Ularni toʼldirish uchun tarmoqqa ulaning. Nimadir notoʼgʼri bajarildi - Hozir sinxronlashtiring - Server bilan sinxronlash - Hozir sinxronlash - Metadata Hodisa/tadbirlar - Kuzatuvdagi obʼektlar - - Tugadi - Sinxronlash tugallandi Sinxronlashda xatolik Sinxronlash paytida xatolik yuz berdi. Iltimos, qayta urinib koʼring - OGOHLАNTIRISh: Maʼlumotlarning aksariyat qismi sinxronlashtirildi. Lekin, baʼzi ziddiyatlar kelib chiqdi. Konfiguratsiyani sinxronlang va qaytadan urinib koʼring. Аgar ogohlantirish davom etsa, ularning sinxronlash holatini tekshiring: @ sifatida belgilangan dasturlar, TEIni va hodisa/tadbirlarni. - 1 %s natijalar topildi - Янгисини қўшиш - Qoʼshish - Yangi reja (jadval) Yoʼnalishlarga - Server URLi - Фойдаланувчи исми - Parol - Kirish - Sessiya ochish Konfiguratsiyangiz sinxronlashmoqda Maʼlumotlaringiz tez orada sinxronlashtiriladi Maʼlumotlaringiz sinxronlashmoqda Sizning yangi konfiguratsiyangiz tayyor Maʼlumotingiz tayyor - Konfiguratsiyani sinxronlash amalga oshmadi - Maʼlumotlarni sinxronlash amalga oshmadi Аkkauntni tiklash + Кунлик Ҳафталик @@ -63,116 +37,72 @@ Matn kiriting Uzun matn kiriting Sonni kiriting - Butun son kiriting Butun manfiy son kiriting Butun musbat yoki nol sonini kiriting Musbat son kiriting - Filtr variantlari Sanani tanlang - Kalendardan tanlang - Dastur tugallandi - Dastur nofaol Ochiq Faqat oʼqish uchun - Yakunlash va toʼldirish bajarildi Bekor qilindi - Faollashtirish Faol Sinxronlash Yuborish uchun Yangilash uchun - Oʼchirib tashlash uchun Xatolik - SINXRONLАSh - SMSni SINXRONLАSh - Tugatish - Яратиш Янгилаш - Sana Tashkiliy birlik Tanlangan sanalar uchun ochiq Tashkiliy boʼlimlar mavjud emas. Iltimos, boshqa sana yoki boshqa Tashkiliy boʼlimni tanlang. - Kategoriya variantlari Kenglik Kenglik - Yangi tadbir - Bir marta - Doimiy Hisobot davri - Kategoriya varianti - Аgar siz qiymatni oʼrnatgan boʼlsangiz, maʼlumotlar toʼplamini toʼldirish uchun qatordagi barcha qiymatlar boʼlishi lozim. - Baʼzi zaruriy qiymatlar mavjud emas. Maʼlumotlar toʼplamini toʼldirish uchun sizga barcha shart boʼlgan qiymatlar mavjud boʼlishi lozim. - Shart boʼlgan maʼlumotlar yetishmayapti. Maʼlumotlar toʼplamini shakllantirish uchun ularni toʼldiring. - Qiymat saqlandi! - Tanlash uchun yaroqli davr mavjud emas Muddati Kategoriya kombinatsiyasi - KK/OO/YYYY Бекор қилиш Oʼchirib tashlash - Jami Oxirgi tadbir sanasi Variantlar mavjud emas Faol dasturlar - Roʼyxatga olish dasturlari Roʼyxatga kiritish - Oxirgi tashrif + - Tafsilotlarni koʼring - KOʼPROQ KOʼRISh Tahrirlash - Kuzatib borish - Kuzatuv yoqilgan - Kuzatuv oʼchirilgan Faolsizlantirish Ushbu dastur yana koʼproq tadbirlarni qabul qilmaydi - - Maʼlumot yoʼq Бош саҳифа - Ilova haqida maʼlumot - Sessiyani bloklash - 1 daqiqa - 15 daqiqa 30 daqiqa 1 soat 6 soat 1 kun (standart) 12 soat - Maʼlumotlarni avtomatik sinxronlash, har: Qoʼllanma - Konfiguratsiyani avtomatik sinxronlash, har: 1 hafta Joriy sinxronlash parametrlari Standart holatga qaytish + Maʼlumotlar bazasi oʼchiriladi. Sizdan qayta kirish soʼraladi. Qabul qiling Бекор қилиш Maʼlumotlarni sinxronlash, hozir Konfiguratsiyani sinxronlang, hozir Sozlamalar - Oxirgi sinxronizatsiya: %s - Oxirgi sinxronizatsiya: %s Toʼldirish - SMS modul: SMS yoʼlak SMS natijalarini yuboruvchi - soniyalar - raqam - raqam SMS yuborishni yoqish SMS natijalar tarqatilishini kutish Sozlamalar cheklangan @@ -192,15 +122,11 @@ Ushbu jurnalda maʼlumotlar va MetaMaʼlumotlarni sinxronlashda vujudga kelgan barcha xatoliklar qayd etilgan Lokal maʼlumotlarni oʼchirib tashlash Qurilmangizda saqlangan barcha Kuzatuv-Nazorat ilovasi maʼlumotlari oʼchirib tashlanadi. Server bilan sinxronlanmagan maʼlumotlar yoʼqotiladi. - Ilova maʼlumotlarining asl standart holatini tiklash; konfiguratsiya - Qurilmangizda saqlangan konfiguratsiya va Ilova maʼlumotlari oʼchiriladi. Sizdan qayta tizimga kirish soʼraladi. Server bilan sinxronlanmagan maʼlumotlar yoʼqotiladi. SMS sozlamalari SMS shlyuzi bilan bogʼliq parametrlarni tekshiring va tahrirlang Status - Qayd qilingan sana - Izlash @@ -209,11 +135,6 @@ Yangi eslatma yozing aniq Izoh qoldirish - Qaytish - Faollashtirildi - Majburiy maydonlar qoldirib ketilgan - Baʼzi majburiy maydonlar qoldirib ketilgan. Аgar hozir orqaga qaytsangiz, barcha saqlangan maʼlumotlar oʼchib ketadi. - Baʼzi majburiy maydonlar qoldirib ketilgan. Аgar orqaga qaytmoqchi boʼlsangiz, Iltimos, ularni toʼldiring. Maʼlumotingiz saqlanmadi Аgarda hozir orqaga qaytsangiz, barcha kiritilgan maʼlumotlar oʼchiriladi. Oʼchirib tashlash va orqaga qaytish @@ -221,29 +142,15 @@ Uchun koordinatalar - Boʼlimlar roʼyxati - %s savollardan %s bajarildi - %s dan %s bajarildi - Yakunlash va yopish - Kunlar - Oylar - Yillar - Haftalar Hodisa sanasi - Lokal natijalar - Onlayn natijalar Аvvalgi dasturlar Ushbu amalni bajarish uchun sizda zaruriy ruxsat yoʼq - Sifatida qoʼshish... - Аниқ davr - (%d) tashkiliy boʼlimi - %d darajasi DHIS2 ga Xush kelibsiz Bu yerda щz faoliyatingizni kuzatishingiz mumkin Filtrni щzgartirishingiz mumkin @@ -252,16 +159,11 @@ Аsosiy menyudan boshqa harakatlarni tekshirish - Oʼzaro aloqa munosabatlarni yaratish Кейинги Qidiruv uchun kamida %d atributni kiritish kerak - Qidiruv uchun kamida 1 ta atributni kiritish kerak - %d dan ortiq natijalar mavjud. Iltimos, qoʼshimcha qidiruv mezonlarini kiriting - Qidiruv mezonlarini kiriting QR qurilmalarni import qilish - Saqlash OK Identifikatsiya Аttributlar @@ -273,32 +175,20 @@ maʼlumotlar yoʼq QR kod yaroqsiz Ushbu atributlarrni qoʼshib boʼlmadi - Ushbu qaydlarni qoʼshib boʼlmadi Ҳақида Roʼyxatga olish dasturlari - hammasini belgilash - aniq tanlov - Tanlangan filtrlar uchun maʼlumot yoʼq. Iltimos, ularni oʼzgartirib koʼring. Улашиш QR ni ulashish Максимум Joriy TEI TEI lar - Tashkiliy boʼlimlar orqali chegaralash - Dastur orqali chegaralash - Dasturlar roʼyxati Ilova maʼlumotlarining asl standart holatini tiklash; konfiguratsiya - Ushbu oʼzaro munosabatlarni qoʼshib boʼlmaydi Buni tekshiring! - Ogohlantirish - Xatolik Saqlash Himoyalangan qiymatlar - QR dan koʼproq maʼlumotlarni qoʼshmoqchimisiz? - Ilova versiyasi: 1 %s SDK versiyasi: 1 %s @@ -309,165 +199,57 @@ Oslo Universiteti tomonidan ishlab chiqilgan va qoʼllab quvvatlanadigan DHIS2 loyihasi Ushbu dastur haqida qoʼshimcha maʼlumot yoki savollar uchun android@dhis2.org elektron manziliga yozing - Parol qoldirib ketilgan, Iltimos, ushbu maydonni toʼldiring. - Foydalanuvchi ismi qoldirib ketilgan. Iltimos, ushbu maydonni toʼldiring. - Ushbu server 2.29 dan farqli versiyani ishlatmoqda. Server versiyasini yangilang yoki davom etish uchun sinov muddatidan foydalaning. - Ushbu server %s dan farqli versiyani ishlatmoqda. Server versiyasini yangilang yoki davom etish uchun sinov muddatidan foydalaning. - Nimadir notoʼgʼri bajarildi. Server URL manzili, foydalanuvchi nomi va parol toʼgʼri ekanligini tekshiring va qaytadan urining. - Server ushbu soʼrovni bajara olmadi. - Serverga kirishga xatolik yuz berdi. - Kirish xatosi - Majburiy maydonlar qoldirib ketilgan - Onlayn qidiruvda kutilmagan xatolik - Natijalarni tahlil qilishda xatolik yuz berdi - Onlayn qidiruvda xattolik yuz berdi - Serverda xatolik yuz berdi Roʼyxatdan oʼtishni yoqish uchun dasturni tanlang Sizda kerakli ruxsat yoʼq - Ushbu funktsiya hali tayyor emas. - Oʼzaro aloqalar yorligʼini olishda xatolik yuz berdi Barchasi %s Bu yaroqli telefon raqami emas - Bu yaroqli elektron pochta manzili emas - Faqat manfiy raqamlarga ruxsat beriladi Faqat musbat raqamlar yoki nolga ruxsat beriladi - Faqat musbat raqamlarga ruxsat beriladi - Faqat 0 dan 1 gacha boʼlgan qiymatlarga ruxsat beriladi - Faqat 0 dan 100 gacha boʼlgan qiymatlarga ruxsat beriladi - Filtrlash uchun sanalarni tanlang Variant tanlang - Muammo haqida xabar berildi - Yangi hodisa/tadbir yaratish - Haqiqatdan ham boshqa hodisa/tadbir yaratmoqchimisiz? Hodisa/Tadbir Tugallandi Ushbu dasturdagi barcha hodisa/tadbirlar yakunlandi. Haqiqatdan ham ushbu dasturni yopishni xoxlaysizmi? - Haqiqatdan ham roʼyxatdan oʼtishni tugatmoqchimisiz? Tekshiring Бош саҳифа MISOL - Vidjet qoʼshish Yordam bering - Maʼlumotlaringizni soxtalashtiring... - ...sizning konfiguratsiyangiz... - ...serverdan qancha yozuvlarni yuklab olish kerakligini belgilang... - Qancha zaxira qiymatlari qolganligini tekshiring va boshqaring - Serverga ulanish amalga oshmaganligini tekshiring - Diskda joy boʼshating - ...yoki boshidan boshlash uchun hamma narsani oʼchirib tashlash. - Oʼzingizning qurilmangizni ham lokal ham serverda toping. - Sizga boshqa dasturlarga oʼtishingiz mumkin - Yangi obʼektlarni qidirish yoki qoʼshish. Siz avval qidirishingiz zarur, yangisini qoʼshishdan oldin! - Maydonlarni tozalash va qidiruvni qayta boshlash uchun shu yerni bosing. Bu shaxs-obʼektlarni boshqaruv paneli Shaxs-obʼektlarning profil maʼlumotlarini koʼring... - ...QR yoki SMS orqali maʼlumot ulashish... - ...yoki kuzatuv uchun shaxsni belgilang - Hodisa/tadbirni yaratish yoki rejalashtirish yoki boshqa Tashkiliy boʼlimga murojaat qilish uchun bosing. Bu yerda siz barcha hodisa/tadbirlarni koʼrasiz. Hodisa/tadbir tafsilotlarini koʼrish uchun bosing. Oʼzaro munosabatlarni qoʼshish, koʼrsatgichlarni koʼrish yoki yozuvlarni yozish uchun menyu boʼylab harakatlananing. - Tashkilotingiz tomonidan barcha qayd qilinganlarni koʼrish yoki yangisini yaratish uchun, bosing - Ushbu hodisa/tadbirni oʼchirib tashlash uchun bosing. Ushbu harakat qaytmas. - Hodisa/tadbirni yakunlash. - Ushbu dastur bilan bogʼliq barcha hodisa/tadbirlar bu yerda keltirilgan. - Yangi hodisa/tadbir qoʼshish uchun, bosing Hodisa/tadbir yaratish uchun barcha maʼlumotlarni kiriting Hodisa/tadbirning tugash holatini tekshirish uchun, bosing. - Hodisa/tadbirni yakunlash yoki qayta ochish uchun, bosing - QR orqali boshqa qurilmalardan maʼlumotlarni oʼqing va saqlang - Maʼlumotlarni server bilan sinxronlashtiring - 4 xonali kod bilan sessiyani bloklash. Kodingizni unutmang! - Foydalanuvchini yoki serverni oʼzgartirish uchun tizimdan chiqing. Internetda ulanish boʼlmasa ham, siz yana tizimga kirishingiz mumkin. - %s: Hech qanday hodisa/tadbir roʼyxatdan oʼtkazilmagan, yangisini qoʼshish uchun \"+\" tugmasini bosing. - Hech qanday hodisa/tadbir roʼyxatdan oʼtkazilmagan. Maʼlumotlarning qiymatlari Аxborot ID oʼqib boʼlmadi Lokal maʼlumotlarni oʼchirib tashlash Qisqacha koʼzdan kechirish - Oʼzaro aloqalar - Koʼrsatgichlar - Аnalitika Ushbu dastur uchun indikator mavjud emas. - Izohlar - Tugallanmadi - Baʼzi maydonlarda xatolar mavjud. Iltimos, ularni bajarish uchun ularni tekshiring Barcha qaydlar Barcha faol dasturlar bilan boshqaruv panelini koʼrsatish - Sinxronizatsiyadagi xatoliklar jurnalini ochish Sinxronizatsiyadagi xatoliklar jurnali bilan ulashish ... - Sizning konfiguratsiyangiz sinxronlashmadi. Sizning serveringizda xatolar boʼlishi mumkin. Qoʼshimcha maʼlumotlar uchun bu yerni bosing. - Тафсилотлари - Koʼrsatgichlar - Izohlar - - Keyinroq toʼldirish - - Tugatish - Xatoliklar mavjud boʼlgan maydonlar - Baʼzi maydonlarda xatoliklar mavjud.Ular saqlanmadi. Iltimos,, ularni tekshirib koʼring. Qiymat noyob boʼlishi lozim, bir biriga oʼxshash topildi. U saqlanmadi. Iltimos, tekshirib koʼrish. - Noyob sohada bir biriga oʼxshash qiymat topildi. Roʼyxatdan oʼtishni saqlash uchun formani tekshirish. Iltimos, kuting Oʼtkazib yuborish - Qayta rejalang Ushbu ekrandagi haqida maʼlumot yoʼq - Muddat oʼtgan - Bu yerda dasturning qoʼllab quvvatlanmaydigan bir qoidasi mavjud. Iltimos, hujjatlarni tekshiring. - Ushbu maydon uchun maʼlumot yoʼq - Ishonch yorliqlari boʼsh - Barmoq izlari skaneri yordamida tizimga kirish uchun avval standart usuldan foydalanishingiz kerak - Biometrik Xavfsizlik - Jadvallar - Жадвал - https:// Sinxronizatsiya qilinmagan maʼlumotlar yoʼqoladi. Haqiqatdan ham davom ettirishni xoxlaysizmi? - Qabul qiling Maʼlumotlarni oʼchirib boʼlmadi. Iltimos, qayta urinib koʼring. Lokal maʼlumotlarni oʼchirish muvaffaqiyatli yakunlandi. - Kamida bitta tashkiliy boʼlim tanlanishi kerak - aʼlumotlarni yuboring - DHIS2 da foydalanuvchi identifikatorlari bilan bogʼliq xatoliklar jurnallarini ulashish orqali ushbu dasturni yaxshilashga yordam berish. - Maydonlarni belgilash - Konfiguratsiyani sinxronlashtirish paytida sizning internet aloqangizda uzilish yuz berdi. Iltimos, keyinroq qayta urinib koʼring - Roziman - Davom etish - Server URLi - Kodingizni unutdingizmi? - Ushbu hodisa/tadbir uchun nima qilmoqchisiz? - TАMOM %s topilmadi Siz kirib koʼrmoqchi boʼlgan yozuv mavjud emas. Buni qidirmoqchimisiz? SMS orqali sinxronlash Ogohlantirish Oʼzaro aloqa - Ushbu dastur uchun hech qanday nizolar mavjud emas - Internetga ulanish uzilgan, lekin telefon xizmatlari mavjudga oʼxshaydi - siz SMS dan foydalanishingiz mumkin. - Internetga ulanish uzilgan. Yopmoq Endi siz shu tugmaga bosish orqali sinxronizatsiya maʼlumotlarini tekshirishingiz mumkin - Ushbu maʼlumotlar faqat sizning qurilmangizda saqlanadi, server bilan sinxronlashtirilmaydi. - Sizning holatingiz dolzarb! Sizning barcha maʼlumotlaringiz serverga yuboriladi. Yozuvni oʼzaro aloqalar bilan bogʼlab boʼlmadi - Sessiyani blokdan chiqarish - Bajarildi - Ochiq - Qoʼllab quvvatlanmaydigan qiymat turi Taqvim koʼrinishini oʼzgartirish - Har soatda - Кунлик - Ҳафталик - Hafta - Sinxronizatsiya sozlanmasi: %s Siz kirib koʼrmoqchi boʼlgan yozuv qayd etilmagan. Siz boshqaruv paneliga kira olmaysiz Formatlashda xatolik Ushbu yozuvni oʼchirib tashlash huquqiga ega emassiz - Bu haqiqiy URL emas Shakl sozlanmoqda... - Qabul qiluvchining raqami Siz %d ta xabar yuborilishini qabul qilasizmi? %1$d / %2$d yuborilmoqda Kutish vaqtini tasdiqlash - 1%d @@ -485,51 +267,29 @@ Shlyuz oʼrnatilmagan Foydalanuvchi tizimga kirmagan SMS modul oʼchirib qoʼyilgan - Yana SMS yuborildi SMS modul ishga tushirilmadi, talab qilingan metamaʼlumotlar topilmadi - bajarildi Qabul qilinmagan SMS lar soni SMS yuborilishi boshlandi Maʼlumotlar oʼqildi va qayta moslashtirildi. Yuborish identifikatori %d - SMS yuborishda xatolik yuz berdi SMS TEI roʼyxatdan oʼtish va qayd qilish SMS hodisa/tadbirlarni yuborish SMS maʼlumotlar toʼplamini yuborish - Boshlanmoqda - Jarayonda - Yuborildi - Аmalga oshmadi - Sinxronizatsiyani boshlash. Sinxronizatsiyalanmoqda... - Sinxronizatsiya tugallandi. - Sinxronizatsiya paytida xatolik yuz berdi - Sinxronizatsiya paytida xatolik yuz berdi. Xatoliklar jurnalini tekshirish uchun shu yerni bosing - Sinxronizatsiya bekor qilindi. Nusxa olindi - Rasm tanlandi - Xaritada koʼrsatish - Roʼyxatni koʼrsatish - %s majburiy, ammo taыdim etilmagan - Koʼpburchak ushlandi Poligon - Barmoq izlari - Iltimos, barmoq izini kiriting Xavfsizlik Xavfsizlik tufayli dasturning ushbu versiyasidan andoza olingan qurilmalarga foydalanishga ruxmat berilmagan. Iltimos, mashq versiyasidan foydalaning. - Baʼzi xatoliklar aniqlandi. Davom etishdan oldin ularni tuzating. Filtrlar qoʼllaniladi Hech qanday filtr qoʼllanilmagan davr Tashkiliy boʼlim - Tartiblash - Status Sinxronlash Menga berilgan vakolat бугун @@ -544,8 +304,6 @@ Dan-Gacha Boshqa Istalgan vaqt - Sinxronizatsiya amalga oshmadi. Muammoni hal qilishga urinish uchun maʼlumotlar qiymatlarini tekshiring va qaytadan urining. Аgar shunda sinxronizatsiya ishlamasa, tizim maʼmuriga murojaat qiling - Ushbu maʼlumotlar serverga yuborilgan, ammo ularning saqlanishiga kafolat berilmagan Ochiq Reja Muddat oʼtgan @@ -561,7 +319,6 @@ SMS shlyuz boʼsh boʼlishi mumkin emas Yuklash uchun qayd etilganlar yoʼq Ushbu konvertatsiya qilish vazifasi qoʼllab-quvvatlanmaydi - Bloklangan ekranni qayta koʼrsatmaslik PIN kodni oʼrnating Nomutanosiblik Sanani oʼzgartirish @@ -586,11 +343,7 @@ Tashkiliy boʼlimni tanlang URL ni tanlang Qiymatni tanlang - Rasmni tanlang - Kodni skanerlash - Variantni tanlang TEI atributidan yuklangan himoyalangan qiymatlar - TEI atributidan yuklangan himoyalangan qiymatlar %s himoyalangan qiymatlar qoldi Himoyalangan qiymatlarni boshqarish Yuklab olish uchun hodisa/tadbirlar @@ -609,51 +362,26 @@ Sinxronizatsiyalanmagan Sinxronlashda xatolik SMS Sinxronizatsiyalangan - Тафсилотлари - Izohlar Saqlandi! - Maʼlumotlar sifatini tekshirishni xoxlaysizmi? - Koʼrinishidan hammasi yaxshi! - Maʼlumotlar toʼplamini toʼldirishni xoxlaysizmi? - Tugallangan deb belgilang - Исми - Formula - Baribir toʼldiring %s %s dan %s %s yoʼqolgan Hodisa/Tadbir holati - Qayd qilingan sana Maʼlumot yoʼq. Yangi yozuv qoʼshish uchun \"+\" tugmasini bosing Maʼlumot yoʼq. Sizda yangi yozuvlarni qoʼshishga ruxsat yoʼq. Hech qanday tavsif yoki koʼrsatma berilmagan Koʼrib chiqiladigan maʼlumotlar - Yakunlandi va saqlandi! Qayd allaqachon mavjud Ochiq Bajarildi Bekor qilindi - Маълумот Tanlangan %s  yuklanmadi. - %s tomonidan bajarilgan: %s - Yangilangan: %s - Maʼlumotlar sifatini tekshirib boʼlmadi. Аdministratoringizga murojaat qiling - Ishonchinigiz komilmi? - Maʼlumotlar toʼplamini qayta ochib koʼrmoqchimisiz? Sana - Hodisa/Tadbir sanasi - %s umumiy xususiyatlarga ega emas. Iltimos, dasturni tanlang Maxfiylik siyosati Koordinata qiymatlari yaroqsiz Maydon toʼldirilishi majburiy Xavfsizlik sababli ushbu versiyadan adb formatidan foydalanishga ruxsat berilmagan.Ilitmos, mashq versiyasidan foydalaning. Orqaga qayting - Ilovani yopish - Batafsil maʼlumot - Batafsil maʼlumot - Yopmoq Buferga nusxa olish - Buferga nusxa olindi - Xatto haqida maʼlumot Rejalashtirila olinmaydigan hodisa/tadbir rejalari mavjud Diagrammalar Patsientlar Roʼyxati @@ -682,16 +410,8 @@ Охирги молиявий йил Fikr-mulohaza Indikatorlar - Қиймат - Bajarildi! - Notoʼgʼri maʼlumot - Qiymat butun son boʼlishi lozim - Qiymatlar - Mavjud - Nomaʼlum Til Natija juda koʼp - Yangisini yaratish Sinxronizatsiyalangan Sinxronlashmoqda Yuborish @@ -705,7 +425,6 @@ Reja boʼyicha Dastur bosqichi Xronologiya - Ixtiyoriy Koʼproq koʼrsatish Kamroq koʼrsatish Sinxronlash diff --git a/app/src/main/res/values-uz/strings.xml b/app/src/main/res/values-uz/strings.xml index af150944719..25d5e029d4e 100644 --- a/app/src/main/res/values-uz/strings.xml +++ b/app/src/main/res/values-uz/strings.xml @@ -1,59 +1,26 @@ DHIS2 - DHIS - 2 Хатолик - - Хатоликлар - - Сервер URLи нотўғри - Нотўғри маълумот - Кутилмаган хатолик - Сервердаги ички хатолик - қиймат йўқ Сақланган бошқа қийматлар йўқ. Тизим администраторига мурожаат қилинг. - Сақланган бошқа қийматлар йўқ. Уларни тўлдириш учун тармоққа уланинг. - хатоликлар Нимадир нотўғри бажарилди - Ҳозир синхронлаштиринг - Сервер билан синхронлаш - Ҳозир синхронлаш - МетаМаълумот Ҳодиса/тадбирлар - Кузатувдаги объектлар - - Тугади - Синхронлаш тугалланди Синхронлашда хатолик Синхронлаш пайтида хатолик юз берди. Илтимос, қайта уриниб кўринг - ОГОҲЛАНТИРИШ: Маълумотларнинг аксарият қисми синхронлаштирилди. Лекин, баъзи зиддиятлар келиб чиқди. Конфигурацияни синхронланг ва қайтадан уриниб кўринг. Агар огоҳлантириш давом этса, уларнинг синхронлаш ҳолатини текширинг: @ сифатида белгиланган дастурлар, TEIни ва ҳодиса/тадбирларни. - Бу ерда хатолик юз берди ва сервер жавоб бермади. Ўз администраторингиз билан алоқа боғланг. - - 1 %s натижалар топилди - Янгисини қўшиш - Қўшиш - Янги режа (жадвал) Йўналишларга - Сервер URLи - Фойдаланувчи исми - Пароль - Кириш - Сессия очиш Конфигурациянгиз синхронлашмоқда Маълумотларингиз тез орада синхронлаштирилади Маълумотларингиз синхронлашмоқда Сизнинг янги конфигурациянгиз тайёр Маълумотингиз тайёр - Конфигурацияни синхронлаш амалга ошмади - Маълумотларни синхронлаш амалга ошмади Аккаунтни тиклаш + Кунлик Ҳафталик @@ -70,127 +37,78 @@ Матн киритинг Узун матн киритинг Сонни киритинг - Бутун сон киритинг Бутун манфий сон киритинг Бутун мусбат ёки ноль сонини киритинг Мусбат сон киритинг - Фильтр вариантлари Санани танланг - Календардан танланг - Дастур тугалланди - Дастур нофаол Очиш Ҳодиса/тадбир якунланди Фақат ўқиш учун - Якунлаш ва тўлдириш Тўлдирилди Бекор қилинди - Фаоллаштириш Фаол Синхронлаш Юбориш учун Янгилаш учун - Ўчириб ташлаш учун Хатолик - СИНХРОНЛАШ - ЮБОРИШ - СМСни СИНХРОНЛАШ - Тугатиш - Яратиш Янгилаш - Сана Ташкилий бўлим Танланган саналар учун очиқ Ташкилий бўлимлар мавжуд эмас. Илтимос, бошқа сана ёки бошқа Ташкилий бўлимни танланг. - Категория вариантлари Кенглик Узунлик - Янги тадбир - Бир марта - Доимий Ҳисобот даври - Категория варианти - Агар сиз қийматни ўрнатган бўлсангиз, маълумотлар тўпламини тўлдириш учун қатордаги барча қийматлар бўлиши лозим. - Баъзи зарурий қийматлар мавжуд эмас. Маълумотлар тўпламини тўлдириш учун сизга барча шарт бўлган қийматлар мавжуд бўлиши лозим. - Шарт бўлган маълумотлар етишмаяпти. Маълумотлар тўпламини шакллантириш учун уларни тўлдиринг. - Қиймат сақланди! - Танлаш учун яроқли давр мавжуд эмас Муддати Категория комбинацияси - КК/ОО/ЙЙЙЙ Бекор қилиш Ўчириб ташлаш - Жами Охирги тадбир санаси Вариантлар мавжуд эмас Фаол дастурлар - Рўйхатга олиш дастурлари Рўйхатга киритиш - Охирги ташриф - Мурожаат қилиш - Тафсилотларни кўринг - КЎПРОҚ КЎРИШ Таҳрирлаш - Кузатиб бориш - Кузатув ёқилган - Кузатув ўчирилган Фаолсизлантириш Ушбу дастур яна кўпроқ тадбирларни қабул қилмайди - Маълумот йўқ ҳодиса/тадбирлар Бош саҳифа - Илова ҳақида маълумот Чиқиш - Сессияни блоклаш - 1 дақиқа - 15 дақиқа 30 дақиқа 1 соат 6 соат 1 кун (стандарт) 12 соат - Маълумотларни автоматик синхронлаш, ҳар: Қўлланма - Конфигурацияни автоматик синхронлаш, ҳар: 1 ҳафта Жорий синхронлаш параметрлари Стандарт ҳолатга қайтиш + Маълумотлар базаси ўчирилади. Сиздан қайта кириш сўралади. Қабул қилинг Бекор қилиш Маълумотларни синхронлаш, ҳозир Конфигурацияни синхронланг, ҳозир Созламалар - Охирги синхронизация: %s - Охирги синхронизация: %s Тўлдириш - СМС модуль: СМС йўлак СМС натижаларини юборувчи - сониялар - рақам - рақам СМС юборишни ёқиш СМС натижалар тарқатилишини кутиш - - Махсус созламаларга эга бўлган дастурлар %s - Созламалар чекланган Глобал миқёсда дастур бўйича @@ -208,15 +126,11 @@ Ушбу журналда маълумотлар ва МетаМаълумотларни синхронлашда вужудга келган барча хатоликлар қайд этилган Локал маълумотларни ўчириб ташлаш Қурилмангизда сақланган барча Кузатув-Назорат иловаси маълумотлари ўчириб ташланади. Сервер билан синхронланмаган маълумотлар йўқотилади. - Илова маълумотларининг асл стандарт ҳолатини тиклаш; конфигурация - Қурилмангизда сақланган конфигурация ва Илова маълумотлари ўчирилади. Сиздан қайта тизимга кириш сўралади. Сервер билан синхронланмаган маълумотлар йўқотилади. СМС созламалари СМС шлюзи билан боғлиқ параметрларни текширинг ва таҳрирланг Статус - Қабул қилинган сана - Излаш @@ -225,11 +139,6 @@ Янги эслатма ёзинг аниқ Изоҳ қолдириш - Қайтиш - Фаоллаштирилди - Мажбурий майдонлар қолдириб кетилган - Баъзи мажбурий майдонлар қолдириб кетилган. Агар ҳозир орқага қайтсангиз, барча сақланган маълумотлар ўчиб кетади. - Баъзи мажбурий майдонлар қолдириб кетилган. Агар орқага қайтмоқчи бўлсангиз, Илтимос, уларни тўлдиринг. Маълумотингиз сақланмади Агарда ҳозир орқага қайтсангиз, барча киритилган маълумотлар ўчирилади. Ўчириб ташлаш ва орқага қайтиш @@ -237,29 +146,15 @@ Учун координаталар - Бўлимлар рўйхати - %s саволлардан %s бажарилди - %s дан %s бажарилди - Якунлаш ва ёпиш - Кунлар - Ойлар - Йиллар - Ҳафталар Ҳодиса санаси - Локал натижалар - Онлайн натижалар Аввалги дастурлар Ушбу амални бажариш учун сизда зарурий рухсат йўқ - Сифатида қўшиш... - Тозалаш давр - (%d) ташкилий бўлими - %d даражаси DHIS2 га Хуш келибсиз Бу ерда щз фаолиятингизни кузатишингиз мумкин Фильтрни щзгартиришингиз мумкин @@ -268,16 +163,11 @@ Асосий менюдан бошқа ҳаракатларни текшириш - Ўзаро алоқа муносабатларни яратиш Кейинги Қидирув учун камида %d атрибутни киритиш керак - Қидирув учун камида 1 та атрибутни киритиш керак - %d дан ортиқ натижалар мавжуд. Илтимос, қўшимча қидирув мезонларини киритинг - Қидирув мезонларини киритинг QR қурилмаларни импорт қилиш - Сақлаш ОК Идентификация Аттрибутлар @@ -289,32 +179,20 @@ маълумотлар йўқ QR код яроқсиз Ушбу атрибутларрни қўшиб бўлмади - Ушбу қайдларни қўшиб бўлмади Ҳақида Рўйхатга олиш дастурлари - ҳаммасини белгилаш - аниқ танлов - Танланган фильтрлар учун маълумот йўқ. Илтимос, уларни ўзгартириб кўринг. Улашиш QR ни улашиш Максимум Жорий TEI TEI лар - Ташкилий бўлимлар орқали чегаралаш - Дастур орқали чегаралаш - Дастурлар рўйхати Илова маълумотларининг асл стандарт ҳолатини тиклаш; конфигурация - Ушбу ўзаро муносабатларни қўшиб бўлмайди Буни текширинг! - Огоҳлантириш - Хато Сақлаш Ҳимояланган қийматлар - QR дан кўпроқ маълумотларни қўшмоқчимисиз? - Илова версияси: 1 %s SDK версияси: 1 %s @@ -325,138 +203,44 @@ Осло Университети томонидан ишлаб чиқилган ва қўллаб қувватланадиган DHIS2 лойиҳаси Ушбу дастур ҳақида қўшимча маълумот ёки саволлар учун android@dhis2.org электрон манзилига ёзинг - Пароль қолдириб кетилган, Илтимос, ушбу майдонни тўлдиринг. - Фойдаланувчи исми қолдириб кетилган. Илтимос, ушбу майдонни тўлдиринг. - Ушбу сервер 2.29 дан фарқли версияни ишлатмоқда. Сервер версиясини янгиланг ёки давом этиш учун синов муддатидан фойдаланинг. - Ушбу сервер %s дан фарқли версияни ишлатмоқда. Сервер версиясини янгиланг ёки давом этиш учун синов муддатидан фойдаланинг. - Нимадир нотўғри бажарилди. Сервер URL манзили, фойдаланувчи номи ва пароль тўғри эканлигини текширинг ва қайтадан урининг. - Сервер ушбу сўровни бажара олмади. - Серверга киришга хатолик юз берди. - Кириш хатоси - Мажбурий майдонлар қолдириб кетилган - Онлайн қидирувда кутилмаган хатолик - Натижаларни таҳлил қилишда хатолик юз берди - Онлайн қидирувда хаттолик юз берди - Серверда хатолик юз берди Рўйхатдан ўтишни ёқиш учун дастурни танланг Сизда керакли рухсат йўқ - Ушбу функция ҳали тайёр эмас. - Ўзаро алоқалар ёрлиғини олишда хатолик юз берди Барчаси %s Бу яроқли телефон рақами эмас - Бу яроқли электрон почта манзили эмас - Фақат манфий рақамларга рухсат берилади Фақат мусбат рақамлар ёки нолга рухсат берилади - Фақат мусбат рақамларга рухсат берилади - Фақат 0 дан 1 гача бўлган қийматларга рухсат берилади - Фақат 0 дан 100 гача бўлган қийматларга рухсат берилади - Фильтрлаш учун саналарни танланг Вариант танланг - Муаммо ҳақида хабар берилди - Янги ҳодиса/тадбир яратиш - Ҳақиқатдан ҳам бошқа ҳодиса/тадбир яратмоқчимисиз? Ҳодиса/Тадбир Тугалланди Ушбу дастурдаги барча ҳодиса/тадбирлар якунланди. Ҳақиқатдан ҳам ушбу дастурни ёпишни хохлайсизми? - Ҳақиқатдан ҳам рўйхатдан ўтишни тугатмоқчимисиз? Текширинг Бош саҳифа МИСОЛ - Виджет қўшиш Ёрдам беринг - Маълумотларингизни сохталаштиринг... - ...сизнинг конфигурациянгиз... - ...сервердан қанча ёзувларни юклаб олиш кераклигини белгиланг... - Қанча захира қийматлари қолганлигини текширинг ва бошқаринг - Серверга уланиш амалга ошмаганлигини текширинг - Дискда жой бўшатинг - ...ёки бошидан бошлаш учун ҳамма нарсани ўчириб ташлаш. - Ўзингизнинг қурилмангизни ҳам локал ҳам серверда топинг. - Сизга бошқа дастурларга ўтишингиз мумкин - Янги объектларни қидириш ёки қўшиш. Сиз аввал қидиришингиз зарур, янгисини қўшишдан олдин! - Майдонларни тозалаш ва қидирувни қайта бошлаш учун шу ерни босинг. Бу шахс-объектларни бошқарув панели Шахс-объектларнинг профиль маълумотларини кўринг... - ...QR ёки СМС орқали маълумот улашиш... - ...ёки кузатув учун шахсни белгиланг - Ҳодиса/тадбирни яратиш ёки режалаштириш ёки бошқа Ташкилий бўлимга мурожаат қилиш учун босинг. Бу ерда сиз барча ҳодиса/тадбирларни кўрасиз. Ҳодиса/тадбир тафсилотларини кўриш учун босинг. Ўзаро муносабатларни қўшиш, кўрсатгичларни кўриш ёки ёзувларни ёзиш учун меню бўйлаб ҳаракатлананинг. - Ташкилотингиз томонидан барча қайд қилинганларни кўриш ёки янгисини яратиш учун, босинг - Ушбу ҳодиса/тадбирни ўчириб ташлаш учун босинг. Ушбу ҳаракат қайтмас. - Ҳодиса/тадбирни якунлаш. - Ушбу дастур билан боғлиқ барча ҳодиса/тадбирлар бу ерда келтирилган. - Янги ҳодиса/тадбир қўшиш учун, босинг Ҳодиса/тадбир яратиш учун барча маълумотларни киритинг Ҳодиса/тадбирнинг тугаш ҳолатини текшириш учун, босинг. - Ҳодиса/тадбирни якунлаш ёки қайта очиш учун, босинг - QR орқали бошқа қурилмалардан маълумотларни ўқинг ва сақланг - Маълумотларни сервер билан синхронлаштиринг - 4 хонали код билан сессияни блоклаш. Кодингизни унутманг! - Фойдаланувчини ёки серверни ўзгартириш учун тизимдан чиқинг. Интернетда уланиш бўлмаса ҳам, сиз яна тизимга киришингиз мумкин. - %s: ўзаро алоқалар Ҳеч қандай ҳодиса/тадбир рўйхатдан ўтказилмаган, янгисини қўшиш учун \"+\" тугмасини босинг. - Ҳеч қандай ҳодиса/тадбир рўйхатдан ўтказилмаган. Маълумотларнинг қийматлари Ахборот ID ўқиб бўлмади Локал маълумотларни ўчириб ташлаш Қисқача кўздан кечириш - Ўзаро алоқалар - Кўрсатгичлар - Аналитика Ушбу дастур учун индикатор мавжуд эмас. - Изоҳлар - Тугалланмади - Баъзи майдонларда хатолар мавжуд. Илтимос, уларни бажариш учун уларни текширинг Барча қайдлар Барча фаол дастурлар билан бошқарув панелини кўрсатиш - Синхронизациядаги хатоликлар журналини очиш Синхронизациядаги хатоликлар журнали билан улашиш ... - Сизнинг конфигурациянгиз синхронлашмади. Сизнинг серверингизда хатолар бўлиши мумкин. Қўшимча маълумотлар учун бу ерни босинг. - ОЛДИНГИ - КЕЙИНГИ - Тафсилотлари - Индикаторлар - Изоҳлар - - Кейинроқ тўлдириш - - Тугатиш - Хатоликлар мавжуд бўлган майдонлар - Баъзи майдонларда хатоликлар мавжуд.Улар сақланмади. Илтимос,, уларни текшириб кўринг. Қиймат ноёб бўлиши лозим, бир бирига ўхшаш топилди. У сақланмади. Илтимос, текшириб кўриш. - Ноёб соҳада бир бирига ўхшаш қиймат топилди. Рўйхатдан ўтишни сақлаш учун формани текшириш. Илтимос, кутинг Ўтказиб юбориш - Қайта режаланг Ушбу экрандаги ҳақида маълумот йўқ - Муддат ўтган - Бу ерда дастурнинг қўллаб қувватланмайдиган бир қоидаси мавжуд. Илтимос, ҳужжатларни текширинг. - Ушбу майдон учун маълумот йўқ - Ишонч ёрлиқлари бўш - Бармоқ излари сканери ёрдамида тизимга кириш учун аввал стандарт усулдан фойдаланишингиз керак - Биометрик Хавфсизлик - Жадваллар - Жадвал - https:// Синхронизация қилинмаган маълумотлар йўқолади. Ҳақиқатдан ҳам давом эттиришни хохлайсизми? - Қабул қилинг Маълумотларни ўчириб бўлмади. Илтимос, қайта уриниб кўринг. Локал маълумотларни ўчириш муваффақиятли якунланди. - Камида битта ташкилий бўлим танланиши керак - аълумотларни юборинг - DHIS2 да фойдаланувчи идентификаторлари билан боғлиқ хатоликлар журналларини улашиш орқали ушбу дастурни яхшилашга ёрдам бериш. - Майдонларни белгилаш - Конфигурацияни синхронлаштириш пайтида сизнинг интернет алоқангизда узилиш юз берди. Илтимос, кейинроқ қайта уриниб кўринг - Розиман - Давом этиш - Сервер URLи - Кодингизни унутдингизми? - Ушбу ҳодиса/тадбир учун нима қилмоқчисиз? - ТАМОМ %s топилмади Сиз кириб кўрмоқчи бўлган ёзув мавжуд эмас. Буни қидирмоқчимисиз? СМС орқали синхронлаш @@ -464,32 +248,15 @@ Юкланмоқда Ўзаро алоқа СМС орқали юборилган - Ушбу дастур учун ҳеч қандай низолар мавжуд эмас - Интернетга уланиш узилган, лекин телефон хизматлари мавжудга ўхшайди - сиз СМС дан фойдаланишингиз мумкин. - Интернетга уланиш узилган. Ёпмоқ Энди сиз шу тугмага босиш орқали синхронизация маълумотларини текширишингиз мумкин - Ушбу маълумотлар фақат сизнинг қурилмангизда сақланади, сервер билан синхронлаштирилмайди. - Сизнинг ҳолатингиз долзарб! Сизнинг барча маълумотларингиз серверга юборилади. Ёзувни ўзаро алоқалар билан боғлаб бўлмади - Сессияни блокдан чиқариш - Бажарилди - Очиш - Қўллаб қувватланмайдиган қиймат тури Тақвим кўринишини ўзгартириш - 15 дақиқа - Ҳар соатда - Кунлик - Ҳафталик - Ҳафта - Синхронизация созланмаси: %s Сиз кириб кўрмоқчи бўлган ёзув қайд этилмаган. Сиз бошқарув панелига кира олмайсиз Форматлашда хатолик Ҳуқуқингиз чегараланганлиги сабабни ушбу ёзувни ўчириб ташлолмайсиз - Бу ҳақиқий URL эмас Шакл созланмоқда... - Қабул қилувчининг рақами Сиз %d та хабар юборилишини қабул қиласизми? %1$d / %2$d юборилмоқда Кутиш вақтини тасдиқлаш - 1%d @@ -507,51 +274,29 @@ Шлюз ўрнатилмаган Фойдаланувчи тизимга кирмаган СМС модуль ўчириб қўйилган - Яна СМС юборилди СМС модуль ишга туширилмади, талаб қилинган метамаълумотлар топилмади - бажарилди Қабул қилинмаган СМС лар сони СМС юборилиши бошланди Маълумотлар ўқилди ва қайта мослаштирилди. Юбориш идентификатори %d - СМС юборишда хатолик юз берди СМС TEI рўйхатдан ўтиш ва қайд қилиш СМС ҳодиса/тадбирларни юбориш СМС маълумотлар тўпламини юбориш - Бошланмоқда - Жараёнда - Юборилди - Амалга ошмади - Синхронизацияни бошлаш. Синхронизацияланмоқда... - Синхронизация тугалланди. - Синхронизация пайтида хатолик юз берди - Синхронизация пайтида хатолик юз берди. Хатоликлар журналини текшириш учун шу ерни босинг - Синхронизация бекор қилинди. Нусха олинди - Расм танланди - Харитада кўрсатиш - Рўйхатни кўрсатиш - %s мажбурий, аммо таыдим этилмаган - Кўпбурчак ушланди Полигон - Бармоқ излари - Илтимос, бармоқ изини киритинг Хавфсизлик Хавфсизлик туфайли дастурнинг ушбу версиясидан андоза олинган қурилмаларга фойдаланишга рухмат берилмаган. Илтимос, машқ версиясидан фойдаланинг. - Баъзи хатоликлар аниқланди. Давом этишдан олдин уларни тузатинг. Фильтрлар қўлланилади Ҳеч қандай фильтр қўлланилмаган Давр Ташкилий бўлим - Тартиблаш - Статус Синхронлаш Менга берилган ваколат бугун @@ -566,8 +311,6 @@ Дан-Гача Бошқа Исталган вақт - Синхронизация амалга ошмади. Муаммони ҳал қилишга уриниш учун маълумотлар қийматларини текширинг ва қайтадан урининг. Агар шунда синхронизация ишламаса, тизим маъмурига мурожаат қилинг - Ушбу маълумотлар серверга юборилган, аммо уларнинг сақланишига кафолат берилмаган Очиш Режа Муддат ўтган @@ -583,7 +326,6 @@ СМС шлюз бўш бўлиши мумкин эмас Юклаш учун қайд этилганлар йўқ Ушбу конвертация қилиш вазифаси қўллаб-қувватланмайди - Блокланган экранни қайта кўрсатмаслик ПИН кодни ўрнатинг Номутаносиблик Санани ўзгартириш @@ -608,11 +350,7 @@ Ташкилий бўлимни танланг URL ни танланг Қийматни танланг - Расмни танланг - Кодни сканерлаш - Вариантни танланг TEI атрибутидан юкланган ҳимояланган қийматлар - TEI атрибутидан юкланган ҳимояланган қийматлар %s ҳимояланган қийматлар қолди Ҳимояланган қийматларни бошқариш Юклаб олиш учун ҳодиса/тадбирлар @@ -632,39 +370,19 @@ Синхронизацияланмаган Синхронлашда хатолик СМС Синхронизацияланган - Тафсилотлари - Изоҳлар Сақланди! - Маълумотлар сифатини текширишни хохлайсизми? - Кўринишидан ҳаммаси яхши! - Маълумотлар тўпламини тўлдиришни хохлайсизми? - Тугалланган сифатида белгилансин - Исми - Формула - Барибир тўлдиринг %s %s дан %s %s йўқолган Ҳодиса/Тадбир ҳолати - Қайд қилинган сана Маълумот йўқ. Янги ёзув қўшиш учун \"+\" тугмасини босинг Маълумот йўқ. Сизда янги ёзувларни қўшишга рухсат йўқ. Ҳеч қандай тавсиф ёки кўрсатма берилмаган Кўриб чиқиладиган маълумотлар - Якунланди ва сақланди! Қайд аллақачон мавжуд Очиш Бажарилди Бекор қилинди - Маълумот Танланган %s  юкланмади. - %s томонидан бажарилган: %s - Янгиланган: %s - Бажарилди - Маълумотлар сифатини текшириб бўлмади. Администраторингизга мурожаат қилинг - Ишончинигиз комилми? - Маълумотлар тўпламини қайта очиб кўрмоқчимисиз? Сана - Тадбир санаси - %s умумий хусусиятларга эга эмас. Илтимос, дастурни танланг Махфийлик сиёсати Махфийлик сиёсатини текшириш учун шу ерни босинг Координата қийматлари яроқсиз @@ -672,15 +390,8 @@ Хавфсизлик сабабли ушбу версиядан adb форматидан фойдаланишга рухсат берилмаган.Илитмос, машқ версиясидан фойдаланинг. Орқага қайтинг - Иловани ёпиш - Батафсил маълумот - Батафсил маълумот - Ёпмоқ Буферга нусха олиш - Буферга нусха олинди - Хатто ҳақида маълумот Серверингиз билан синхронизацияда хатоликни аниқладик. Иложи бўлса, хатолик ҳақида администраторингизга хабар беринг. - Сизнинг фойдаланувчингиз билан сеанс ташкиллаштира олмадик Режалаштирила олинмайдиган ҳодиса/тадбир режалари мавжуд Диаграммалар Пациентлар Рўйхати @@ -719,37 +430,22 @@ Фикр-мулоҳаза Кўрсатгичлар Қиймат + Камерада фойдаланишга рухсат берилмаган. Ушбу функциядан фойдаланиш учун уни ёқишингиз керак. Диаграммалар ва индикаторлар Суратга олиш - Галереядан танлаш - Бажарилди! Ушбу дастур ҳимояланган Сабабини шу ерда ёзиб қолдиринг - Аутентификация қилинмоқда - Кўрсатиб ўтилган URL манзил DHIS шаблонида эмас - Нотўғри маълумот - Қиймат бутун сон бўлиши лозим Сессия муддати тугаган Сизнинг жорий сессиянгиз муддати тугаган. Хавфсизлик нуқтаи назаридан, сиз қайтадан аккаунт маълумотларини тақдим этишингиз лозим. - Қийматлар Радар (тўрсимон) сифатда кўриш - Мавжуд - Номаълум Тил Натижа жуда кўп - Янгисини яратиш Яхши Синхронизацияланган Синхронлашмоқда Юбориш Янгилаш Hozir emas - - %s da xatoliklar - - - %s da ogohlantirishlar - Кузатув учун белгиланган Фақат кўриш учун Ўтказиб юборилди @@ -762,13 +458,21 @@ Дастур босқичи ҳодиса/тадбир Хронология - Ихтиёрий Кўпроқ кўрсатиш Камроқ кўрсатиш Синх - Қаторларни кўрсатиш - Қаторларни беркитиш Тавсифни кўрсатиш Тавсифни беркитиш Ўтказиш - + + %s da xatoliklar + + + + %s da ogohlantirishlar + + + + Махсус созламаларга эга бўлган дастурлар %s + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 03a600a9022..dd4b7a8f15d 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1,63 +1,28 @@ Dhis2 - dhis - 2 Lỗi - - Lỗi - - Sai đường dẫn đến máy chủ - Sai thông tin đăng nhập - Lỗi không mong muốn - Lỗi máy chủ nội bộ - không có giá trị Không còn giá trị dành riêng. Liên hệ với quản trị viên hệ thống của bạn - Không còn giá trị dành riêng. Kết nối với mạng để nạp thêm - lỗi Có gì đó không đúng - Đồng bộ ngay - Đang đồng bộ với máy chủ - Đang đồng bộ - Siêu dữ liệu Sự kiện - Đối tượng theo dõi - - Lưu - Hoàn thành đồng bộ Lỗi đồng bộ Xảy ra lỗi trong quá trình đồng bộ. Vui lòng thử lại. Đồng bộ đã hoàn tất nhưng chúng tôi không nhận được xác nhận máy chủ cho tất cả dữ liệu. Các dữ liệu vẫn được đánh dấu là \"Ngoại tuyến\" trong Ứng dụng. Chúng tôi khuyên bạn thử đồng bộ lại. - CẢNH BÁO: Hầu hết dữ liệu của bạn đã được đồng bộ nhưng một vài dữ liệu vẫn bị xung đột. Hãy thử đồng bộ cấu hình và thử lại. Nếu cảnh báo vẫn tiếp diễn, kiểm tra trạng thái đồng bộ của chương trình, các Đối Tượng Theo Dõi và các sự kiện với các dấu hiệu xung đột là @. - Có lỗi và máy chủ không phản hồi. Liên hệ quản trị viên của bạn. - - Tìm thấy %s kết quả - Thêm sự kiện mới - Thêm Nhập %s Nhập / Bỏ qua %s - Đặt lịch hẹn Giới thiệu đến cơ sở khác - Đường dẫn đến máy chủ - Tên đăng nhập - Mật khẩu - Đăng nhập - Mở khóa phần nhập Đang đồng bộ Cấu Hình của bạn Dự Liệu của bạn sẽ được sớm đồng bộ Đang đồng bộ Dữ Liệu của bạn Cấu Hình của bạn đã sẵn sàng Dữ Liệu của bạn đã sẵn sàng - Đồng bộ Cấu Hình thất bại - Đồng bộ Dữ Liệu thất bại Phục hồi tài khoản - Cần có kết nối mạng để phục hồi tài khoảng của bạn Hàng Ngày @@ -78,137 +43,87 @@ Nhập văn bản Nhập văn bản dài Nhập số - Nhập số nguyên Nhập số nguyên âm Nhập số nguyên dương hoặc số 0 Nhập số nguyên dương - Tùy Chọn Bộ Lọc Chọn ngày - Thay đổi lịch - Chương trình đã hoàn tất - Chương trình không hoạt động Mở Chưa hoàn tất Sự kiện đã hoàn tất Chỉ Xem - Lưu và Hoàn Tất Đã hoàn tất Đã hủy Mở lại - Mở lại biểu nhập - Kích hoạt Hoạt động Đồng bộ Gửi Cập nhật - Xóa Lỗi - ĐỒNG BỘ - GỬI ĐI - ĐỒNG BỘ SMS - Hoàn tất - Tạo mới Cập nhật - Ngày Đơn vị Không có Đơn Vị nào trong ngày được chọn. Xin vui lòng chọn ngày khác hoặc Đơn Vị khác. - Tùy Chọn Phân Loại Vĩ độ Kinh độ - Sự kiện mới - Một lần - Cố định Thời điểm báo cáo - Tùy chọn - Nếu bạn nhập một giá trị, cần nhập tất cả giá trị để hoàn tất Biểu Nhập - Một số giá trị bắt buộc đang bị thiếu. Bạn cần nhập giá trị bắt buộc để hoàn tất Biểu Nhập. - Các trường nhập bắt buộc đang bị thiếu. Xin vui lòng điền giá trị bắt buộc để hoàn thành biểu nhập. - Giá trị đã được lưu! - Không có thời điểm để chọn. Ngày đến hạn %1$skế tiếp Tổ hợp Phân Loại - DD/MM/YYYY Hủy Xóa - Tổng số Ngày sự kiện gần nhất Không có tùy chọn Danh sách đăng ký Các chương trình đang hoạt động - Chương trình để đăng ký Đăng ký - Ngày đến khám gần nhất : - áp dụng - Xem Chi Tiết - XEM THÊM Chỉnh sửa - Theo dõi - Đã bật theo dõi - Đã tắt theo dõi Không hoạt động Chương trình này không cho phép thêm sự kiện - Không có dữ liệu sự kiện Trang chủ - Thông Tin Ứng Dụng Đăng Xuất - Khóa Phiên đăng nhập Hiển thị thêm %s... Ẩn bớt... - 1 Phút - 15 Phút 30 Phút 1 Giờ 6 Giờ 1 Ngày (Mặc định) 12 Giờ - Đồng bộ dữ liệu tự động mỗi: Thủ công - Tự động đồng bộ cấu hình mỗi: 1 Tuần Các tham số đồng bộ hiện tại: Trở về cài đặt mặc định + Cơ sở dữ liệu sẽ được xóa.\nBạn sẽ được yêu cầu đăng nhập lại. Chấp nhận Hủy Đồng Bộ Dữ Liệu Ngay Đồng Bộ Cấu Hình Ngay Cài đặt - Đồng bộ lần cuối: %s - Đồng bộ lần cuối: %s Nạp lại - Mô đun SMS: Cổng SMS Người gửi kết quả SMS Thời gian chờ kết quả SMS (giây) - giây - số - số Cho phép nộp SMS Đợi phản hồi kết quả SMS - - Có %s chương trình với các cài đặt riêng - Cài đặt bị giới hạn Toàn cầu theo chương trình @@ -226,20 +141,15 @@ Nhật ký này chứa tất cả các lỗi gây ra khi đồng bộ dữ liệu và siêu dữ liệu của bạn. Xóa dữ liệu nội bộ Tất cả dữ liệu của Ứng Dụng này được lưu trữ trong thiết bị của bạn sẽ bị xóa. Dữ liệu chưa được đồng bộ lên máy chủ sẽ bị mất. - Cài đặt lại dữ liệu và cấu hình - Cấu hình và dữ liệu trong ứng dụng của bạn được lưu trữ trong thiết bị sẽ bị xóa. Bạn sẽ được yêu cầu đăng nhập lại và dữ liệu chưa đồng bộ lên máy chủ sẽ bị mất. Cài đặt SMS Kiểm tra và chỉnh sửa các tham số liên quan tới cổng sms Cập nhật phần mềm - Phần mềm Xuất cơ sở dữ liệu Xuất cơ sở dữ liệu và chia sẻ nó với quản trị của bạn Xác thực hai yếu tố Trạng thái - Ngày đăng ký - Tìm kiếm @@ -261,29 +171,15 @@ Tọa độ của - Danh sách phân đoạn - %s câu hỏi của %s đã hoàn tất - %s của %s đã hoàn tất - Hoàn tất và Đóng - Ngày - Tháng - Năm - Tuần Ngày khởi phát - Kết quả cục bộ - Kết quả trực tuyến Các chương trình cũ Bạn không có quyền thực hiện hành động này - Thêm làm - Xóa Thời điểm - (%d) Đơn vị - Tuyến %d Chào mừng đến với dhis2 Bạn có thể xem các hoạt động của mình tại đây Bạn có thể áp dụng bộ lọc... @@ -292,16 +188,11 @@ Xem các hoạt động khác ở trình đơn chính - Tạo mối quan hệ Tiếp tục Bạn cần nhập ít nhất %d thuộc tính để thực hiện tìm kiếm - Bạn cần nhập ít nhất một thuộc tính để tìm kiếm - Có nhiều hơn %d kết quả. Vui lòng nhập thêm thông số tìm kiếm khác - Nhập thông số tìm kiếm của bạn Nhập dữ liệu QR - Lưu OK Nhận dạng Thuộc Tính @@ -313,32 +204,20 @@ không có dữ liệu Mã QR không hợp lệ Không thể thêm thuộc tính này - Không thể đăng ký Giới thiệu Chương trình để đăng ký - chọn tất cả - Bỏ chọn - Không có thông tin với các bộ lọc đã chọn. Vui lòng thay đổi Chia sẻ Chia sẻ mã QR Lớn nhất Hiện tại ĐỐI TƯỢNG THEO DÕI ĐỐI TƯỢNG THEO DÕI - GIới hạn bởi đơn vị - Giới hạn bởi chương trình - Danh sách chương trình Cài đặt lại Dữ liệu & cấu hình - Không thể thêm mối quan hệ này Kiểm tra cái này! - Cảnh báo - Lỗi Lưu Giá trị dành riêng - Bạn có muốn thêm dữ liệu từ mã QR không? - Phiên bản Ứng dụng: %s Phiên bản SDK: %s @@ -349,131 +228,43 @@ Phát triển và duy trì bởi dự án DHIS2, thuộc trường Đại học Oslo, Khoa Tin học. Để biết thêm thông tin hoặc hỏi về ứng dụng này, vui lòng gửi thư đến android@dhis2.org - Chưa nhập mật khẩu. Vui lòng nhập mật khẩu. - Chưa nhập tên đăng nhập. Vui lòng nhập tên đăng nhập. - Máy chủ này đang chạy phiên bản dhis khác với phiên bản 2.29. Hãy cập nhật phiên bản máy chủ của bạn hoặc sử dụng môi trường thực nghiệm để tiếp tục. - Máy chủ này đang chạy một phiên bản dhis khác với %s. Hãy cập nhật phiên bản máy chủ của bạn hoặc sử dụng môi trường thử nghiệm để tiếp tục. - Có gì đó không đúng. Vui lòng kiểm tra lại đường dẫn máy chủ, tên đăng nhập và mật khẩu và thử lại. - Máy chủ không thể hoàn thành yêu cầu này. - Có vấn đề khi đang đăng nhập vào máy chủ. - Lỗi đăng nhập - Các trường dữ liệu bắt buộc bị thiếu - Xảy ra lỗi không mong muốn khi tìm kiếm trực tuyến - Xảy ra lỗi khi chuyển đổi kết quả - Tìm kiếm trực tuyến trả về lỗi - Có lỗi gì đó trên máy chủ Chọn một chương trình để đăng ký Bạn không có quyền cần thiết - Chức năng này chưa hoạt động. - Có lỗi khi lấy nhãn mối quan hệ Tất cả %s Số điện thoại này không hợp lệ - Email này không hợp lệ - Chỉ cho phép nhập số âm Chỉ cho phép số nguyên hoặc số 0 - Chỉ cho phép số dương - Chỉ cho phép giá trị từ 0 đến 1 - Chỉ cho phép giá trị từ 0 đến 100 - Chọn ngày để lọc Tùy chọn - Vấn đề đã được báo cáo - Tạo sự kiện mới - Bạn có muốn tạo một sự kiện khác? Sự kiện đã hoàn tất Tất cả các sự kiện trong chương trình này đã hoàn tất. Bạn có muốn đóng chương trình không? - Bạn có muốn hoàn tất đăng ký? Bạn có muốn hoàn tất %sluôn không? Kiểm tra Trang chủ VÍ DỤ - Thêm tiện ích Hiện trợ giúp - Đang đồng bộ dữ liệu của bạn... - ...cấu hình của bạn... - ...cài đặt bao nhiêu hồ sơ có thể tải xuống từ máy chủ... - Kiểm tra và quản lý còn bao nhiêu giá trị dành riêng - Kiểm tra kết nối mạng đến máy chủ bị lỗi hay không - Giải phóng một ít dung lượng ổ đĩa - ...hoặc xóa tất cả để bắt đầu lại từ đầu. - Tìm kiếm đối tượng theo dõi của bạn tại thiết bị của bạn hoặc trên máy chủ trực tuyến - Bạn có thể đổi sang các chương trình khác. - Tìm kiếm hoặc Thêm đối tượng theo dõi mới. Bạn cần phải tìm kiếm trước khi thêm một đối tượng mới! - Bấm vào đây để xóa các trường dữ liệu và đặt lại thông tin tìm kiếm Đây là bản trình bày dữ liệu của đối tượng theo dõi Xem thông tin hồ sơ của các đối tượng theo dõi... - ...Chia sẻ thông tin qua mã QR hoặc SMS... - ...Hoặc đánh dấu đối tượng để theo dõi - Bấm để tạo hoặc đặt lịch cho một sự kiện hoặc giới thiệu đến một đơn vị khác. Tại đây bạn sẽ thấy tất cả các sự kiện. Bấm để xem chi tiết một sự kiện. Thao tác điều hướng trong trình đơn để Thêm mối quan hệ, Xem chỉ số hoặc Viết ghi chú - Bấm vào đây để xem tất cả các đăng ký của đối tượng theo dõi hoặc đăng ký mới. - Bấm để xóa sự kiện này. Hành động này không thể hoàn tác. - Hoàn tất sự kiện. - Tất cả các sự kiện liên quan tới chương trình này sẽ được liệt kê ở đây. - Bấm để thêm một sự kiện mới Nhập tất cả dữ liệu để tạo sự kiện Bấm để kiểm tra trạng thái hoàn tất sự kiện - Bấm để Hoàn tất hoặc Mở-lại sự kiện - Đọc và lưu thông tin từ thiết bị khác bằng QR - Đồng bộ dữ liệu của bạn với máy chủ - Khóa phiên đăng nhập với mã 4 ký tự số. Đừng quên mã của bạn! - Đăng xuất để thay đổi tên đăng nhập hoặc máy chủ. Bạn vẫn sẽ có thể đăng nhập lại thậm chí khi không có kết nối mạng. - %s: mối quan hệ Không có sự kiện đã đăng ký, bấm vào \"+\" để thêm một sự kiện mới. - Không có sự kiện được đăng ký Giá trị dữ liệu Thông tin Không thể đọc được mã nhận dạng này Xóa dữ liệu nội bộ Tổng quan - Mối quan hệ - Chỉ số - Phân tích Không có chỉ số cho chương trình này. - Ghi Chú - Không thể hoàn tất - Không thể lưu - Có cảnh báo trong biểu nhập. - Bạn muốn làm gì? - Một số trường dữ liệu có lỗi. Vui lòng kiểm tra chúng để có thể hoàn tất. Tất cả đăng ký Bảng hiển thị dữ liệu với tất cả chương trình đang hoạt động - Mở lịch sử lỗi đồng bộ Lịch sử lỗi đồng bộ Chia sẻ với... - Đồng bộ cấu hình của bạn thất bại. Có thể có lỗi nào đó trong máy chủ của bạn. Bấm vào đây để biết thêm chi tiết - TRƯỚC - SAU - Chi tiết - Chỉ số - Ghi Chú - - Hoàn tất sau - - Lưu - Các trường dữ liệu có lỗi - Một số trường dữ liệu có lỗi. Chúng chưa được lưu. Xin vui lòng kiểm tra lại. Giá trị phải là duy nhất, có một giá trị bị trùng. Nó chưa được lưu lại. Xin vui lòng kiểm tra. - Có một sự trùng lặp trong trường duy nhất. Xin vui lòng kiểm tra lại biểu nhập để lưu Đăng ký này. Xin vui lòng đợi Bỏ qua - Sửa lịch hẹn Không có thông tin cho màn hình này - Quá hạn - Có một quy tắc chương trình không được hỗ trợ. Vui lòng kiểm tra tài liệu. - Không có thông tin cho trường dữ liệu này - Xóa hết thông tin đăng nhập - Để có thể đăng nhập bằng cách sử dụng máy quét dấu vân tay, trước tiên bạn cần phải sử dụng cách mặc định. - Bảo mật sinh trắc học - Đăng nhập nhanh - Bạn có muốn sử dụng dấu vân tay của bạn hoặc phương thức sinh học khác để đăng nhập không? - Bảng - Bảng - https:// Dữ liệu đã đồng bộ sẽ không bị mất. Bạn có muốn tiếp tục không? - Chấp nhận Không thể xóa dữ liệu. Vui lòng thử lại. Đã xóa dữ liệu cục bộ thành công Phải chọn ít nhất một đơn vị @@ -486,6 +277,7 @@ Đồng ý Tiếp tục Đường dẫn đến máy chủ + Nhập đường dẫn liên kết đến máy chủ DHIS 2 với đơn vị được cung cấp bởi quản lý của bạn.\n\n Hoặc bạn có thể quét mã QR có chứa đường dẫn liên kết đến máy chủ. Bạn đã quên mã của bạn phải không? Bạn muốn làm gì với sự kiện này? KẾT THÚC @@ -496,33 +288,16 @@ Đang tải lên Mối quan hệ Đã gửi bằng SMS - Không có xung đột trong trường trình này - Không có kết nối mạng, nhưng dường như có dịch vụ điện thoại - bạn có thể sử dụng SMS. - Không có kết nối mạng Đóng Bây giờ bạn có thể kiểm tra thông tin đồng bộ bằng cách bấm vào đây - Dữ liệu này chỉ được lưu trong thiết bị của bạn, nó chưa được đồng bộ lên máy chủ. - Bạn đã được cập nhật! Tất cả dữ liệu của bạn đã gửi đến máy chủ. Bạn không thể liên kết một hồ sơ với chính nó - Mở khóa phiên đăng nhập - Hoàn tất - Mở - Không hỗ trợ kiểu giá trị này Thay đổi xem lịch - 15 Phút - Mỗi Giờ - Hàng ngày - Hàng tuần - Tuần - Cài đặt đồng bộ: %s Hồ sơ bạn đang truy suất không có đăng ký. Bạn không thể mở bảng trình bày dữ liệu Lỗi định dạng Xóa %s Bạn không có quyền để xóa dữ liệu này - Đường dẫn liên kết không đúng Đang điều chỉnh biểu nhập... - Số người nhận Bạn có chấp nhận gửi %dtin nhắn không? Đang gửi %1$d/%2$d Đang đợi xác nhận số lượng - %d @@ -540,51 +315,29 @@ Không cài đặt cổng Người dùng chưa đăng nhập Mô đung SMS bị tắt - Đang nộp SMS khác Mô đun SMS không được khởi tạo, không tìm thấy siêu dữ liệu yêu cầu. - Hoàn tất Đếm SMS không được chấp nhận Đã Nộp SMS Đọc và chuyển đổi dữ liệu. Mã nộp : %d - Lỗi khi gửi sms SMS đăng ký đối tượng theo dõi SMS nộp sự kiện SMS nộp biểu nhập - Đang bắt đầu - Đang tiến hành - Đã gửi - Thất bại - Đang bắt đầu đồng bộ Đang đồng bộ... - Đồng bộ đã hoàn tất. - Lỗi đã xảy ra trong khi đồng bộ. - Có một lỗi đã xảy ra trong khi đồng bộ. Bấm vào đây để kiểm tra nhật ký lỗi. - Đã hủy đồng bộ Đã sao chép - Hình ảnh đã chọn - Hiển thị trong bản đồ - Hiển thị danh sách - %slà bắt buộc nhưng chưa được cung cấp. - Đã thêm bản đồ ranh giới Bản đồ ranh giới - Vân tay - Vui lòng đặt ngón tay của bạn lên chỗ đọc dấu vân tay Bảo mật Vì lí do bảo mật, phiên bản này của ứng dụng không cho phép sử dụng trong các thiết bị nguồn. Vui lòng sử dụng phiên bản đào tạo. - Có một số lỗi. Vui lòng sửa chúng trước khi tiếp tục. Đang áp dụng các bộ lọc Không có bộ lọc nào được áp dụng Thời điểm Đơn vị - Đang sắp xếp thứ tự - Trạng thái Đồng bộ Đã gán cho tôi Hôm Nay @@ -599,8 +352,6 @@ Từ - Đến Khác Bất cứ khi nào - Lỗi đồng bộ. Kiểm tra giá trị dữ liệu để sửa vấn đề và thử lại. Nếu nó vẫn bị lỗi, hãy liên lạc quản lý của bạn. - Dữ liệu này đã được gửi đến máy chủ nhưng nó không đảm bảo đã được lưu trữ Mở Lịch hẹn Quá hạn @@ -616,7 +367,6 @@ Cổng SMS không được để trống Không có đăng ký để tải lên Nhiệm vụ chuyển đổi này không hỗ trợ - Không hiển thị lại màn hình khóa Cài đặt mã pin Xung đột Thay đổi ngày @@ -641,11 +391,7 @@ Chọn đơn vị Nhập đường dẫn liên kết Nhập giá trị - Chọn hình ảnh - Mã scan - Chọn tùy chọn Các giá trị dành riêng đã tải xuống cho mỗi thuộc tính Đối Tượng Theo Dõi - Các giá trị dành riêng đã tải xuống cho mỗi thuộc tính Đối Tượng Theo Dõi Còn %s giá trị dành riêng Quản lý giá trị dành riêng Sự kiện để tải xuống @@ -665,55 +411,29 @@ Chưa đồng bộ Lỗi đồng bộ Đã đồng bộ SMS - Chi tiết - Ghi chú Đã lưu! - Ban có muốn kiểm tra chất lượng dữ liệu không? - Mọi thứ đều ổn! - Bạn cũng muốn hoàn tất biểu nhập phải không? - Đánh dấu đã hoàn tất - Tên - Công thức - Hoàn tất luôn Thiếu %s %s từ %s %s Trạng thái sự kiện - Ngày đăng ký Không có dữ liệu. Bấm \"+\" để tạo hồ sơ mới. Không có dữ liệu. Bạn không có quyền để tạo hồ sơ mới. Không có mô tả hoặc hướng dẫn được cung cấp Dữ liệu để xem xét - Đã lưu và Hoàn tất! Có 1 đăng ký đang hoạt động Mở Hoàn tất Đã hủy - Dữ liệu %s đã chọn không thể tải xuống - Đã hoàn tất được %s: %s - Đã cập nhật: %s - Hoàn thành - Chúng tôi không thể kiểm tra chất lượng dữ liệu. Hãy liên hệ quản lý của bạn. - Bạn có chắc chắn không? - Bạn có muốn mở-lại biểu nhập này không? Ngày - Ngày sự kiện - %s không có các thuộc tính công khai. Vui lòng chọn một chương trình. Chính Sách Bảo Mật Bấm vào đây để kiểm trachính sách bảo mật Giá trị tọa độ không đúng + Bạn không thể đăng ký %stừ đơn vị khác vào chương trình này bởi vì nó được bảo vệ.\n Hãy liên hệ quản lý của bạn. Trường dữ liệu này bắt buộc Vì lí do bảo mật, phiên bản ứng dụng này không cho phép sử dụng với adb. Vui lòng sử dụng phiên bản đào tạo. Quay lại - Đóng ứng dụng - Thêm chi tiết - Thêm chi tiết - Đóng Sao chép vào bộ nhớ tạm - Đã sao chép vào bộ nhớ tạm - Thông tin lỗi Chúng tôi đã tìm thấy một lỗi khi đang đồng bộ với máy chủ của bạn. Hãy chia sẻ lỗi này với quản lý của bạn nếu có thể. - Chúng ta không thể khởi đầu phiên đăng nhập bằng tên đăng nhập của bạn Có các sự kiện đã lên kế hoạch mà sẽ không chỉnh sửa lịch hẹn. Biểu đồ Danh sách @@ -764,47 +484,26 @@ Phản hồi Chỉ Số Quyền mở máy ảnh bị từ chối..\nBạn cần bật tính năng máy ảnh để sử dụng chức năng này. - Giá trị Biểu đồ và Chỉ số Chụp ảnh - Chọn từ thư viện ảnh - Hoàn thành! Chương trình này đã được bảo vệ Mô tả lý do ở đây - Đang xác thực - Đường dẫn liên kết đã cung cấp không phải là đường dẫn đến máy chủ DHIS2 - Sai thông tin đăng nhập - Máy chủ không xác định - Giá trị phải là số nguyên Phiên đăng nhập đã hết hạn Phiên đăng nhập của bạn đã hết hạn. Vì lý do bảo mật, bạn cần cung cấp thông tin đăng nhập lại. - Giá trị Tuần 1%d 2%s đến 2%s Không hiển thị lại Cấu hình chức năng Xem Radar - Không thể cài đặt kiểu tọa độ Đã theo dõi %s - %s không thể chuyển đổi thành Ngày Không thể cập nhật trường dữ liệu này. Xin vui lòng thử lại. - Chỉnh sửa - Hiện có Không thể chỉnh sửa dữ liệu này bởi vị chúng được đánh dấu đã hoàn tất Không thể chỉnh sửa dữ liệu này bởi vì thời gian chỉnh sửa đã hết hạn Bạn không có quyền chỉnh sửa dữ liệu này Dữ liệu này không thể sửa bởi vì đơn vị đã bị đóng Bạn không có quyền chỉnh sửa dữ liệu này - Bạn không thể chỉnh sửa dữ liệu từ đơn vị này - Đặt lại Tìm Kiếm + Bạn không thể chỉnh sửa dữ liệu từ đơn vị này Tìm kiếm trực tuyến trả về tin nhắn sau: - Cảnh báo quy tắc chương trình - Có một vấn đề cấu hình gây ra một vòng lặp trong các quy tắc chương trình. Hãy liên lạc với quản lý của bạn. - LÀM MỚI - Không rõ - Làm mới lại tất cả dữ liệu - Bạn đã đạt số lượng tài khoản tối đa (%d). Để tạo thêm tài khoản, hãy đăng nhập và xóa một trong những tài khoản của bạn. Xóa Tài khoản - Quản lý Tài Khoản Xử lý sự cố cấu hình Ngôn ngữ Bấm vào đây để thay đổi ngôn ngữ của ứng dụng @@ -823,9 +522,7 @@ Không có kết quả cho tìm kiếm này Có quá nhiều kết quả Thử thay đổi tìm kiếm của bạn để thu hẹp kết quả. - Tạo mới %sMới - Cần phải nhập ít nhất %sthuộc tính để tìm kiếm Ok Mở trang tìm kiếm để tìm bất cứ %snào Bạn có thể tìm kiếm hoặc tạo một %smới @@ -834,7 +531,6 @@ Một số trường dữ liệu có lỗi và chưa được lưu. \nBạn có muốn xem lại biểu nhập không\? Bạn có muốn đánh dấu biểu nhập này là hoàn tất không? Bạn có một số tin nhắn lỗi.\nBạn có muốn xem không\? - Nếu bạn tắt bây giờ, tất cả thay đổi sẽ bị hủy bỏ. Đã Mở-lại Tài khoản đã bị vô hiệu hóa Tài khoản của bạn đã bị vô hiệu hóa bởi quản lý chương trình. @@ -844,27 +540,10 @@ Ngày hiện tại: %s Thiết bị: %s Phiên bản hệ điều hành: %s - Mở chi tiết %s - Chi tiết %s Đang đồng bộ %s... - Đã gửi SMS. Đang đợi máy chủ trả lời - Đã xác nhận SMS đến máy chủ. Đang cập nhật Trạng Thái - Chúng tôi không thể xác nhận SMS đến máy chủ hay chưa. Đang cập nhật trạng thái - Đã gửi SMS. Đang cập nhật trạng thái. - Chưa gửi SMS. - Đang mở ứng dụng SMS. Xin vui lòng không chỉnh sửa tin nhắn. Gửi SMS với Bạn đã gửi SMS chưa? - Đang đợi xác nhận thủ công - Đang đồng bộ các sự kiện - Đang đồng bộ các đối tượng theo dõi - Đang đồng bộ các tập dữ liệu - Đang đồng bộ tài nguyên - Đồng bộ hoàn tất - Đồng bộ SMS đang hiển thị nhưng phải được truy suất từng hồ sơ cá nhân Đang tải hình ảnh... - Mô tả chương trình - Sai mã pin Đã đăng ký trong Sở hữu bởi Cần được đồng bộ @@ -884,18 +563,11 @@ Làm mới Không phải bây giờ Bấm vào đây để xem thêm - - Các lỗi %s - - - Các cảnh báo %s - Phiên bản ứng dụng %s đang có. Bạn có muốn tải nó không? Cập nhật phần mềm Để sau Tải xuống ngay Kiểm tra các cập nhật - Đang kiểm tra các cập nhật Không có cập nhật mới Phiên bản ứng dụng Có lỗi trong cấu hình chương trình, chúng tôi không thể tải sự kiện này. Xin vui lòng liên hệ quản trị hệ thống của bạn. @@ -932,8 +604,6 @@ Giai Đoạn Chương Trình sự kiện Dòng thời gian - Bỏ tìm kiếm - Không bắt buộc Cơ sở dữ liệu đã được tải về Đang nhập cơ sở dữ liệu Hiện thêm @@ -956,4 +626,15 @@ Đã chuyển thành công %1$s đã hủy Ngày đến hạn đã được cập nhật - + + Các lỗi %s + + + + Các cảnh báo %s + + + + Có %s chương trình với các cài đặt riêng + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8371a7a180e..37f60f48ed7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,63 +1,28 @@ DHIS2 - 区域健康信息系统 - 2 错误 - - 失误 - - 服务器URL错误 - 证书不正确 - 未知错误 - 服务器内部错误 - 无值 没有更多保留值,请联系管理员 - 没有更多保留值,连接网络重新填充他们 - 错误 出了些问题 - 现在同步 - 与服务器同步中 - 同步 - 元数据 事件 - 跟踪的实体 - - 已完成 - 同步完成 同步错误 同步中发生错误,请重试 同步完成,但我们没有收到所有记录的服务器确认。这些记录在应用程序中仍被标记为“离线”状态。我们建议重试同步。 - 警告:你的大多数数据已经同步,但是有些字段存在冲突,同步你的配置再试。如果警告仍然存在,检查你的同步状态:项目、跟踪实体和事件如果有冲突会标记为@。 - 发生错误,服务器未响应。与您的管理员联系。 - - 发现结果:1%s - 新加 - 添加 输入%s 输入/跳过 %s - 新调度 流转 - 服务器URL - 用户名 - 口令 - 登录 - 解锁会话 同步你的配置 你的数据很快会被同步 同步你的数据 你的配置已经准备好 你的数据已经准备好 - 配置同步失败 - 数据同步失败 账号恢复 - 需要网络连接才能恢复您的帐户。 每天 @@ -78,138 +43,87 @@ 输入文本 输入长文本 输入数字 - 输入整数 输入负整数 输入正整数或零 输入正整数 - 过滤选项 选择日期 - 改变日历 - 项目已经完成 - 项目不活跃 开放 没完成 事件完成 只读 - 完成 已完成 已取消 重新打开 - 重新打开表单 - 激活 活跃的 同步 发送 更新 - 删除 错误 - 同步 - 发送 - 同步短信 - 完成 - 创建 更新 - 日期 机构 选择的日期没有开放的机构,请选择另外的日期或机构 - 分类选项 维度 经度 - 新事件 - 一次 - 永远 报告期间 - 采集选项 - 如果你设置值,整行的值需要设置才能完成数据集。 - 一些必需的值缺失,你需要设置强制的值才能完成数据集。 - 有强制字段请填充后完成数据集 - 值已被保存 - 没有可用期间可选 到期日期 下一页%1$s 分类选择框 - DD/MM/YYYY 取消 删除 - 总共 最近事件日期 无可用选项 登记清单 活动项目 - 注册的项目 注册 - 上次访问: - 应用 - 查看详情 - 更多 编辑 - 后续跟踪 - 使能后续跟踪 - 后续跟踪关闭 去活 该项目不允许更多事件 - 无数据 事件 首页 - 应用信息 退出 - 阻止会话 显示%s更多… 显示较少… - 1分钟 - 15分钟 30分钟 一小时 六小时 一天(缺省) 12小时 - 自动同步数据 每: 手工 - 自动同步配置 -每: 一周 当前同步参数 复位缺省值 + 数据库将被删除,\n 你将被要求再次登录。 接受 取消 现在同步数据 现在同步配置 设置 - 上次同步:1%s - 上次同步:%s 重新填充 - 短信模块: 短信网关 短信结果发送者 SMS 结果超时(秒) - - 数量 - 数量 激活短信提交 等待短信结果响应 - - 这里有%s个项目有特定的设置 - 设置限制 全局 按项目 @@ -227,19 +141,14 @@ 该日志包括你同步数据和元数据的所有错误。 删除本地数据 所有存储在你的设备的采集app数据将被删除。没有同步到服务器的数据将丢失。 - 重置应用数据和配置 - 所有存储在你的设备的采集app数据和配置将被删除。你将被要求重新登录并且没有与服务器同步的数据将会丢失。 短信设置 检查和编辑短信网关的相关参数。 软件更新 - 软件 导出数据库 导出数据库并与管理员共享 状态 - 注册日期 - 搜索 @@ -260,29 +169,15 @@ 坐标 - 分段列表 - 已经完成 %s 个问题,总共%s。 - 完成 %s共 %s - 完成并关闭 - 天数 - - - 事故日期 - 本地结果 - 在线结果 过去的项目 你没有执行该操作的权限 - 添加为 - 清除 期间 - (%d)机构 - 等级%d 欢迎来到DHIS2 你可以在这里查看你的活动 你可以应用一个过滤器 @@ -291,16 +186,11 @@ 选择主菜单的其它操作 - 创建关系 下一个 你至少需要输入 %d个属性便于搜索 - 你至少需要输入 一个属性便于搜索 - 有多余%d个结果,请再细化搜索条件 - 输入你的搜索条件 导入二维码数据 - 保存 标识 属性 @@ -312,32 +202,20 @@ 无数据 无效的二维码 该属性不能被添加 - 该注册不能添加 关于 注册的项目 - 全选 - 清除选择 - 选择的过滤器没有相关信息,请尝试改变 共享 共享二维码 最大 当前 跟踪实体 跟踪实体 - 按机构限制 - 按项目限制 - 项目列表 重置应用数据和配置 - 不能添加该关系 检查 - 警告 - 错误 保存 保留值 - 你想从二维码添加更多数据? - 应用版本:%s SDK版本:%s @@ -348,131 +226,43 @@ 由挪威奥斯陆大学信息系的一部分,DHIS2项目组开发和维护, 关于该应用更多信息或问题请联系 android@dhis2.org - 口令缺失,请输入。 - 用户名缺失,请输入。 - 服务器运行版本不是2.29,请更新服务器版本或使用测试环境继续 - 服务器运行的版本不是 %s。更新服务器版本使用测试的环境继续。 - 有些事出错,请检查服务器URL,用户名和口令是否正确后再试。 - 服务器不能完成该请求。 - 试图登录服务器存在问题。 - 登录错误 - 强制字段缺失 - 在线搜索发生未知的错误 - 解析结果发生错误 - 在线搜索返回错误。 - 服务器出现错误 选择一个项目开始注册 你没有请求的权限 - 功能还没有实现。 - 获取关系标签发生错误 所有 %s 不是有效的的电话号码 - 不是有效的电子邮件 - 仅仅允许负数 仅仅允许正整数和零 - 仅仅允许正数。 - 仅仅 从0到1的值允许 - 仅仅从0到100的值允许 - 选择过滤日期 选择选项 - 报告的问题 - 生成新事件 - 你要创建另外的事件? 完成的事件 该项目的所有事件已经完成。你同时要关闭该项目吗? - 你同时要完成登记注册吗? 您也想完成%s吗? 检查 首页 例子 - 添加组件 显示帮助 - 同步你的数据 - 你的配置 - 设置从服务器下载多少记录 - 检查和管理剩下多少保留值 - 检查是否到服务器的连接失败 - 释放更多盘空间 - 删除启动后的所有东西 - 发现在本地和联机服务器的实体 - 你可以切换到其它项目 - 搜索或添加一个新实体。你需要先搜索再添加一个新的 - 点击这里清除字段和重启搜索。 这是实体仪表盘 查看实体概要信息 - 通过二维码或短信共享信息。 - 或者标记实体为后续跟踪。 - 点击创建或调度一个事件或流转到其它机构 这里你将看到所有的事件。点击查看事件详情。 通过菜单添加关系、查看指标或写笔记。 - 点击查看实体的所有注册数据或者创建一个新的 - 点击删除该事件。该操作不能取消。 - 完成事件 - 所有该项目的相关事件将列表在这里。 - 点击添加新事件 输入所有数据创建事件 点击检查事件完成状态 - 点击完成或重新打开事件 - 通过二维码从其它设备读取并存储信息。 - 和服务器同步你的数据 - 用四个数字阻止会话。不要忘记你的口令。 - 退出以便改变用户或服务器,你将可以再次登录即使没有互联网。 - %s: 关系 还没有登记的事件,点击“+”新建一个。 - 还没有登记或上报的事件 数据值 信息 不能读取该ID 删除本地数据 概要 - 关系 - 指标 - 分析 该项目无指标 - 笔记 - 不能完成 - 无法保存 - 表格中有警告 - 你想做什么? - 有些字段有错误,请检查,才能完成项目 所有的注册 显示所有活动项目的仪表盘 - 打开同步错误日志 同步错误日志 分享 - 你的配置同步失败。服务器可能有错误,点击查看更多信息。 - 前一个 - 下一个 - 详情 - 指标 - 笔记 - - 稍后完成 - - 保存 - 有错误的字段 - 有些字段有错误,他们已经保存,请检查。 值应该唯一,发现相同的。还没有保存,请检查。 - 唯一字段发现相同的,请检查表单 以便保存注册 请等待 跳过 - 重新调度 该屏没有信息 - 逾期 - 存在不支持的项目规则,请检查文档。 - 该字段没有相关信息 - 空证书 - 为了能够使用指纹扫描器,你需要先使用缺省的方法 - 生物特征安全 - 快速登录 - 您想使用指纹或其他生物识别方法登录吗? - - - https:// 没有同步的数据不会丢失,你真想要继续 - 接受 数据不能删除,请重试 删除本地数据成功完成 至少得选择一个机构 @@ -495,33 +285,16 @@ 上载中 关系 通过短信发送 - 该项目没有冲突。 - 网络不可用。但是看起来电话系统可用,你可以使用短信 - 网络连接不可用。 关闭 点击这里检查同步信息。 - 数据仅仅存储在你的设备。还没有与服务器同步。 - 已经更新。所有的数据已经发送到服务器。 你不能关联记录到自身。 - 解锁会话 - 完成 - Open - 不支持的值类型 改变日历视图。 - 15分钟 - 每小时 - 每天 - 每周 - - 同步设置:%s 你试图访问的记录还没有注册。你不能访问仪表盘。 格式化错误 删除 %s 你没有删除记录的权限 - 没有有效的URL 调整表单。。。 - 接收号码 你接受发送 %d消息? 发送 %1$d/%2$d 等待确认量:%d @@ -539,51 +312,29 @@ 没有设置网关 用户没有登录 短信模块被关闭 - 其它的短信提交在进行中 短信模块没有初始化,没有发现需要的元数据。 - 已完成 短信数量不能接受 开始的短信提交。 数据读取和转换的。提交ID:%d - 短信提交发送错误 短信 实体注册和登记 SMS事件注册 SMS数据集提交 - 开始 - 进行中 - 发送 - 失败 - 开始同步 同步 - 同步完成 - 同步中发生错误。 - 同步期间发生错误。点击这里查看错误日志 - 取消的同步 副本 - 选择的图像 - 地图上显示 - 显示列表 - %s是强制的但是没有提供 - 采集的多边形。 多边形 - 指纹 - 请在指纹阅读器输入指纹 安全 基于安全原因,该版本的app不允许使用根设备。请使用培训版本。 - 有些错误,请修正后继续 应用过滤器 无过滤器 期间 机构 - 排序 - 状态 同步 指派给我 今天 @@ -598,8 +349,6 @@ 从——到 其它 任何时候 - 同步失败,检查数据值修正错误再试。如果仍然失败,联系管理员。 - 数据已经发送到服务器,但不保证已经被保存。 开放 调度 逾期 @@ -615,7 +364,6 @@ 短信网关不能为空 没有注册可以上传 转换任务不支持。 - 不再显示锁屏 设置PIN 冲突 改变日期 @@ -640,11 +388,7 @@ 选择机构 输入url 输入值 - 选择图像 - 扫描码 - 选择选项 每个TEI的属性下载的保留值 - 每个TEI的属性下载的保留值 剩下%s 保留值 管理保留值 下载的事件 @@ -664,55 +408,29 @@ 未同步 同步错误 短信已同步 - 详情 - 评论 已经保存! - 你要检查数据质量? - 一切都很好 - 你要完成数据集? - 标记已经完成 - 名称 - 公式 - 任何都要完成 遗失 %s %s 从%s %s 事件状态 - 注册日期 没有数据,点击“+”添加新的记录 没有数据,您没有权限添加新记录 没有提供描述或指导 复查的数据 - 保存并完成! 已经存在活动的注册 Open 完成 取消 - 数据 选择的%s不能下载 - %s完成:%s - 已更新:%s - 完成 - 不能检查质量,联系管理员 - 你确信? - 你要重新打开数据集? 日期 - 事故日期 - %s没有公有属性,请选择一个项目 私有策略 点击这里检查隐私策略 坐标值无效 + 您无法在此项目中加入来自其他组织单位的 %s,因为它受到保护。\n请与您的管理员联系。 此字段是必填字段 出于安全原因,此版本的应用程序不允许与adb一起使用。请使用培训版本。 回退 - 关闭应用 - 更多细节 - 更多细节 - 关闭 复制到剪贴板 - 复制到剪贴板 - 错误信息 与您的服务器同步时发现错误。如果可能,与系统管理员共享错误。 - 我们无法与您的用户发起会话 已经计划的事件不会重新安排 图表 列表 @@ -763,46 +481,27 @@ 反馈 指标 + 相机权限被拒绝。\n 您需要启用它才能使用此功能。 图表和指标 拍照 - 从图库中选择 - 完成! 该项目受保护 在此说明原因 - 验证中 - 提供的服务器URL不是dhis实例 - 证书不正确 - 未知服务器 - 值必须是一个整数 会话已过期 您当前的会话已过期。出于安全原因,您将需要再次提供凭据 - 星期%d %s到%s 不再显示 特征配置 显示为雷达图 - 无法设置坐标类型 跟进%s - %s 无法解析为日期 无法更新此字段。请再试一次 - - 可用的 此数据不可编辑,因为已标记为已完成 此数据不可编辑,因为其编辑时间已过期 您无权编辑此数据 此数据不可修改,因为单位部门已关闭 您无权编辑此数据 - 您无法编辑此机构的数据 - 重置搜索 + 您无法编辑此机构的数据 网上搜索返回如下信息: - 项目规则警告 - 存在导致规则循环的配置问题。联系您的管理员。 - 刷新 - 未知 - 刷新所有数据 - 您已达到最大帐户数 (%d)。要添加新帐户,请登录并删除您的一个帐户 删除帐户 - 管理帐户 配置疑难解答 语言 点击此处更改应用程序的语言 @@ -820,15 +519,14 @@ 此搜索没有结果 结果太多 尝试更改搜索以缩小结果范围。 - 新建 新%s - 需要至少输入%s属性才能搜索 Ok! 开始搜索以找到任何 %s 您可以搜索或创建一个新的 %s 您收到了一些警告信息。 您想将此表单标记为已完成吗? 如果您现在退出,更改将被丢弃。 + 您有一些错误消息。\n您要查看它们吗? 重新开放 帐户已禁用 您的帐户已被管理员禁用。 @@ -838,27 +536,10 @@ 当前日期:%s 设备:%s 操作系统版本:%s - 打开 %s 详细信息 - %s 细节 正在同步 %s… - 短信发送。等待服务器响应。 - 短信确认到达服务器。更新状态 - 我们无法确认 SMS 已到达服务器。更新状态 - 短信发送。更新状态。 - 短信未发送。 - 打开短信应用。请不要修改留言。 发送短信 你发短信了吗? - 等待手工确认 - 同步事件 - 同步跟踪的实体 - 同步数据集 - 同步资源 - 同步完成 - SMS 同步可用,但必须为每个单独的记录访问 正在下载图片... - 项目说明 - 错误的密码 已加入 拥有者 需要同步 @@ -878,18 +559,11 @@ 刷新 现在不要 点击此处探索 - - %s 错误 - - - %s 警告 - 应用程序版本%s 现已推出。您想下载吗? 软件更新 之后 现在下载 检查更新 - 查询更新 没有新的更新 应用版本 程序配置出了问题,我们无法加载此事件。请联系您的系统管理员。 @@ -926,8 +600,6 @@ 项目阶段 事件 时间线 - 清除搜索 - 可选项描述 数据库已下载 导入数据库 更多 @@ -952,4 +624,16 @@ 完整%s 停用%s 重新打开%s + + + %s 错误 + + + + %s 警告 + + + + 这里有%s个项目有特定的设置 + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ef76fabce31..218c288afc0 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1,66 +1,32 @@ DHIS2 - 区域健康信息系统 - 2 错误 - - 错误 - - 服务器URL错误 - 凭证错误 - 未知错误 - 服务器内部错误 - 无值 没有更多保留值,请联系管理员 - 没有更多保留值,连接网络重新填充他们 - 错误 出问题了 - 现在同步 - 与服务器同步中 - 现在同步 - 元数据 事件 - 跟踪的实体 - - 已完成 - 同步完成 同步错误 同步中发生错误,请重试 同步完成,但我们没有收到所有记录的服务器确认。这些记录在应用程序中仍被标记为“离线”状态。我们建议重试同步。 失败:同步过程中出了问题。如果有连接,请重试。如果错误仍然存在,请联系您的管理员。 您的数据存在冲突。请同步您的配置,查看数据并尝试再次同步。 - 警告:你的大多数数据已经同步,但是有些字段存在冲突,同步你的配置再试。如果警告仍然存在,检查你的同步状态:项目、跟踪实体和事件如果有冲突会标记为@。 您的部分数据未能同步。 - 发生错误,服务器未响应。与您的管理员联系。 - 发现结果:1%s - 新增 - 添加 输入%s 输入/跳过 %s - 新调度 转派 - 服务器URL - 用户名 - 口令 - 登录 - 解锁会话 同步你的配置 你的数据很快会被同步 同步你的数据 你的配置已经准备好 你的数据已经准备好 - 配置同步失败 - 数据同步失败 账号恢复 - 需要网络连接才能恢复您的帐户。 每日 @@ -81,117 +47,73 @@ 输入文本 输入长文本 输入数字 - 输入整数 输入负整数 输入正整数或零 输入正整数 - 过滤选项 选择日期 - 改变日历 - 项目已经完成 - 项目不活跃 开放 没完成 事件完成 只读 - 完成 已完成 已取消 重新打开 - 重新打开表单 - 激活 活跃的 同步 发送 更新 - 删除 错误 - 同步 - 发送 - 同步短信 - 完成 - 创建 更新 - 日期 机构 选择的日期没有开放的机构,请选择另外的日期或机构 - 分类选项 纬度 经度 - 新事件 - 一次 - 永远 报表周期 - 采集选项 - 如果你设置值,整行的值需要设置才能完成数据集。 - 一些必需的值缺失,你需要设置强制的值才能完成数据集。 - 有强制字段请填充后完成数据集 - 值已被保存 - 没有可用期间可选 到期日期 下一页%1$s 分类选择框 - DD/MM/YYYY 取消 删除 - 总共 最近事件日期 无可用选项 报名名单 活动项目 - 加入的项目 加入 - 上次访问: - 应用 - 查看详情 - 更多 编辑 - 后续跟踪 - 使能后续跟踪 - 后续跟踪关闭 去活 该项目不允许更多事件 - 无数据 事件 主页 - 应用信息 退出 - 阻止会话 显示%s更多… 显示较少… - 1分钟 - 15分钟 30分钟 1小时 6小时 1天(默认) 12小时 - 自动同步数据 每: 手工 - 自动同步配置: 1周 - 当前同步参数 - 复位缺省值 数据库将被删除。 您将被要求重新登录。 接受 @@ -199,22 +121,13 @@ 现在同步数据 现在同步配置 设置 - 上次同步:1%s 最后一次同步时间 - 上次同步:%s 重新填充 - 短信模块: 短信网关 短信结果发送者 SMS 结果超时(秒) - - 数量 - 数量 激活短信提交 等待短信结果响应 - - 这里有%s个项目有特定的设置 - 同步周期 设置限制 全局 @@ -233,12 +146,9 @@ 该日志包括你同步数据和元数据的所有错误。 删除本地数据 所有存储在你的设备的采集app数据将被删除。没有同步到服务器的数据将丢失。 - 重置应用数据和配置 - 所有存储在你的设备的采集app数据和配置将被删除。你将被要求重新登录并且没有与服务器同步的数据将会丢失。 短信设置 检查和编辑短信网关的相关参数。 软件更新 - 软件 导出数据库 导出数据库并与管理员共享 双重身份验证 @@ -246,8 +156,6 @@ 分两步启用或禁用身份验证。 - 加入日期 - 搜索 @@ -256,12 +164,6 @@ 书写新笔记 清除 添加笔记 - 反转 - 激活 - 强制字段缺失 - 有些强制字段缺失,如果你后退,所有保存的信息将被删除。 - 有些强制字段缺失,如果你后退,请填充他们 - 某些字段需要您注意。\n您要查看表单吗? 你的数据已经被保存 如果退回,所有输入信息将被删除 删除并后退 @@ -272,29 +174,15 @@ 坐标 - 分段列表 - 已经完成 %s 个问题,总共%s。 - 完成 %s共 %s - 完成并关闭 - - - - 星期 发生日期 - 本地结果 - 在线结果 过去的项目 你没有执行该操作的权限 - 添加为 - 清除 周期 - (%d)机构 - 等级%d 欢迎来到DHIS2 你可以在这里查看你的活动 你可以应用一个过滤器 @@ -303,7 +191,6 @@ 在主菜单上的选择其他操作 - 创建关系 下一步 你至少需要输入 %d个属性便于搜索 你至少需要输入 一个属性便于搜索 @@ -314,7 +201,6 @@ 导入二维码数据 - 保存 标识 属性 @@ -326,13 +212,9 @@ 无数据 无效的二维码 该属性不能被添加 - 该加入登记不能添加 关于 加入的项目 - 全选 - 清除选择 - 选择的过滤器没有相关信息,请尝试改变 分享 共享二维码 在线搜索无法按类型搜索。 @@ -341,19 +223,11 @@ 当前 跟踪实体 跟踪实体 - 按机构限制 - 按项目限制 - 项目列表 重置应用数据和配置 - 不能添加该关系 检查 - 警告 - 错误 保存 保留值 - 你想从二维码添加更多数据? - 应用版本:%s SDK版本:%s @@ -364,148 +238,45 @@ DHIS2 项目组开发和维护,这是奥斯陆大学信息学系的一部分。 关于该应用更多信息或问题请联系 android@dhis2.org和lin_xd@126.com - 口令缺失,请输入。 - 用户名缺失,请输入。 - 服务器运行版本不是2.29,请更新服务器版本或使用测试环境继续 - 服务器运行的版本不是 %s。更新服务器版本使用测试的环境继续。 - 有些事出错,请检查服务器URL,用户名和口令是否正确后再试。 - 服务器不能完成该请求。 - 试图登录服务器存在问题。 - 登录错误 - 强制字段缺失 - 在线搜索发生未知的错误 - 解析结果发生错误 - 在线搜索返回错误。 - 服务器出现错误 选择一个项目开始加入登记 你没有请求的权限 - 功能还没有实现。 - 获取关系标签发生错误 所有 %s 不是有效的的电话号码 - 不是有效的电子邮件 - 仅仅允许负数 仅仅允许正整数和零 - 仅仅允许正数。 - 仅仅 从0到1的值允许 - 仅仅从0到100的值允许 - 选择过滤日期 选择选项 - 报告的问题 - 生成新事件 - 你要创建另外的事件? 完成的事件 该项目的所有事件已经完成。你同时要关闭该项目吗? - 您还想同时完成加入登记吗? 您也想完成%s吗? 检查 主页 例子 - 添加组件 显示帮助 - 同步你的数据 - 你的配置 - 设置从服务器下载多少记录 - 检查和管理剩下多少保留值 - 检查是否到服务器的连接失败 - 释放更多盘空间 - 删除启动后的所有东西 - 发现在本地和联机服务器的实体 - 你可以切换到其它项目 - 搜索或添加一个新实体。你需要先搜索再添加一个新的 - 点击这里清除字段和重启搜索。 这是实体仪表盘 查看实体概要信息 - 通过二维码或短信共享信息。 - 或者标记实体为后续跟踪。 - 点击创建或调度一个事件或流转到其它机构 这里你将看到所有的事件。点击查看事件详情。 通过菜单添加关系、查看指标或写笔记。 - 点击查看实体的所有登记数据或者创建一个新的 - 点击删除该事件。该操作不能取消。 - 完成事件 - 所有该项目的相关事件将列表在这里。 - 点击添加新事件 输入所有数据创建事件 点击检查事件完成状态 - 点击完成或重新打开事件 - 通过二维码从其它设备读取并存储信息。 - 和服务器同步你的数据 - 用四个数字阻止会话。不要忘记你的口令。 - 退出以便改变用户或服务器,你将可以再次登录即使没有互联网。 - %s: 关系 没有注册事件,单击“ +”添加一个新事件。 - 没有注册事件。 数据值 信息 不能读取该ID 删除本地数据 概要 - 关系 - 指标 - Analytics 该项目无指标 - 笔记 - 不能完成 - 无法保存 - 表格中有警告 - 你想做什么? - 有些字段有错误,请检查,才能完成项目 所有的加入登记 显示所有活动项目的仪表盘 - 打开同步错误日志 同步错误日志 分享 - 你的配置同步失败。服务器可能有错误,点击查看更多信息。 - 前一个 - 下一个 - 详情 - 指标 - 笔记 - - 稍后完成 - - 保存 - 有错误的字段 - 有些字段有错误,他们已经保存,请检查。 值应该唯一,发现相同的。还没有保存,请检查。 - 唯一字段发现相同的,请检查表单 以便保存注册 请等待..... 跳过 - 重新调度 该屏没有信息 - 逾期 - 存在不支持的项目规则,请检查文档。 - 该字段没有相关信息 - 空证书 - 为了能够使用指纹扫描器,你需要先使用缺省的方法 - 生物特征安全 - 快速登录 - 您想使用指纹或其他生物识别方法登录吗? - - - https:// 没有同步的数据不会丢失,你真想要继续 - 接受 数据不能删除,请重试 删除本地数据成功完成 - 至少得选择一个机构 - 发送数据 - 通过共享与您的用户ID相关的错误日志来帮助DHIS2改进此应用程序。 - 你想帮助我们改进这个应用程序吗? - 如果您同意,应用程序将发送匿名统计数据和错误日志,帮助我们改善用户体验和性能。 - 隐私政策 - 检查字段 - 你丢掉了互联网连接,当执行配置同步的时候。稍后再试。 - 同意 - 继续 - 服务器URL - 输入系统管理员为贵组织提供的 DHIS 2 服务器网址。\n\n或者,您也可以扫描包含服务器 URL 的 QR 代码 - 忘记你的数字口令 - 对该事件你想做什么? - 结束 %s没有发现 你试图访问的记录不可用。你想搜索吗? 通过短信同步。 @@ -513,33 +284,16 @@ 上载中 关系 通过短信发送 - 该项目没有冲突。 - 网络不可用。但是看起来电话系统可用,你可以使用短信 - 网络连接不可用。 关闭 点击这里检查同步信息。 - 数据仅仅存储在你的设备。还没有与服务器同步。 - 已经更新。所有的数据已经发送到服务器。 你不能关联记录到自身。 - 解锁会话 - 完成 - 开放 - 不支持的值类型 改变日历视图。 - 15分钟 - 每小时 - 每日 - 每周 - - 同步设置:%s 你试图访问的记录还没有注册。你不能访问仪表盘。 格式化错误 删除 %s 你没有删除记录的权限 - 没有有效的URL 调整表单。。。 - 接收号码 你接受发送 %d消息? 发送 %1$d/%2$d 等待确认量:%d @@ -557,51 +311,29 @@ 没有设置网关 用户没有登录 短信模块被关闭 - 其它的短信提交在进行中 短信模块没有初始化,没有发现需要的元数据。 - 已完成 短信数量不能接受 开始的短信提交。 数据读取和转换的。提交ID:%d - 短信提交发送错误 SMS 跟踪实体实例TEI注册和报名 SMS事件注册 SMS数据集提交 - 开始 - 进行中 - 发送 - 失败 - 开始同步 同步 - 同步完成 - 同步中发生错误。 - 同步期间发生错误。点击这里查看错误日志 - 取消的同步 副本 - 选择的图像 - 地图上显示 - 显示列表 - %s是强制的但是没有提供 - 采集的多边形。 多边形 - 指纹 - 请在指纹阅读器输入指纹 安全 基于安全原因,该版本的app不允许使用根设备。请使用培训版本。 - 有些错误,请修正后继续 应用过滤器 无过滤器 周期 机构 - 排序 - 状态 同步 指派给我 今天 @@ -616,8 +348,6 @@ 从——到 其它 任何时候 - 同步失败,检查数据值修正错误再试。如果仍然失败,联系管理员。 - 数据已经发送到服务器,但不保证已经被保存。 开放 调度 逾期 @@ -628,15 +358,12 @@ 设置有限%s 活动:%s/%s TEI:%s/%s 设置限制%s - 隐藏加入数据 - 显示加入数据 短信模块被激活 短信模块被关闭 下载短信模块,请等待。 短信网关不能为空 没有登记可以上传 转换任务不支持。 - 不再显示锁屏 设置PIN 冲突 改变日期 @@ -661,11 +388,7 @@ 选择机构 输入url 输入值 - 选择图像 - 扫描码 - 选择选项 每个TEI的属性下载的保留值 - 每个TEI的属性下载的保留值 剩下%s 保留值 管理保留值 待下载的事件 @@ -685,39 +408,19 @@ 未同步 同步错误 短信已同步 - 详情 - 评论 已经保存! - 您要检查数据质量吗? - 一切看起来都不错! - 你要完成数据集? - 标记为完成 - 名称 - 公式 - 任何都要完成 遗失 %s %s 从%s %s 事件状态 - 登记日期 没有数据,点击“+”添加新的记录 没有数据,您没有权限添加新记录 没有提供描述或指导 复查的数据 - 保存并完成! 已经存在活动的注册 开放 完成 已取消 - 数据 选择的%s不能下载 - %s完成:%s - 已更新:%s - 完成 - 不能检查质量,联系管理员 - 你确信? - 你要重新打开数据集? 日期 - 事件日期 - %s没有公有属性,请选择一个项目 私有策略 点击这里检查隐私策略 坐标值无效 @@ -731,15 +434,8 @@ 哎呀!有些事情没有按计划进行。 让我们帮你恢复行动。 后退 - 关闭应用 - 更多细节 - 更多细节 - 关闭 复制到剪贴板 - 复制到剪贴板 - 错误信息 与您的服务器同步时发现错误。如果可能,与系统管理员共享错误。 - 我们无法与您的用户发起会话 还有计划的事件,不能重新安排 图表 列表 @@ -790,47 +486,26 @@ 反馈 指标 相机权限被拒绝。\n您需要启用它才能使用此功能。 - 图表和指标 拍照 - 从图库中选择 - 完成! 该项目受保护 在此说明原因 - 验证中 - 提供的服务器URL不是dhis实例 - 凭证错误 - 未知服务器 - 值必须是整数 会话已过期 您当前的会话已过期。出于安全原因,您将需要再次提供凭据 - 星期%d %s到%s 不再显示 特征配置 显示为雷达图 - 无法设置坐标类型 跟进%s - %s 无法解析为日期 无法更新此字段。请再试一次 - - 可用的 此数据不可编辑,因为已标记为已完成 此数据不可编辑,因为其编辑时间已过期 您无权编辑此数据 此数据不可修改,因为单位部门已关闭 您无权编辑此数据 - 您无法编辑此机构的数据 - 重置搜索 + 您无法编辑此机构的数据 网上搜索返回如下信息: - 项目规则警告 - 存在导致规则循环的配置问题。联系您的管理员。 - 刷新 - 未知 - 刷新所有数据 - 您已达到最大帐户数 (%d)。要添加新帐户,请登录并删除您的一个帐户 删除帐户 - 管理帐户 配置疑难解答 语言 点击此处更改应用程序的语言 @@ -849,9 +524,7 @@ 此搜索没有结果 结果太多 尝试更改搜索以缩小结果范围。 - 新建 新%s - 需要至少输入%s属性才能搜索 Ok! 开始搜索以找到任何 %s 您可以搜索或创建一个新的 %s @@ -860,7 +533,6 @@ 一些字段有错误并且它们没有被保存。 \n您要查看表单吗? 您想将此表单标记为已完成吗? 您有一些错误消息。\n您要查看它们吗? - 如果您现在退出,更改将被丢弃。 重新开放 帐户已禁用 您的帐户已被管理员禁用。 @@ -870,27 +542,10 @@ 当前日期:%s 设备:%s 操作系统版本:%s - 打开 %s 详细信息 - %s 细节 正在同步 %s… - 短信发送。等待服务器响应。 - 短信确认到达服务器。更新状态 - 我们无法确认 SMS 已到达服务器。更新状态 - 短信发送。更新状态。 - 短信未发送。 - 打开短信应用。请不要修改留言。 发送短信 你发短信了吗? - 等待手工确认 - 同步事件 - 同步跟踪的实体 - 同步数据集 - 同步资源 - 同步完成 - SMS 同步可用,但必须为每个单独的记录访问 正在下载图片... - 项目说明 - 错误的密码 已加入 拥有者 需要同步 @@ -910,18 +565,11 @@ 刷新 现在不要 点击此处探索 - - %s 错误 - - - %s 警告 - 应用程序版本%s 现已推出。您想下载吗? 软件更新 之后 现在下载 检查更新 - 查询更新 没有新的更新 未知来源权限被拒绝。\n您需要启用它才能安装版本。 存储权限被拒绝。\n您需要启用它才能安装版本。 @@ -963,8 +611,6 @@ 项目阶段 事件 时间线 - 清除搜索 - 可选项描述 数据库已下载 导入数据库 没有配置可使用的搜索字段。 @@ -972,10 +618,6 @@ 更多 显示较少 同步 - 显示字段 - 隐藏字段 - 导入成功 - 此部分配置错误。 成功同步 正在同步 %s… 文件资源 @@ -992,4 +634,15 @@ 完整%s 停用%s 重新打开%s + + %s 错误 + + + + %s 警告 + + + + 这里有%s个项目有特定的设置 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d06b6357755..d6c7c3dcd3a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,70 +1,33 @@ Dhis2 - dhis - 2 - \u0009 - 20180901 Error - - Error - Errors - - Wrong server url - Wrong credentials - Unexpected error - Internal server error - no value There are no more reserved values. Contact your system administrator - There are no more reserved values. Connect to the network to refill them - errors Something went wrong - Synchronise now - Synchronising with server - Syncing now - Metadata Events - Tracked entities - - Finished - Sync complete Sync Error An error has occurred during synchronisation. Please retry. Sync finished but we did not receive server confirmation for all records. Those record are still marked as \"offline\" in the App. We recommend to retry the synchronization. FAIL: Something went wrong in the sync process. If you have connection please try again. If the error persists, contact your administrator. There are conflicts in your data. Please sync your configuration, review your data and try syncing again. - WARNING: Most of your data is synced but some fields presented conflicts. Sync your configuration and try again. If warning persists, check their sync status: programs, TEI\'s and events with conflicts are marked as @. Some of your data failed to sync. - There was an error and the server did not respond. Contact your administrator. - %s results found - Add new - Add Enter %s Enter / Skip %s - Schedule new Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat Referral - Server url - Username - Password - Log In - Unlock session Syncing your Configuration Your Data will shortly be synced Syncing your Data Your Configuration is ready Your Data is ready - Configuration sync failed - Data sync failed Account recovery - A network connection is needed to recover your account. Daily @@ -90,141 +53,86 @@ Enter text Enter long text Enter number - Enter integer Enter negative integer Enter positive integer or zero Enter positive integer - Filter options Choose date - Change calendar - Program Completed - Program inactive Open Not completed Event completed Read only - Finish and complete Completed Cancelled Re-open - Re-open form - Activate Active Sync To send To update - To delete Error - SYNC - SEND - SYNC SMS - Completion - Create Update - Date Org unit There aren\'t any open Organization Units for the selected date. Please select another date or another Organisation Unit. - Category option Latitude Longitude - New event - Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. - One time - Permanent Report period - Cat option - If you set a value, the whole row needs all of values to complete the Data Set. - Some values required are missing. You need set mandatory values to complete the Data Set. - There are missing mandatory fields. Please, fill them to complete the data set. - Value is saved! - There is no available periods to select. Due date Next %1$s Cat combo - DD/MM/YYYY Cancel Delete - Total Most recent event date No options available Enrollment list Active programs - Programs to enroll Enroll - Last Visit: - apply - See Details - VIEW MORE Edit - Follow up - Follow up enabled - Follow up disabled Deactivate This program does not allow more events - No data events Home - App Info Log Out - Block Session Show %s more… Show less… - 1 Minute - 15 Minutes 30 Minutes 1 Hour 6h 1 Day (Default) 12h - Automatic Sync Data every: Manual - Automatic Sync Configuration every: 1 Week - Current sync parameters: - Reset to default Database will be deleted.\nYou will be asked to log in again. Accept Cancel SYNC Data Now SYNC Configuration Now Settings - Last sync on: %s Last sync on - Last sync on: %s Refill - SMS module: SMS gateway SMS result sender SMS result timeout (sec) - seconds - number - number Enable SMS submission Wait for SMS result response - - There is %s program with specific settings - There are %s programs with specific settings - Sync period Settings limited Globally @@ -243,12 +151,9 @@ This log contains all errors caused by syncing your data and metadata. Delete local data All Capture App data stored in your device will be deleted. Data which is not synced to the server will be lost. - Reset app data & configuration - Your configuration and App data stored in the device will be deleted. You will be asked to log in again and data which is not synced to the server will be lost. SMS Settings Check and edit parameters related to the sms gateway Software update - Software Export database Export your database and share it with your administrator Two factor authentication @@ -256,8 +161,6 @@ Enable or disable the authentication in two steps. - Enrollment date - Search @@ -266,43 +169,19 @@ Write new note clear Add note - Revert - Activated - Mandatory fields are missing - Some mandatory fields are missing. If you want to go back now, all saved information will be deleted. - Some mandatory fields are missing. If you want to go back, please fill them. - Some fields need your attention.\nDo you want to review the form? Your data is not saved - If you go back now all information entered will be deleted. - Delete and go back - Some fields have errors:\n%s\nPlease, check them. - Some mandatory fields are missing. You can finish data entry and exit, but the event cannot be completed. Coordinates for the - Section list - %s questions of %s completed - %s of %s completed - Complete and close - Days - Months - Years - Weeks Incident date - Local results - Online results Past programs You don\'t have the required permission to perform this action - Add as… - Clear Period - (%d) Org Unit - Level %d Welcome to dhis2 Here you can see your activities You can apply a filter… @@ -311,17 +190,11 @@ Check other actions on the main menu - Create relationship Next You need to enter at least %d of the attributes to do a search - You need to enter at least one of the attributes to do a search - There is more than %d results. Please enter further search criteria - Your search criteria didn\'t return any result.\nRevise your criteria or enroll a new %s - Enter your search criteria Import QR data - Save OK Identification Attributes @@ -333,33 +206,18 @@ no data Invalid QR code This attribute can\'t be added - This enrollment can\'t be added About Programs to enroll - select all - clear selection - There is no information for the filters selected. Please try changing them. Share Share QR - Search by type is not possible for online search.\nPlease select a program from the list. - Max - Current TEI TEIs - Limit by organisation unit - Limit by program - Program list Reset app data & configuration - It\'s not possible to add this relationship Check this! - Warning - Error Save Reserved Values - Do you want to add more data from QR? - App Version: %s SDK Version: %s @@ -370,148 +228,45 @@ Developed and maintained by the DHIS2 project, part of University of Oslo, department of informatics. For more information or questions about this application, please write to android@dhis2.org - Password is missing. Please fill this field. - Username is missing. Please fill this field. - This server is running a dhis version different than 2.29. Update your server version or use the testing environment to proceed. - This server is running a dhis version different than %s. Update your server version or use the testing environment to proceed. - Something went wrong. Please check that server url, username and password are correct and try again. - The server could not complete this request. - There has been a problem while trying to log into the server. - Login error - Mandatory fields are missing - Unexpected error while searching online - Error while parsing results - Searching online returned an error - Something went wrong in the server Select a program to enable enrolling You don\'t have the requiered permission - This functionality is not ready yet. - Error getting relationship label All %s This is not a valid phone number - This is not a valid email - Only negative numbers are allowed Only positive numbers or zero allowed - Only positive numbers are allowed - Only values from 0 to 1 are allowed - Only values from 0 to 100 are allowed - Select dates to filter Select option - Issue reported - Generate new event - Do you want to create another event? Events Completed All events in this program are completed. Would you like to close the program as well? - Would you like to complete the enrollment as well? Would you like to complete the %s as well? Check Home EXAMPLE - Add widget Show help - Synchronize your data… - …your configuration… - …set how many records to download from server… - Check and manage how many reserved values are left - Check if a connection to the server failed - Free some disk space - …or delete everything to start over. - Find your entities both in your local device and the online server. - You can switch to other programs - Search or Add new entities. You need to search before you can add a new one! - Click here to clean the fields and restart the search. This is the entity dashboard See entities profile information… - …Share information via QR or SMS… - …Or mark the entity for follow-up - Click to create or schedule an event or refer to another Org Unit. Here you will see all events. Click to see an event detail. Navigate through the menu to add relationships, see indicators or write notes. - Click to see all enrollments of your entity or create a new one. - Click to delete this event. This action can not be undone. - Complete the event. - All events related to this program will be listed here. - Click to add a new event Enter all data to create the event Click to check the event completion status. - Click to complete or re-open an event - Read and store information from other devices via QR - Synchronize your data with the server - Block session with a 4 digit code. Do not forget your code! - Log out to change user or server. You will be able to log in again even if there is no internet connection. - %s: relationships There are no events registered, click \"+\" to add a new one. - There are no events registered. Data values Info Could not read this Id Delete local data Overview - Relationships - Indicators - Analytics There are no indicators for this program. - Notes - Unable to complete - Unable to save - There are warnings in the form - What do yo want to do? - Some fields have errors. Please, check them to be able to complete All enrollments Show dashboard with all active programs - Open sync errors log Sync error log Share with... - Your configuration sync has failed. There could be errors in your server. Click here for more information. - PREV - NEXT - Details - Indicators - Notes - - Complete later - - Finish - Fields with errors - Some fields have errors. They have not been saved. Please, check them. The value should be unique, a coincidence has been found. It has not been saved. Please check it. - A coincidence in a unique field has been found. Please check form to save Enrollment. Please wait Skip - Reschedule There is no information for this screen - Overdue - There is one program rule which is not supported. Please check the documentation - No info for this field - Empty Credentials - To be able to log in using the fingerprint scanner you first need to use the default way - Biometric Security - Quick login - Would you like to use your fingerprint or another biometric method to log in? - Tables - Table - https:// Non synchronized data will be lost. Do you really want to continue? - Accept Data could not be deleted. Please retry. Delete local data finished successfully. - At least one organisation unit must be selected - Send data - Help DHIS2 improve this app by sharing error logs associated to your user id. - Do you want to help us improve this app? - If you say yes the app will send anonymous statistics and error logs which help us improve user experience and performance.\n\nFor further details, please refer to our privacy policy. - privacy policy - Check fields - You lost your internet connection while performing your configuration sync. Please try again later - Agree - Continue - Server url - Enter the DHIS 2 server url for your organisation as provided by your system administrator.\n\nAlternatively, you can scan a QR code containing the URL of your server - Forgot your code? - What would you like to do for this event? - END %s not found The record you are trying to access is not available. Would you like to search for it? Synced by SMS @@ -519,33 +274,16 @@ Uploading Relationship Sent by sms - There are no conflicts for this program - Network connection is not available, but it looks like there is phone services - you can use SMS. - Network connection is not available. Close Now you can check sync information by clicking here - This data is stored in your device only, it is not synced with the server. - You are up to date! All your data is sent to the server. You can\'t relate a record with itself - Unblock session - Complete - Open - Unsupported value type Change calendar view - 15 minutes - Every hour - Daily - Weekly - Week - Synchronization setting: %s The record you are trying to access has no enrollment. You can\'t access the dashboard. Formatting error Delete %s You do not have the authority to delete this record - This is not a valid url Adjusting form… - Receiver number Do you accept to send %d messages? Sending %1$d/%2$d Waiting amount confirmation - %d @@ -563,51 +301,29 @@ No gateway set User not logged in SMS module is disabled - Another SMS submission ongoing SMS module not initiated, not found required metadata - Completed SMS count not accepted Started sms submission Data read and converted. Submission id: %d - Error sending sms submission SMS TEI registration and enrollment SMS event submission SMS data set submission - Starting - In progress - Sent - Failed - Starting synchronization. Synchronizing... - Synchronization completed. - Error has happened during synchronization. - An error has happened during synchronization. Click here to check error logs - Cancelled synchronization. Copied - Image selected - Show in map - Show list - %s is mandatory but has not been provided. - Polygon captured Polygon - Fingerprint - Please, put your finger in your fingerprint reader. Security For security reasons, this version of the app is not allow to be used in rooted devices. Please use the training version. - There are some errors. Please fix them before continuing. Filters applying No filters applied Period Org. Unit - Sorting - Status Sync Assigned to me Today @@ -622,26 +338,19 @@ From - To Other Anytime - Sync failed. Sync failed. Check the data values to try to fix the problem and try again. If it keeps failing contact your system administrator - This data has been sent to the server but it is not guaranteed to have been stored Open Schedule Overdue Visited Completed Skipped - Events:%smax:%s%scurrent:%s TEI:%smax:%s%scurrent:%s - Settings limited %s\nEvents: %s/%s TEI: %s/%s Settings limited %s - Hide enrollment data - Show enrollment data SMS module enabled SMS module disabled Downloading data for SMS module. Please wait SMS gateway can\'t be empty There are no enrollments to upload This convertTask is not supported - Don´t show again lock screen Set pin Conflict Change date @@ -671,11 +380,7 @@ Choose organisation unit Enter url Enter value - Choose image - Scan code - Choose option Reserved values downloaded per TEI attribute - Reserved values downloaded per TEI attribute %s reserved values left Manage reserved values Events to download @@ -695,60 +400,31 @@ Not Synced Sync Error SMS Synced - Details - Comments Saved! - Do you want to check data quality? - Everything looks good! - Do you also want to complete the data set? - Mark as completed - Name - Formula - Complete anyway Missing %s %s from %s %s Event status @string/enrollment_status - Enrollment date There are no data. Click \"+\" to add new a new record There are no data. You don\'t have permission to add new records. No description or instruction provided Data to review - Saved and completed! There is already an active enrollment Open Complete Cancelled - - Data The selected %s could not be downloaded. - Completed by %s: %s - Updated: %s - Done - We could not check data quality. Contact your administrator - Are you sure? - Do you want to re-open the data set? Date - Event date - %s has no public attributes. Please select a program Privacy Policy Click here to check our privacy policy - Coordinates values are not valid - You cannot enroll TEIs from other org units in this program because it is protected.\nContact your administrator. You cannot enroll a %s from other org units in this program because it is protected.\nContact your administrator. This field is mandatory For security reasons, this version of the app is not allow to be used with adb. Please use the training version. Oops! Something didn\'t go as planned.\nLet us get you back in action. Go back - Close app - More details - More details - Close Copy to clipboard - Copied to clipboard - Error information We found an error when syncing with your server. Share the error with your system administrator if possible. - We could not init the session with your user There are planned events that won\'t be re-scheduled Charts List @@ -800,49 +476,28 @@ Feedback Indicators Camera permission is denied.\nYou need to enable it to use this feature. - Value Charts and indicators Take Photo - Choose from gallery - Done! This program is protected Describe the reason here - Authenticating - Provided server url is not a dhis instance - Wrong credentials - Unknown server - Value must be an integer The session has expired Your current session has expired. For security reasons you will need to provide your credentials again - Values %s - %s Week %d %s to %s %d %s - %d %s Do not show again Feature Configuration Show as Radar - Can not set coordinate type Followed up %s - %s can\'t be parsed as a date This field could not be updated. Please try again - Edition - Available This data is not editable because is marked as completed This data is not editable because its edition time is expired You do not have permission to edit this data This data is not editable because the organization unit is closed You do not have permission to edit this data - You cannot edit data from this organisation unit - Reset search + You cannot edit data from this organisation unit The online search returned the following message: - Program rules warning - There is a configuration issue causing a loop in the rules. Contact you administrator. - REFRESH - Unknown - Refresh all data - You have reached the maximum number of accounts (%d). To add a new account, log in and delete one of your accounts Delete Account - Manage Accounts Configuration troubleshooting Language Tap here to change the language of the application @@ -861,7 +516,6 @@ There are no results for this search Too many results Try changing your search to narrow down the results. - Create new New %s New %s with last biometrics It is necessary to enter at least %s attributes to search @@ -873,7 +527,6 @@ Some fields have errors and they are not saved. \nDo you want to review the form? Do you want to mark this form as complete? You have some error messages.\nDo you want to review them? - If you exit now the changes will be discarded. Re-opened Account disabled Your account has been disabled by an administrator. @@ -883,27 +536,10 @@ Current date: %s Device: %s Os version: %s - Open %s details - %s details Syncing %s… - SMS sent. Waiting for server response. - SMS confirmed to reach server. Updating Status - We can\'t confirm the SMS reached the server. Updating Status - SMS sent. Updating status. - SMS not sent. - Opening SMS app. Do not modify the message please. Send Sms with Did you send the sms? - Awaiting manual confirmation - Syncing events - Syncing tracked entities - Syncing data sets - Syncing resources - Syncing done - SMS sync is available but has to be accessed for each individual record Downloading images... - Program description - Wrong pin Enrolled in Owned by Sync needed @@ -923,20 +559,11 @@ Refresh Not now Tap here to explore - - %s error - %s errors - - - %s warning - %s warnings - App version %s is now available. Do you want to download it? Software update Later Download now Check for updates - Checking for updates There are no new updates Unknown sources permission is denied.\nYou need to enable it to install version. Storage permission is denied.\nYou need to enable it to install version. @@ -978,18 +605,10 @@ Program stage event Timeline - Clear search - Optional Database downloaded - Importing database - There are no search fields configured to use.\nPlease contact your admin. Show more Show less Sync - Show fields - Hide fields - Import successful - This section is misconfigured.\nContact your administrator. Successful sync Syncing %s… file resources @@ -1006,4 +625,20 @@ Complete %s Deactivate %s Re-open %s + Developer options + + + %s error + %s errors + + + + %s warning + %s warnings + + + + There is %s program with specific settings + There are %s programs with specific settings + diff --git a/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt b/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt index c2cc572746f..21bb995381c 100644 --- a/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt +++ b/app/src/test/java/org/dhis2/bindings/SettingsExtensionsTest.kt @@ -8,6 +8,7 @@ class SettingsExtensionsTest { private val metadataSyncingPeriods = arrayListOf( EVERY_HOUR, + EVERY_6_HOUR, EVERY_12_HOUR, EVERY_24_HOUR, EVERY_7_DAYS, diff --git a/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt b/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt index 6aa51739cbf..dd5fb510726 100644 --- a/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt +++ b/app/src/test/java/org/dhis2/bindings/TEICardExtensionsTest.kt @@ -8,6 +8,7 @@ import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.model.MetadataIconData import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.junit.Test import org.mockito.kotlin.any @@ -128,5 +129,7 @@ class TEICardExtensionsTest { .color("color") .icon("icon") .build(), - ).build() + ).categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() } diff --git a/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt b/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt index 0fe12fafebd..bc018e273e9 100644 --- a/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt +++ b/app/src/test/java/org/dhis2/data/dhislogic/DhisEnrollmentUtilsTest.kt @@ -47,6 +47,7 @@ class DhisEnrollmentUtilsTest { .builder() .uid("enrollmentUid") .status(EnrollmentStatus.CANCELLED) + .attributeOptionCombo("attributeOptionComboUid") .build() val result = dhisEnrollmentUtils.isEventEnrollmentOpen( @@ -92,6 +93,7 @@ class DhisEnrollmentUtilsTest { .builder() .uid("enrollmentUid") .status(EnrollmentStatus.ACTIVE) + .attributeOptionCombo("attributeOptionComboUid") .build() val result = dhisEnrollmentUtils.isEventEnrollmentOpen( diff --git a/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt b/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt index 423bc8ad1ae..7e32ff6d492 100644 --- a/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt +++ b/app/src/test/java/org/dhis2/data/dhislogic/DhisPeriodUtilsTest.kt @@ -154,6 +154,31 @@ class DhisPeriodUtilsTest { ) } + @Test + fun `WeeklyFriday period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.WeeklyFriday, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("2019ThuW2") + .startDate(GregorianCalendar(2019, 0, 11).time) + .endDate(GregorianCalendar(2019, 0, 17).time) + .build() + + Assert.assertEquals( + "Week 0 2019-01-11 To 2019-01-17", + periodUtils.getPeriodUIString( + PeriodType.WeeklyFriday, + testDate, + Locale.ENGLISH, + ), + ) + } + @Test fun `WeeklySaturday period should return expected result`() { whenever( @@ -430,6 +455,31 @@ class DhisPeriodUtilsTest { ) } + @Test + fun `FinancialFeb period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.FinancialFeb, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("periodId") + .startDate(GregorianCalendar(2018, 1, 1).time) + .endDate(GregorianCalendar(2019, 0, 31).time) + .build() + + Assert.assertEquals( + "Feb 2018 - Jan 2019", + periodUtils.getPeriodUIString( + PeriodType.FinancialFeb, + testDate, + Locale.ENGLISH, + ), + ) + } + @Test fun `FinancialApril period should return expected result`() { whenever( @@ -480,6 +530,56 @@ class DhisPeriodUtilsTest { ) } + @Test + fun `FinancialAug period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.FinancialAug, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("periodId") + .startDate(GregorianCalendar(2018, 7, 1).time) + .endDate(GregorianCalendar(2019, 6, 31).time) + .build() + + Assert.assertEquals( + "Aug 2018 - Jul 2019", + periodUtils.getPeriodUIString( + PeriodType.FinancialAug, + testDate, + Locale.ENGLISH, + ), + ) + } + + @Test + fun `FinancialSep period should return expected result`() { + whenever( + periodHelper.blockingGetPeriodForPeriodTypeAndDate( + PeriodType.FinancialSep, + testDate, + ), + ) doReturn + Period + .builder() + .periodId("periodId") + .startDate(GregorianCalendar(2018, 8, 1).time) + .endDate(GregorianCalendar(2019, 7, 31).time) + .build() + + Assert.assertEquals( + "Sep 2018 - Aug 2019", + periodUtils.getPeriodUIString( + PeriodType.FinancialSep, + testDate, + Locale.ENGLISH, + ), + ) + } + @Test fun `FinancialOct period should return expected result`() { whenever( diff --git a/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt b/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt index dff2410bd0c..77fc808aa2d 100644 --- a/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt +++ b/app/src/test/java/org/dhis2/data/dhislogic/EnrollmentEventGeneratorTest.kt @@ -1,6 +1,7 @@ package org.dhis2.data.dhislogic import org.dhis2.commons.Constants +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.period.PeriodType import org.hisp.dhis.android.core.program.Program @@ -163,6 +164,7 @@ class EnrollmentEventGeneratorTest { GregorianCalendar(2019, 0, 1).time, ).program(PROGRAM_UID) .organisationUnit(ENROLLMENT_ORG_UNIT) + .attributeOptionCombo("attributeOptionComboUid") .build() private fun mockedProgram(useFirstStage: Boolean) = @@ -170,6 +172,8 @@ class EnrollmentEventGeneratorTest { .builder() .uid(PROGRAM_UID) .useFirstStageDuringRegistration(useFirstStage) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() private fun mockedAutogeneratedEvents( 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 fb4eb711151..1fa1ddc8cb8 100644 --- a/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt @@ -18,6 +18,7 @@ import org.dhis2.commons.filters.workingLists.ProgramStageToWorkingListItemMappe import org.dhis2.commons.filters.workingLists.TeiFilterToWorkingListItemMapper import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.category.CategoryCombo +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.settings.FilterSetting import org.hisp.dhis.android.core.settings.HomeFilter @@ -211,6 +212,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .build() val catCombo = @@ -286,6 +289,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .build() val catCombo = @@ -380,6 +385,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .build() val catCombo = @@ -459,6 +466,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .trackedEntityType( TrackedEntityType @@ -600,6 +609,8 @@ class FilterRepositoryTest { Program .builder() .uid("random") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .programType(org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION) .trackedEntityType( TrackedEntityType diff --git a/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt b/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt index d177b550eec..c8442c42f7d 100644 --- a/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt +++ b/app/src/test/java/org/dhis2/data/forms/dataentry/ValueStoreTest.kt @@ -1,13 +1,18 @@ package org.dhis2.data.forms.dataentry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf import org.dhis2.commons.data.EntryMode import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.dhis2.form.model.ValueStoreResult +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.providers.FieldErrorMessageProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.hisp.dhis.android.core.D2 +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.option.Option @@ -27,14 +32,18 @@ class ValueStoreTest { private lateinit var dvValueStore: ValueStore private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val dhisEnrollmentUtils: DhisEnrollmentUtils = DhisEnrollmentUtils(d2) - private val fieldErrorMessageProvider: FieldErrorMessageProvider = mock() private val crashReportController: CrashReportController = mock() - private val networkUtils: NetworkUtils = mock() + private val networkStatusProvider: NetworkStatusProvider = mock() private val searchTEIRepository: SearchTEIRepository = mock() private val resourceManager: ResourceManager = mock() + private val dispatchers: DispatcherProvider = + mock { + on { io() } doReturn Dispatchers.IO + } @Before fun setUp() { + whenever(networkStatusProvider.connectionStatus) doReturn flowOf(false) attrValueStore = ValueStoreImpl( d2, @@ -42,10 +51,10 @@ class ValueStoreTest { EntryMode.ATTR, dhisEnrollmentUtils, crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatchers, ) deValueStore = ValueStoreImpl( @@ -54,10 +63,10 @@ class ValueStoreTest { EntryMode.DE, dhisEnrollmentUtils, crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatchers, ) dvValueStore = ValueStoreImpl( @@ -66,10 +75,10 @@ class ValueStoreTest { EntryMode.DV, dhisEnrollmentUtils, crashReportController, - networkUtils, searchTEIRepository, - fieldErrorMessageProvider, resourceManager, + networkStatusProvider, + dispatchers, ) } @@ -346,6 +355,7 @@ class ValueStoreTest { .builder() .uid("fieldUid") .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() val storeResult = deValueStore.deleteOptionValueIfSelected( @@ -418,6 +428,7 @@ class ValueStoreTest { .builder() .uid(testingUid) .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( d2 @@ -509,6 +520,7 @@ class ValueStoreTest { .builder() .uid("uid") .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() private fun mockedUniqueAttribute(): TrackedEntityAttribute = diff --git a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt index f0b474d582a..8c262cbac20 100644 --- a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt @@ -1,41 +1,33 @@ package org.dhis2.data.services -import io.reactivex.Completable import io.reactivex.Observable +import org.dhis2.commons.bindings.event import org.dhis2.commons.bindings.program import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.service.SyncPresenterImpl import org.dhis2.data.service.SyncRepository import org.dhis2.data.service.SyncResult -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.utils.analytics.AnalyticsHelper +import org.dhis2.mobile.sync.domain.SyncStatusController import org.hisp.dhis.android.core.D2 -import org.hisp.dhis.android.core.arch.call.BaseD2Progress import org.hisp.dhis.android.core.arch.call.D2Progress import org.hisp.dhis.android.core.arch.call.D2ProgressStatus import org.hisp.dhis.android.core.arch.call.D2ProgressSyncStatus import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.event.Event -import org.hisp.dhis.android.core.fileresource.FileResourceDomainType import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.program.ProgramType -import org.hisp.dhis.android.core.settings.GeneralSettings import org.hisp.dhis.android.core.settings.LimitScope import org.hisp.dhis.android.core.settings.ProgramSetting import org.hisp.dhis.android.core.settings.ProgramSettings +import org.hisp.dhis.android.core.settings.SynchronizationSettings import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.android.core.tracker.exporter.TrackerD2Progress import org.junit.Assert.assertTrue import org.junit.Before 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.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SyncPresenterTest { @@ -43,8 +35,6 @@ class SyncPresenterTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val preferences: PreferenceProvider = mock() - private val workManagerController: WorkManagerController = mock() - private val analyticsHelper: AnalyticsHelper = mock() private val syncStatusController: SyncStatusController = mock() private val syncRepository: SyncRepository = mock() @@ -54,10 +44,8 @@ class SyncPresenterTest { SyncPresenterImpl( d2, preferences, - workManagerController, - analyticsHelper, - syncStatusController, syncRepository, + syncStatusController, ) } @@ -69,8 +57,14 @@ class SyncPresenterTest { 200, LimitScope.GLOBAL, ) + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() @@ -86,8 +80,14 @@ class SyncPresenterTest { LimitScope.PER_OU_AND_PROGRAM, ) - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() assertTrue(eventLimit == 200 && limitByOU && limitByProgram) @@ -102,8 +102,14 @@ class SyncPresenterTest { LimitScope.PER_PROGRAM, ) - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() assertTrue(eventLimit == 200 && !limitByOU && limitByProgram) @@ -118,137 +124,19 @@ class SyncPresenterTest { LimitScope.PER_ORG_UNIT, ) - whenever(d2.settingModule().programSetting().blockingGet()) doReturn mockedProgramSettings + val mockedSyncSettings = + mock { + on { programSettings() } doReturn mockedProgramSettings + } + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings val (eventLimit, limitByOU, limitByProgram) = presenter.getDownloadLimits() assertTrue(eventLimit == 200 && limitByOU && !limitByProgram) } - @Test - fun `Should configure secondary tracker if configuration exists`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn - GeneralSettings - .builder() - .encryptDB(false) - .matomoID(11111) - .matomoURL("MatomoURL") - .build() - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - - presenter.syncMetadata { } - - verify(analyticsHelper, times(1)).updateMatomoSecondaryTracker(any(), any(), any()) - } - - @Test - fun `Should not configure secondary tracker if matomo settings is missing`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn - GeneralSettings - .builder() - .encryptDB(false) - .build() - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - presenter.syncMetadata { } - - verifyNoMoreInteractions(analyticsHelper) - } - - @Test - fun `Should not configure secondary tracker if no configuration exists`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn null - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - presenter.syncMetadata { } - - verify(analyticsHelper, times(0)).updateMatomoSecondaryTracker(any(), any(), any()) - } - - @Test - fun `Should clear secondary tracker`() { - whenever( - d2.metadataModule().download(), - ) doReturn - Observable.fromArray( - BaseD2Progress.empty(2), - ) - whenever( - d2.settingModule().generalSetting().blockingGet(), - ) doReturn null - whenever( - d2.mapsModule().mapLayersDownloader().downloadMetadata(), - ) doReturn Completable.complete() - whenever( - d2 - .fileResourceModule() - .fileResourceDownloader() - .byDomainType() - .eq(FileResourceDomainType.ICON) - .download(), - ) doReturn Observable.just(BaseD2Progress.empty(1)) - presenter.syncMetadata { } - - verify(analyticsHelper, times(0)).updateMatomoSecondaryTracker(any(), any(), any()) - verify(analyticsHelper).clearMatomoSecondaryTracker() - } - @Test fun `Should return successfully SYNC if tei enrollment and events are ok`() { whenever( @@ -429,8 +317,14 @@ class SyncPresenterTest { eventUid: String = "uid", programUid: String = "programUid", ) { + whenever(d2.event(eventUid)) doReturn + Event + .builder() + .uid(eventUid) + .program(programUid) + .build() whenever( - syncRepository.downLoadEvent(eventUid), + syncRepository.downLoadEvent(eventUid, programUid), ) doReturn Observable.fromArray( TrackerD2Progress.builder().build(), diff --git a/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt b/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt index 36203ba359f..26136ea205f 100644 --- a/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt +++ b/app/src/test/java/org/dhis2/data/sorting/SearchSortingValueSetterTest.kt @@ -4,16 +4,23 @@ import org.dhis2.commons.filters.Filters import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.commons.filters.sorting.SortingStatus import org.dhis2.data.enrollment.EnrollmentUiDataHelper +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.dhis2.tracker.search.model.GeometryFeatureType +import org.dhis2.tracker.search.model.SyncState +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.dhis2.tracker.search.model.TrackedEntityTypeAttributeDomain +import org.dhis2.tracker.search.model.TrackedEntityTypeDomain import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope -import org.hisp.dhis.android.core.enrollment.Enrollment -import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -21,14 +28,32 @@ import org.mockito.Mockito import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import java.time.Instant import java.util.Date +import kotlin.time.Instant as KtlInstant +import java.time.Instant class SearchSortingValueSetterTest { lateinit var searchSortingValueSetter: SearchSortingValueSetter private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val enrollmentUiDataHelper: EnrollmentUiDataHelper = mock() - + private val enrollmentUid = "enrollmentUid" + private val programUid = "programUid" + private val enrollmentOrgUnit = "enrollmentOrgUnit" + private val teiUid = "teiUid" + private val selectedEnrollment = DomainEnrollment( + uid = enrollmentUid, + orgUnit = enrollmentOrgUnit, + program = programUid, + enrollmentDate = KtlInstant.parse("2020-01-01T00:00:00.00Z"), + incidentDate = KtlInstant.parse("2020-01-01T00:00:00.00Z"), + completedDate = KtlInstant.parse("2020-01-01T00:00:00.00Z"), + followUp = false, + status = EnrollmentStatus.ACTIVE, + trackedEntityInstance = teiUid, + ) + private val enrollments = listOf( + selectedEnrollment + ) @Before fun setUp() { searchSortingValueSetter = @@ -50,14 +75,14 @@ class SearchSortingValueSetterTest { .eventModule() .events() .byEnrollmentUid() - .eq(""), + .eq("enrollmentUid"), ) doReturn mock() whenever( d2 .eventModule() .events() .byEnrollmentUid() - .eq("") + .eq("enrollmentUid") .byDeleted(), ) doReturn mock() whenever( @@ -65,7 +90,7 @@ class SearchSortingValueSetterTest { .eventModule() .events() .byEnrollmentUid() - .eq("") + .eq("enrollmentUid") .byDeleted() .isFalse, ) doReturn mock() @@ -74,7 +99,7 @@ class SearchSortingValueSetterTest { .eventModule() .events() .byEnrollmentUid() - .eq("") + .eq("enrollmentUid") .byDeleted() .isFalse .orderByTimeline(RepositoryScope.OrderByDirection.ASC), @@ -84,7 +109,7 @@ class SearchSortingValueSetterTest { .eventModule() .events() .byEnrollmentUid() - .eq("") + .eq("enrollmentUid") .byDeleted() .isFalse .orderByTimeline(RepositoryScope.OrderByDirection.ASC) @@ -154,7 +179,7 @@ class SearchSortingValueSetterTest { val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.PERIOD, SortingStatus.ASC), ) @@ -215,7 +240,7 @@ class SearchSortingValueSetterTest { val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.PERIOD, SortingStatus.ASC), ) @@ -276,7 +301,7 @@ class SearchSortingValueSetterTest { val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.PERIOD, SortingStatus.ASC), ) @@ -304,7 +329,7 @@ class SearchSortingValueSetterTest { val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.ORG_UNIT, SortingStatus.ASC), ) @@ -329,7 +354,7 @@ class SearchSortingValueSetterTest { val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.ORG_UNIT, SortingStatus.ASC), ) @@ -409,7 +434,7 @@ class SearchSortingValueSetterTest { val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.ENROLLMENT_STATUS, SortingStatus.ASC), ) @@ -447,12 +472,14 @@ class SearchSortingValueSetterTest { Program .builder() .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .enrollmentDateLabel("programEnrollmentDateLabel") .build() val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.ENROLLMENT_DATE, SortingStatus.ASC), ) @@ -481,11 +508,13 @@ class SearchSortingValueSetterTest { Program .builder() .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() val result = searchSortingValueSetter.setSortingItem( - searchTeiModel(), + generateSearchTeiModel(), SortingItem(Filters.ENROLLMENT_DATE, SortingStatus.ASC), ) @@ -532,43 +561,66 @@ class SearchSortingValueSetterTest { .build(), ) - private fun searchTeiModel(): SearchTeiModel = - SearchTeiModel().apply { - setCurrentEnrollment( - Enrollment - .builder() - .uid("enrollmentUid") - .organisationUnit("enrollmentOrgUnit") - .status(EnrollmentStatus.ACTIVE) - .program("programUid") - .enrollmentDate(Date.from(Instant.parse("2020-01-01T00:00:00.00Z"))) - .build(), - ) - tei = - TrackedEntityInstance - .builder() - .uid("teiUid") - .organisationUnit("teiOrgUnit") - .build() - } + private fun generateSearchTeiModel(): SearchTeiModel { + val tei = TrackedEntitySearchItemResult( + uid = "teiUid", + created = KtlInstant.parse("2020-01-01T00:00:00.00Z"), + lastUpdated = KtlInstant.parse("2020-01-01T00:00:00.00Z"), + createdAtClient = KtlInstant.parse("2020-01-01T00:00:00.00Z"), + lastUpdatedAtClient = KtlInstant.parse("2020-01-01T00:00:00.00Z"), + ownerOrgUnit = "ownerOrgUnit", + enrollmentOrgUnit = "enrollmentOrgUnit", + shouldDisplayOrgUnit = true, + geometry = null, + syncState = SyncState.SYNCED, + aggregatedSyncState = SyncState.SYNCED, + deleted = false, + isOnline = true, + teTypeName = "teTypeName", + type = TrackedEntityTypeDomain( + trackedEntityTypeAttributeDomains = listOf(TrackedEntityTypeAttributeDomain( + trackedEntityTypeUid= "trackedEntityTypeUid", + trackedEntityAttributeUid = "trackedEntityAttributeUid", + displayInList= true, + mandatory = false, + searchable = true, + sortOrder = 1, + )), + featureType = GeometryFeatureType.POINT, + ), + header = "teiHeader", + overDueDate = null, + selectedEnrollment = selectedEnrollment, + profilePicture = null, + enrolledPrograms = listOf(DomainProgram( + uid = "programUid", + displayName = "programDisplayName", + style = DomainObjectStyle( + icon = "iconUid", + color = "colorUid" + ) + )), + enrollments = enrollments, + relationships = null, + defaultTypeIcon = null, + attributeValues = emptyList(), + ) + val searchTeiModel = SearchTeiModel() + searchTeiModel.tei = tei + return searchTeiModel + } - private fun onlineSearchTeiModel(): SearchTeiModel = - SearchTeiModel().apply { - tei = - TrackedEntityInstance - .builder() - .uid("teiUid") - .organisationUnit("teiOrgUnit") - .build() - } + private fun onlineSearchTeiModel(): SearchTeiModel { + val searchTeiModel = generateSearchTeiModel() + searchTeiModel.tei = searchTeiModel.tei?.copy(isOnline = true) + return searchTeiModel + } + + + private fun searchTeiModelWithoutEnrollment(): SearchTeiModel { + val searchTeiModel = generateSearchTeiModel() + searchTeiModel.tei = searchTeiModel.tei?.copy(selectedEnrollment = null, enrollments = emptyList()) + return searchTeiModel + } - private fun searchTeiModelWithoutEnrollment(): SearchTeiModel = - SearchTeiModel().apply { - tei = - TrackedEntityInstance - .builder() - .uid("teiUid") - .organisationUnit("teiOrgUnit") - .build() - } } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt index d9d5302e140..1969cdfd61e 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsLoadBiometricSearchResultsUseCaseTest.kt @@ -4,19 +4,18 @@ import androidx.paging.testing.asSnapshot import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase -import org.dhis2.data.search.SearchParametersModel import org.dhis2.form.model.FieldUiModelImpl import org.dhis2.mobile.commons.model.CustomIntentModel -import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt -import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.hisp.dhis.android.core.common.ValueType -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -24,74 +23,95 @@ import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class SimprintsLoadBiometricSearchResultsUseCaseTest { - private val searchRepository: SearchRepository = mock() private val searchRepositoryKt: SearchRepositoryKt = mock() + private val searchTrackedEntities: SearchTrackedEntities = mock() private val orderSearchResultsByIdentifyResponse: SimprintsOrderSearchResultsByIdentifyResponseUseCase = mock() private val useCase = SimprintsLoadBiometricSearchResultsUseCase( - searchRepository = searchRepository, searchRepositoryKt = searchRepositoryKt, + searchTrackedEntities = searchTrackedEntities, orderSearchResultsByIdentifyResponse = orderSearchResultsByIdentifyResponse, ) @Test fun `invoke should return ordered biometric search results when available`() = runTest { - val firstItem = mock() - val secondItem = mock() - val firstModel = searchTeiModel("first") - val secondModel = searchTeiModel("second") + val firstItem = + trackedEntitySearchResult( + uid = "tei-1", + attributeValues = listOf(trackedEntityAttributeValue("biometric", "guid-1")), + ) + val secondItem = + trackedEntitySearchResult( + uid = "tei-2", + attributeValues = listOf(trackedEntityAttributeValue("biometric", "guid-2")), + ) + val firstModel = searchTeiModel("tei-1", "first") + val secondModel = searchTeiModel("tei-2", "second") + whenever( - orderSearchResultsByIdentifyResponse( - any(), - any(), - any List>(), + orderSearchResultsByIdentifyResponse( + searchFields = any(), + queryData = any(), + searchTrackedEntities = any(), + getUid = any(), + getAttributeValue = any(), ), ) doReturn listOf(secondItem, firstItem) - whenever(searchRepository.transform(secondItem, null, false, null)) doReturn secondModel - whenever(searchRepository.transform(firstItem, null, false, null)) doReturn firstModel + whenever(searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel(secondItem, null)) doReturn secondModel + whenever(searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel(firstItem, null)) doReturn firstModel val result = useCase( searchItems = listOf(simprintsBiometricSearchField()), - searchParametersModel = - SearchParametersModel( - selectedProgram = null, - queryData = mutableMapOf("biometric" to listOf("guid-1", "guid-2")), + searchInput = + searchInput( + queryDataList = + listOf( + QueryData( + attributeId = "biometric", + values = listOf("guid-1", "guid-2"), + searchOperator = null, + ), + ), ), - isOnline = true, - offlineOnly = false, sortingItem = null, ) assertEquals(listOf(secondModel, firstModel), result?.asSnapshot()) - verify(searchRepository).transform(secondItem, null, false, null) - verify(searchRepository).transform(firstItem, null, false, null) + verify(searchRepositoryKt).mapTrackedEntitySearchItemResultToSearchTeiModel(secondItem, null) + verify(searchRepositoryKt).mapTrackedEntitySearchItemResultToSearchTeiModel(firstItem, null) } @Test fun `invoke should return null when biometric ordering is not available`() = runTest { whenever( - orderSearchResultsByIdentifyResponse( - any(), - any(), - any List>(), + orderSearchResultsByIdentifyResponse( + searchFields = any(), + queryData = any(), + searchTrackedEntities = any(), + getUid = any(), + getAttributeValue = any(), ), ) doReturn null val result = useCase( searchItems = listOf(simprintsBiometricSearchField()), - searchParametersModel = - SearchParametersModel( - selectedProgram = null, - queryData = mutableMapOf("biometric" to listOf("guid-1")), + searchInput = + searchInput( + queryDataList = + listOf( + QueryData( + attributeId = "biometric", + values = listOf("guid-1"), + searchOperator = null, + ), + ), ), - isOnline = true, - offlineOnly = false, sortingItem = null, ) @@ -117,8 +137,13 @@ class SimprintsLoadBiometricSearchResultsUseCaseTest { ), ) - private fun searchTeiModel(header: String) = - SearchTeiModel().apply { - setHeader(header) - } + private fun searchInput(queryDataList: List) = + SearchTrackedEntitiesInput( + selectedProgram = null, + allowCache = true, + excludeValues = emptySet(), + hasStateFilters = false, + isOnline = true, + queryDataList = queryDataList, + ) } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt index ea227fdfb08..0db7e494050 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest.kt @@ -2,11 +2,10 @@ package org.dhis2.simprints import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import org.dhis2.data.search.SearchParametersModel -import org.dhis2.usescases.searchTrackEntity.SearchRepository +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt -import org.dhis2.usescases.searchTrackEntity.SearchTeiModel -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -19,13 +18,13 @@ import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest { - private val searchRepository: SearchRepository = mock() private val searchRepositoryKt: SearchRepositoryKt = mock() + private val searchTrackedEntities: SearchTrackedEntities = mock() private val useCase = SimprintsLoadPossibleDuplicatesSearchResultsUseCase( - searchRepository = searchRepository, searchRepositoryKt = searchRepositoryKt, + searchTrackedEntities = searchTrackedEntities, ) @Test @@ -33,9 +32,8 @@ class SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest { runTest { val result = useCase( - searchParametersModel = SearchParametersModel(selectedProgram = null, queryData = null), - isOnline = true, - offlineOnly = false, + queryDataList = emptyList(), + searchInput = searchInput(emptyList()), sortingItem = null, ) @@ -45,15 +43,19 @@ class SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest { @Test fun `invoke should return null when query contains only blank values`() = runTest { + val queryDataList = + listOf( + QueryData( + attributeId = "biometric", + values = listOf(" ", ""), + searchOperator = null, + ), + ) + val result = useCase( - searchParametersModel = - SearchParametersModel( - selectedProgram = null, - queryData = mutableMapOf("biometric" to listOf(" ", "")), - ), - isOnline = true, - offlineOnly = false, + queryDataList = queryDataList, + searchInput = searchInput(queryDataList), sortingItem = null, ) @@ -63,50 +65,58 @@ class SimprintsLoadPossibleDuplicatesSearchResultsUseCaseTest { @Test fun `invoke should return transformed possible duplicates results in order`() = runTest { - val item1 = - mock { - on { uid() } doReturn "tei-1" - } - val item2 = - mock { - on { uid() } doReturn "tei-2" - } + val item1 = trackedEntitySearchResult(uid = "tei-1") + val item2 = trackedEntitySearchResult(uid = "tei-2") - whenever(searchRepositoryKt.searchTrackedEntitiesImmediate(any(), any())).thenAnswer { - val params = it.arguments[0] as SearchParametersModel - when (params.queryData?.get("biometric")?.firstOrNull()) { + whenever(searchTrackedEntities.invokeImmediate(any())).thenAnswer { invocation -> + val input = invocation.arguments[0] as SearchTrackedEntitiesInput + val guidValue = + input + .queryDataList + ?.firstOrNull { queryData -> queryData.attributeId == "biometric" } + ?.values + ?.firstOrNull() + when (guidValue) { "guid-1" -> listOf(item1, item2) "guid-2" -> listOf(item1) else -> emptyList() } } - val model1 = searchTeiModel("model-1") - val model2 = searchTeiModel("model-2") - whenever(searchRepository.transform(item1, null, false, null)) doReturn model1 - whenever(searchRepository.transform(item2, null, false, null)) doReturn model2 + val model1 = searchTeiModel("tei-1", "model-1") + val model2 = searchTeiModel("tei-2", "model-2") + whenever(searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel(item1, null)) doReturn model1 + whenever(searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel(item2, null)) doReturn model2 + + val queryDataList = + listOf( + QueryData( + attributeId = "biometric", + values = listOf("guid-1", "guid-2"), + searchOperator = null, + ), + ) val result = useCase( - searchParametersModel = - SearchParametersModel( - selectedProgram = null, - queryData = mutableMapOf("biometric" to listOf("guid-1", "guid-2")), - ), - isOnline = true, - offlineOnly = false, + queryDataList = queryDataList, + searchInput = searchInput(queryDataList), sortingItem = null, ) assertEquals(listOf(model1, model2), result) - verify(searchRepositoryKt, times(2)).searchTrackedEntitiesImmediate(any(), any()) - verify(searchRepository).transform(item1, null, false, null) - verify(searchRepository).transform(item2, null, false, null) + verify(searchTrackedEntities, times(2)).invokeImmediate(any()) + verify(searchRepositoryKt).mapTrackedEntitySearchItemResultToSearchTeiModel(item1, null) + verify(searchRepositoryKt).mapTrackedEntitySearchItemResultToSearchTeiModel(item2, null) } - private fun searchTeiModel(header: String) = - SearchTeiModel().apply { - setHeader(header) - } + private fun searchInput(queryDataList: List) = + SearchTrackedEntitiesInput( + selectedProgram = null, + allowCache = true, + excludeValues = emptySet(), + hasStateFilters = false, + isOnline = true, + queryDataList = queryDataList, + ) } - diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt index eddc1dc31bb..3f48930cf84 100644 --- a/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt +++ b/app/src/test/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCaseTest.kt @@ -6,19 +6,17 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils -import org.dhis2.data.search.SearchParametersModel +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput import org.dhis2.usescases.searchTrackEntity.SearchRepository import org.dhis2.usescases.searchTrackEntity.SearchRepositoryKt -import org.dhis2.usescases.searchTrackEntity.SearchTeiModel -import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -29,33 +27,39 @@ import org.mockito.kotlin.whenever class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { private val searchRepository: SearchRepository = mock() private val searchRepositoryKt: SearchRepositoryKt = mock() + private val searchTrackedEntities: SearchTrackedEntities = mock() private val networkUtils: NetworkUtils = mock() private val filterManager: FilterManager = mock() @Test fun `invoke should return matched enrollment navigation when a single biometric result is found`() = runTest { - val program = Program.builder().uid("initialProgramUid").build() - val trackedEntity: TrackedEntitySearchItem = mock() + val program = program("initialProgramUid") + val trackedEntity = trackedEntitySearchResult(uid = "teiUid", isOnline = true) val searchTeiModel = searchTeiModel( teiUid = "teiUid", - enrollmentUid = "enrollmentUid", - enrollmentProgramUid = "matchedProgramUid", + selectedEnrollment = + domainEnrollment( + uid = "enrollmentUid", + programUid = "matchedProgramUid", + teiUid = "teiUid", + ), + isOnline = true, ) whenever(searchRepository.getProgram("initialProgramUid")) doReturn program + whenever(searchRepositoryKt.getExcludeValues()) doReturn HashSet() whenever(networkUtils.isOnline()) doReturn true whenever(filterManager.stateFilters) doReturn emptyList() - whenever( - searchRepositoryKt.searchTrackedEntitiesImmediate( - SearchParametersModel( - selectedProgram = program, - queryData = mutableMapOf("biometric" to listOf("guid-1")), + doReturn(listOf(trackedEntity)) + .whenever(searchTrackedEntities) + .invokeImmediate( + searchInput( + selectedProgram = "initialProgramUid", + isOnline = true, ), - true, - ), - ) doReturn listOf(trackedEntity) - whenever(searchRepository.transform(trackedEntity, program, false, null)) doReturn + ) + whenever(searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel(trackedEntity, null)) doReturn searchTeiModel val result = @@ -79,22 +83,22 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { @Test fun `invoke should fall back to the initial program when the matched result has no selected enrollment`() = runTest { - val program = Program.builder().uid("initialProgramUid").build() - val trackedEntity: TrackedEntitySearchItem = mock() + val program = program("initialProgramUid") + val trackedEntity = trackedEntitySearchResult(uid = "teiUid", isOnline = false) whenever(searchRepository.getProgram("initialProgramUid")) doReturn program + whenever(searchRepositoryKt.getExcludeValues()) doReturn HashSet() whenever(networkUtils.isOnline()) doReturn false whenever(filterManager.stateFilters) doReturn emptyList() - whenever( - searchRepositoryKt.searchTrackedEntitiesImmediate( - SearchParametersModel( - selectedProgram = program, - queryData = mutableMapOf("biometric" to listOf("guid-1")), + doReturn(listOf(trackedEntity)) + .whenever(searchTrackedEntities) + .invokeImmediate( + searchInput( + selectedProgram = "initialProgramUid", + isOnline = false, ), - false, - ), - ) doReturn listOf(trackedEntity) - whenever(searchRepository.transform(trackedEntity, program, true, null)) doReturn - searchTeiModel(teiUid = "teiUid") + ) + whenever(searchRepositoryKt.mapTrackedEntitySearchItemResultToSearchTeiModel(trackedEntity, null)) doReturn + searchTeiModel(teiUid = "teiUid", isOnline = false) val result = useCase(StandardTestDispatcher(testScheduler))( @@ -108,7 +112,7 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { teiUid = "teiUid", programUid = "initialProgramUid", enrollmentUid = null, - isOnline = true, + isOnline = false, ), result, ) @@ -125,25 +129,29 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { ) assertNull(result) - verify(searchRepositoryKt, never()).searchTrackedEntitiesImmediate(any(), any()) + verify(searchTrackedEntities, never()).invokeImmediate(any()) } @Test fun `invoke should return null when search resolves multiple tracked entities`() = runTest { - val program = Program.builder().uid("initialProgramUid").build() + val program = program("initialProgramUid") whenever(searchRepository.getProgram("initialProgramUid")) doReturn program + whenever(searchRepositoryKt.getExcludeValues()) doReturn HashSet() whenever(networkUtils.isOnline()) doReturn true whenever(filterManager.stateFilters) doReturn emptyList() - whenever( - searchRepositoryKt.searchTrackedEntitiesImmediate( - SearchParametersModel( - selectedProgram = program, - queryData = mutableMapOf("biometric" to listOf("guid-1")), - ), - true, + doReturn( + listOf( + trackedEntitySearchResult(uid = "tei-1"), + trackedEntitySearchResult(uid = "tei-2"), ), - ) doReturn listOf(mock(), mock()) + ).whenever(searchTrackedEntities) + .invokeImmediate( + searchInput( + selectedProgram = "initialProgramUid", + isOnline = true, + ), + ) val result = useCase(StandardTestDispatcher(testScheduler))( @@ -153,38 +161,43 @@ class SimprintsResolveSingleBiometricSearchNavigationUseCaseTest { ) assertNull(result) - verify(searchRepository, never()).transform(any(), anyOrNull(), any(), anyOrNull()) + verify(searchRepositoryKt, never()).mapTrackedEntitySearchItemResultToSearchTeiModel(any(), any()) } private fun useCase(ioDispatcher: CoroutineDispatcher) = SimprintsResolveSingleBiometricSearchNavigationUseCase( searchRepository = searchRepository, searchRepositoryKt = searchRepositoryKt, + searchTrackedEntities = searchTrackedEntities, networkUtils = networkUtils, filterManager = filterManager, ioDispatcher = ioDispatcher, ) - private fun searchTeiModel( - teiUid: String, - enrollmentUid: String? = null, - enrollmentProgramUid: String? = null, - ) = SearchTeiModel().apply { - tei = - TrackedEntityInstance - .builder() - .uid(teiUid) - .trackedEntityType("teiType") - .organisationUnit("orgUnit") - .build() - enrollmentUid?.let { uid -> - setCurrentEnrollment( - Enrollment - .builder() - .uid(uid) - .program(enrollmentProgramUid) - .build(), - ) - } - } + private fun program(uid: String): Program = + Program + .builder() + .uid(uid) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() + + private fun searchInput( + selectedProgram: String, + isOnline: Boolean, + ) = SearchTrackedEntitiesInput( + selectedProgram = selectedProgram, + allowCache = false, + excludeValues = emptySet(), + hasStateFilters = false, + isOnline = isOnline, + queryDataList = + listOf( + QueryData( + attributeId = "biometric", + values = listOf("guid-1"), + searchOperator = null, + ), + ), + ) } diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsSearchTestModels.kt b/app/src/test/java/org/dhis2/simprints/SimprintsSearchTestModels.kt new file mode 100644 index 00000000000..1e072fa0588 --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsSearchTestModels.kt @@ -0,0 +1,96 @@ +package org.dhis2.simprints + +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.search.model.DomainEnrollment +import org.dhis2.tracker.search.model.EnrollmentStatus +import org.dhis2.tracker.search.model.GeometryFeatureType +import org.dhis2.tracker.search.model.SyncState +import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.dhis2.tracker.search.model.TrackedEntityTypeDomain +import org.dhis2.usescases.searchTrackEntity.SearchTeiModel + +internal fun trackedEntitySearchResult( + uid: String, + header: String = uid, + isOnline: Boolean = false, + selectedEnrollment: DomainEnrollment? = null, + attributeValues: List = emptyList(), +): TrackedEntitySearchItemResult = + TrackedEntitySearchItemResult( + uid = uid, + created = null, + lastUpdated = null, + createdAtClient = null, + lastUpdatedAtClient = null, + ownerOrgUnit = null, + enrollmentOrgUnit = null, + shouldDisplayOrgUnit = false, + geometry = null, + syncState = SyncState.SYNCED, + aggregatedSyncState = SyncState.SYNCED, + deleted = false, + isOnline = isOnline, + teTypeName = "Person", + type = + TrackedEntityTypeDomain( + trackedEntityTypeAttributeDomains = emptyList(), + featureType = GeometryFeatureType.NONE, + ), + header = header, + overDueDate = null, + selectedEnrollment = selectedEnrollment, + profilePicture = null, + enrolledPrograms = null, + enrollments = null, + relationships = null, + defaultTypeIcon = null, + attributeValues = attributeValues, + ) + +internal fun trackedEntityAttributeValue( + attribute: String, + value: String?, +): TrackedEntitySearchItemAttributeDomain = + TrackedEntitySearchItemAttributeDomain( + attribute = attribute, + displayName = attribute, + displayFormName = attribute, + value = value, + valueType = TrackerInputType.TEXT, + displayInList = true, + optionSet = null, + ) + +internal fun domainEnrollment( + uid: String, + programUid: String?, + teiUid: String, +): DomainEnrollment = + DomainEnrollment( + uid = uid, + orgUnit = "orgUnit", + program = programUid, + enrollmentDate = null, + incidentDate = null, + completedDate = null, + followUp = false, + status = EnrollmentStatus.ACTIVE, + trackedEntityInstance = teiUid, + ) + +internal fun searchTeiModel( + teiUid: String, + header: String = teiUid, + isOnline: Boolean = false, + selectedEnrollment: DomainEnrollment? = null, +): SearchTeiModel = + SearchTeiModel().apply { + tei = + trackedEntitySearchResult( + uid = teiUid, + header = header, + isOnline = isOnline, + selectedEnrollment = selectedEnrollment, + ) + } diff --git a/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt b/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt index c63760f9c95..55bcaa81c08 100644 --- a/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt +++ b/app/src/test/java/org/dhis2/uicomponents/map/geometry/MapCoordinateFieldToFeatureCollectionTest.kt @@ -7,6 +7,7 @@ import org.dhis2.maps.utils.CoordinateAttributeInfo import org.dhis2.maps.utils.CoordinateDataElementInfo import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event @@ -69,11 +70,13 @@ class MapCoordinateFieldToFeatureCollectionTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -96,11 +99,13 @@ class MapCoordinateFieldToFeatureCollectionTest { .builder() .uid("de2Uid") .displayFormName("de2Name") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -123,11 +128,13 @@ class MapCoordinateFieldToFeatureCollectionTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() diff --git a/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt b/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt index 41076b5017b..4945cb931f5 100644 --- a/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt +++ b/app/src/test/java/org/dhis2/uicomponents/map/geometry/mapper/featurecollection/MapDataElementToFeatureTest.kt @@ -6,6 +6,7 @@ import org.dhis2.maps.utils.CoordinateDataElementInfo import org.dhis2.uicomponents.map.geometry.MapEventToFeatureCollectionTest import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event @@ -78,11 +79,13 @@ class MapDataElementToFeatureTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -105,11 +108,13 @@ class MapDataElementToFeatureTest { .builder() .uid("de2Uid") .displayFormName("de2Name") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() @@ -132,11 +137,13 @@ class MapDataElementToFeatureTest { .builder() .uid("deUid") .displayFormName("deName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Enrollment .builder() .uid("enrollmentUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), Geometry .builder() diff --git a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryTest.kt index c4bd86431a0..466ed46772e 100644 --- a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailRepositoryTest.kt @@ -3,6 +3,7 @@ package org.dhis2.usescases.datasets.datasetDetail import dhis2.org.analytics.charts.Charts import io.reactivex.Single import org.dhis2.commons.resources.DhisPeriodUtils +import org.dhis2.data.dhislogic.AUTH_ALL import org.dhis2.data.dhislogic.AUTH_DATAVALUE_ADD import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.category.CategoryCombo @@ -103,14 +104,14 @@ class DataSetDetailRepositoryTest { .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD), + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL), ) doReturn mock() whenever( d2 .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD) + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL) .blockingIsEmpty(), ) doReturn false @@ -165,14 +166,14 @@ class DataSetDetailRepositoryTest { .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD), + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL), ) doReturn mock() whenever( d2 .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD) + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL) .blockingIsEmpty(), ) doReturn true @@ -235,14 +236,14 @@ class DataSetDetailRepositoryTest { .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD), + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL), ) doReturn mock() whenever( d2 .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD) + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL) .blockingIsEmpty(), ) doReturn false @@ -311,14 +312,14 @@ class DataSetDetailRepositoryTest { .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD), + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL), ) doReturn mock() whenever( d2 .userModule() .authorities() .byName() - .eq(AUTH_DATAVALUE_ADD) + .`in`(AUTH_DATAVALUE_ADD, AUTH_ALL) .blockingIsEmpty(), ) doReturn false diff --git a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailViewModelTest.kt index bcebeeba2ab..7b755a93ee0 100644 --- a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/DataSetDetailViewModelTest.kt @@ -5,8 +5,10 @@ import junit.framework.TestCase.assertEquals import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.dhis2.commons.viewmodel.DispatcherProvider +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -28,6 +30,11 @@ class DataSetDetailViewModelTest { private lateinit var viewModel: DataSetDetailViewModel + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) diff --git a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt index 0d5e30f80a3..9c47451c580 100644 --- a/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/datasets/datasetDetail/datasetlist/DataSetListViewModelTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.matomo.Actions @@ -19,6 +20,7 @@ import org.dhis2.usescases.datasets.datasetDetail.DataSetDetailModel import org.dhis2.usescases.datasets.datasetDetail.DataSetDetailRepository import org.dhis2.usescases.datasets.datasetDetail.datasetList.DataSetListViewModel import org.hisp.dhis.android.core.common.State +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -43,6 +45,11 @@ class DataSetListViewModelTest { private val filterProcessor: FlowableProcessor = PublishProcessor.create() private val filterManagerFlowable = Flowable.just(filterManager).startWith(filterProcessor) + @After + fun tearDown() { + Dispatchers.resetMain() + } + @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { diff --git a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt index 8b59fa59d37..0d5185fd534 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentFormRepositoryTest.kt @@ -3,6 +3,7 @@ package org.dhis2.usescases.enrollment import org.dhis2.data.dhislogic.DhisEnrollmentUtils import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -35,6 +36,8 @@ class EnrollmentFormRepositoryTest { .builder() .uid("programUid") .displayName("programName") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever(enrollmentRepository.blockingGet()) doReturn Enrollment diff --git a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt index 4ce9b67fd85..921e44ff171 100644 --- a/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImplTest.kt @@ -1,10 +1,7 @@ package org.dhis2.usescases.enrollment -import android.content.Intent -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.reactivex.Single import io.reactivex.processors.PublishProcessor -import kotlinx.coroutines.test.runTest import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider @@ -19,7 +16,7 @@ import org.hisp.dhis.android.core.common.Access import org.hisp.dhis.android.core.common.DataAccess import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry -import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.EnrollmentAccess import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -30,11 +27,8 @@ import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstanceObjectRepository import org.junit.Before -import org.junit.Rule import org.junit.Test import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -42,9 +36,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class EnrollmentPresenterImplTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val enrollmentFormRepository: EnrollmentFormRepository = mock() private val programRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() private val teiRepository: TrackedEntityInstanceObjectRepository = mock() @@ -86,6 +77,8 @@ class EnrollmentPresenterImplTest { Program .builder() .uid("") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access( Access .builder() @@ -107,6 +100,8 @@ class EnrollmentPresenterImplTest { Program .builder() .uid("") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access( Access .builder() @@ -174,7 +169,13 @@ class EnrollmentPresenterImplTest { .geometry(geometry) .uid("random") .build() - val program = Program.builder().uid("tUID").build() + val program = + Program + .builder() + .uid("tUID") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(teiRepository.blockingGet()) doReturn tei whenever(programRepository.blockingGet()) doReturn program @@ -206,7 +207,13 @@ class EnrollmentPresenterImplTest { .geometry(geometry) .uid("random") .build() - val program = Program.builder().uid("tUID").build() + val program = + Program + .builder() + .uid("tUID") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(teiRepository.blockingGet()) doReturn tei whenever(programRepository.blockingGet()) doReturn program @@ -266,62 +273,6 @@ class EnrollmentPresenterImplTest { assert(!presenter.isEventScheduleOrSkipped("uid")) } - @Test - fun `Should delegate finish request to Simprints enrollment view model`() = - runTest { - val intent: Intent = mock() - whenever( - simprintsEnrollmentViewModel.onFinishRequested( - enrollmentUid = "enrollmentUid", - ), - ) doReturn intent - - val result = presenter.onFinishRequested("enrollmentUid") - - assert(result == intent) - } - - @Test - fun `Should delegate register last result using current enrollment tei to Simprints enrollment view model`() = - runTest { - val enrollment: Enrollment = - mock { - on { trackedEntityInstance() } doReturn "teiUid" - on { uid() } doReturn "enrollmentUid" - } - whenever(enrollmentRepository.blockingGet()) doReturn enrollment - whenever( - simprintsEnrollmentViewModel.onRegisterLastResult( - resultCode = any(), - data = anyOrNull(), - teiUid = anyOrNull(), - enrollmentUid = anyOrNull(), - ), - ) doReturn SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH - - val result = - presenter.onRegisterLastResult( - resultCode = 1, - data = null, - enrollmentUid = "enrollmentUidExtra", - ) - - verify(simprintsEnrollmentViewModel).onRegisterLastResult( - resultCode = 1, - data = null, - teiUid = "teiUid", - enrollmentUid = "enrollmentUidExtra", - ) - assert(result == SimprintsEnrollmentViewModel.RegisterLastResult.CONTINUE_FINISH) - } - - @Test - fun `Should notify Simprints enrollment view model when register last launch fails`() { - presenter.onRegisterLastLaunchFailed() - - verify(simprintsEnrollmentViewModel).onRegisterLastLaunchFailed() - } - @Test fun `should create an event right after enrollment creation`() { whenever(enrollmentFormRepository.generateEvents()) doReturn diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt index e79e8acf17a..7006e8c25a3 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt @@ -5,6 +5,7 @@ import io.reactivex.Single import org.dhis2.data.dhislogic.AUTH_ALL import org.dhis2.data.dhislogic.AUTH_UNCOMPLETE_EVENT import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.dataelement.DataElement import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -63,8 +64,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) assertTrue(repository.isEnrollmentOpen) @@ -81,8 +82,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) repository.isEnrollmentOpen @@ -106,8 +107,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) assertTrue(!repository.isEnrollmentCancelled) @@ -129,12 +130,13 @@ class EventCaptureRepositoryImplTest { .builder() .uid(trackerEventEnrollmentUid) .status(EnrollmentStatus.CANCELLED) + .attributeOptionCombo("attributeOptionComboUid") .build() val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) assertTrue(repository.isEnrollmentCancelled) @@ -147,8 +149,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) repository.isEventEditable(eventUid) @@ -180,8 +182,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) val testObserver = repository.programStageName().test() @@ -197,8 +199,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) whenever( @@ -227,8 +229,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) whenever( @@ -253,8 +255,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) val testStatus = EventStatus.SKIPPED whenever( @@ -275,8 +277,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) val testNewDate = GregorianCalendar(3021, 11, 1).time whenever( @@ -300,8 +302,8 @@ class EventCaptureRepositoryImplTest { val repository = EventCaptureRepositoryImpl( - eventUid, d2, + eventUid, ) repository @@ -316,7 +318,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = EventCaptureRepositoryImpl(eventUid, d2) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2 @@ -335,7 +337,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = EventCaptureRepositoryImpl(eventUid, d2) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2 @@ -354,11 +356,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) repository .eventStatus() @@ -372,11 +370,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2 @@ -424,11 +418,7 @@ class EventCaptureRepositoryImplTest { mockEvent(status = EventStatus.COMPLETED) mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2.eventModule().eventService().getEditableStatus(eventUid), @@ -449,11 +439,7 @@ class EventCaptureRepositoryImplTest { mockEvent(status = EventStatus.COMPLETED) mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2.eventModule().eventService().getEditableStatus(eventUid), @@ -474,11 +460,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) repository .eventIntegrityCheck() @@ -492,11 +474,7 @@ class EventCaptureRepositoryImplTest { mockEvent(eventDate = GregorianCalendar(3021, 0, 1).time) mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) repository .eventIntegrityCheck() @@ -510,11 +488,8 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) + val numberOfNotes = 12 whenever( d2.noteModule().notes().byEventUid(), @@ -546,11 +521,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2.settingModule().appearanceSettings().blockingExists(), @@ -564,11 +535,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2.settingModule().appearanceSettings().blockingExists(), @@ -593,11 +560,7 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) whenever( d2.settingModule().appearanceSettings().blockingExists(), @@ -622,11 +585,8 @@ class EventCaptureRepositoryImplTest { mockEvent() mockSections() - val repository = - EventCaptureRepositoryImpl( - eventUid, - d2, - ) + val repository = EventCaptureRepositoryImpl(d2 = d2, eventUid = eventUid) + whenever( d2 .programModule() @@ -752,7 +712,11 @@ class EventCaptureRepositoryImplTest { .sortOrder(sectionOrderC) .dataElements( mutableListOf( - DataElement.builder().uid(sectionCDataElementA).build(), + DataElement + .builder() + .uid(sectionCDataElementA) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), ), ).build(), ProgramStageSection @@ -762,8 +726,16 @@ class EventCaptureRepositoryImplTest { .sortOrder(sectionOrderB) .dataElements( mutableListOf( - DataElement.builder().uid(sectionBDataElementA).build(), - DataElement.builder().uid(sectionBDataElementB).build(), + DataElement + .builder() + .uid(sectionBDataElementA) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), + DataElement + .builder() + .uid(sectionBDataElementB) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), ), ).build(), ProgramStageSection @@ -773,7 +745,11 @@ class EventCaptureRepositoryImplTest { .sortOrder(sectionOrderA) .dataElements( mutableListOf( - DataElement.builder().uid(sectionADataElementA).build(), + DataElement + .builder() + .uid(sectionADataElementA) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), ), ).build(), ) diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt index 0ba99a2cf04..af4c0c5a36e 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialPresenterTest.kt @@ -12,6 +12,7 @@ import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventFieldMapp import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventEditableStatus.Editable import org.hisp.dhis.android.core.event.EventEditableStatus.NonEditable @@ -364,7 +365,13 @@ class EventInitialPresenterTest { programStageUid: String?, moreOrgUnits: Boolean = false, ) { - val program = Program.builder().uid(uid).build() + val program = + Program + .builder() + .uid(uid) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() val orgUnits = mutableListOf( OrganisationUnit diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt index 501f6b8559f..eeb1fec1baf 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventInitial/EventInitialRepositoryImplTest.kt @@ -259,6 +259,8 @@ class EventInitialRepositoryImplTest { Program .builder() .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access(Access.create(true, true, DataAccess.create(true, hasAccess))) .build() } diff --git a/app/src/test/java/org/dhis2/usescases/login/SyncIsPerformedInteractorTest.kt b/app/src/test/java/org/dhis2/usescases/login/SyncIsPerformedInteractorTest.kt deleted file mode 100644 index da548816f47..00000000000 --- a/app/src/test/java/org/dhis2/usescases/login/SyncIsPerformedInteractorTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.dhis2.usescases.login - -import org.dhis2.data.server.UserManager -import org.dhis2.usescases.sync.WAS_INITIAL_SYNC_DONE -import org.hisp.dhis.android.core.configuration.internal.DatabaseAccount -import org.hisp.dhis.android.core.systeminfo.SystemInfo -import org.hisp.dhis.android.core.user.User -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -class SyncIsPerformedInteractorTest { - private lateinit var interactor: SyncIsPerformedInteractor - private val userManager: UserManager = - Mockito.mock(UserManager::class.java, Mockito.RETURNS_DEEP_STUBS) - - @Before - fun setup() { - interactor = SyncIsPerformedInteractor(userManager) - } - - @Test - fun `Should check if entry exists on datastore module`() { - whenever(userManager.d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - userManager.d2 - .dataStoreModule() - .localDataStore() - .value(WAS_INITIAL_SYNC_DONE), - ) doReturn mock() - whenever( - userManager.d2 - .dataStoreModule() - .localDataStore() - .value(WAS_INITIAL_SYNC_DONE) - .blockingExists(), - ) doReturn false - whenever( - userManager.d2 - .userModule() - .accountManager() - .getCurrentAccount(), - ) doReturn null - - val result = interactor.execute() - - assert(!result) - } - - @Test - fun `Should check if import exists on datastore module`() { - val serverUrl = "https://play.dhis2.org/40" - val userName = "pepe" - - whenever(userManager.d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - userManager.d2 - .dataStoreModule() - .localDataStore() - .value(WAS_INITIAL_SYNC_DONE), - ) doReturn mock() - whenever( - userManager.d2 - .dataStoreModule() - .localDataStore() - .value(WAS_INITIAL_SYNC_DONE) - .blockingExists(), - ) doReturn false - - val mockedSystemInfo: SystemInfo = - mock { - on { contextPath() } doReturn serverUrl - } - - val mockedUser: User = - mock { - on { username() } doReturn userName - } - - whenever( - userManager.d2 - .userModule() - .user() - .blockingGet(), - ) doReturn mockedUser - - whenever( - userManager.d2 - .systemInfoModule() - .systemInfo() - .blockingGet(), - ) doReturn mockedSystemInfo - - val mockedAccount: DatabaseAccount = - mock { - on { serverUrl() } doReturn serverUrl - on { username() } doReturn userName - on { importDB() } doReturn mock() - } - - whenever( - userManager.d2 - .userModule() - .accountManager() - .getCurrentAccount(), - ) doReturn mockedAccount - - val result = interactor.execute() - - assert(result) - } -} diff --git a/app/src/test/java/org/dhis2/usescases/main/MainPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/main/MainPresenterTest.kt deleted file mode 100644 index 2604bc66e6a..00000000000 --- a/app/src/test/java/org/dhis2/usescases/main/MainPresenterTest.kt +++ /dev/null @@ -1,323 +0,0 @@ -package org.dhis2.usescases.main - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import io.reactivex.Flowable -import io.reactivex.Single -import io.reactivex.processors.BehaviorProcessor -import io.reactivex.processors.FlowableProcessor -import io.reactivex.processors.PublishProcessor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.dhis2.commons.filters.FilterManager -import org.dhis2.commons.filters.FilterManager.PeriodRequest -import org.dhis2.commons.filters.Filters -import org.dhis2.commons.filters.data.FilterRepository -import org.dhis2.commons.matomo.Categories.Companion.HOME -import org.dhis2.commons.matomo.MatomoAnalyticsController -import org.dhis2.commons.prefs.Preference.Companion.DEFAULT_CAT_COMBO -import org.dhis2.commons.prefs.Preference.Companion.PREF_DEFAULT_CAT_OPTION_COMBO -import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.commons.schedulers.SchedulerProvider -import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.schedulers.TrampolineSchedulerProvider -import org.dhis2.data.server.UserManager -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.VersionRepository -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.usescases.login.SyncIsPerformedInteractor -import org.dhis2.usescases.main.domain.LogoutUser -import org.dhis2.usescases.settings.DeleteUserData -import org.hisp.dhis.android.core.category.CategoryCombo -import org.hisp.dhis.android.core.category.CategoryOptionCombo -import org.hisp.dhis.android.core.configuration.internal.DatabaseAccount -import org.hisp.dhis.android.core.systeminfo.SystemInfo -import org.hisp.dhis.android.core.user.User -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.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoMoreInteractions -import org.mockito.kotlin.whenever -import java.io.File -import java.util.Date - -@OptIn(ExperimentalCoroutinesApi::class) -class MainPresenterTest { - private lateinit var presenter: MainPresenter - private val repository: HomeRepository = mock() - private val schedulers: SchedulerProvider = TrampolineSchedulerProvider() - private val view: MainView = mock() - private val preferences: PreferenceProvider = mock() - private val workManagerController: WorkManagerController = mock() - private val filterManager: FilterManager = mock() - private val filterRepository: FilterRepository = mock() - - private val matomoAnalyticsController: MatomoAnalyticsController = mock() - private val userManager: UserManager = mock() - private val deleteUserData: DeleteUserData = mock() - private val syncIsPerfomedInteractor: SyncIsPerformedInteractor = mock() - private val syncStatusController: SyncStatusController = mock() - private val versionRepository: VersionRepository = mock() - private val testingDispatcher = UnconfinedTestDispatcher() - private val dispatcherProvider: DispatcherProvider = - mock { - on { io() } doReturn testingDispatcher - on { ui() } doReturn testingDispatcher - } - - private val forceToNotSynced: Boolean = false - - private val logoutUser: LogoutUser = mock() - - @Rule - @JvmField - val rule = InstantTaskExecutorRule() - - @Before - fun setUp() { - Dispatchers.setMain(testingDispatcher) - whenever(versionRepository.newAppVersion) doReturn MutableSharedFlow() - presenter = - MainPresenter( - view, - repository, - schedulers, - preferences, - workManagerController, - filterManager, - filterRepository, - matomoAnalyticsController, - userManager, - deleteUserData, - syncIsPerfomedInteractor, - syncStatusController, - versionRepository, - dispatcherProvider, - forceToNotSynced, - logoutUser, - ) - } - - @Test - fun `Should save default settings and render user name when the activity is resumed`() { - presenterMocks() - - presenter.init() - - verify(view).renderUsername(any()) - verify(preferences).setValue(DEFAULT_CAT_COMBO, "uid") - verify(preferences).setValue(PREF_DEFAULT_CAT_OPTION_COMBO, "uid") - } - - @Test - fun `Should log out`() = - runTest { - val accountsCount = 1 - whenever(logoutUser()) doReturn Result.success(accountsCount) - - presenter.logOut() - - verify(logoutUser, times(1)).invoke() - verify(view).goToLogin(accountsCount, false) - } - - @Test - fun `Should block session`() { - presenter.blockSession() - - verify(workManagerController).cancelAllWork() - verify(view).back() - } - - @Test - fun `Should clear disposable when activity is paused`() { - presenter.onDetach() - - val disposableSize = presenter.disposable.size() - - assertTrue(disposableSize == 0) - } - - @Test - fun `Should open drawer when menu is clicked`() { - presenter.onMenuClick() - - verify(view).openDrawer(any()) - } - - @Test - fun `should return to home section when user taps back in a different section`() { - val filterProcessor: FlowableProcessor = PublishProcessor.create() - val filterManagerFlowable = Flowable.just(filterManager).startWith(filterProcessor) - val periodRequest: FlowableProcessor> = - BehaviorProcessor.create() - whenever(filterManager.asFlowable()) doReturn filterManagerFlowable - whenever(filterManager.periodRequest) doReturn periodRequest - whenever(filterManager.ouTreeFlowable()) doReturn Flowable.just(true) - - presenter.onNavigateBackToHome() - - verify(view).goToHome() - } - - @Test - fun `Should track event when clicking on SyncManager`() { - presenter.onClickSyncManager() - - verify(matomoAnalyticsController).trackEvent(any(), any(), any()) - } - - @Test - fun `Should go to delete account`() { - val randomFile = File("random") - whenever(view.obtainFileView()) doReturn randomFile - whenever(userManager.d2) doReturn mock() - whenever(userManager.d2.userModule()) doReturn mock() - whenever(userManager.d2.userModule().accountManager()) doReturn mock() - whenever(view.obtainFileView()) doReturn randomFile - whenever(repository.accountsCount()) doReturn 1 - - presenter.onDeleteAccount() - - verify(view).showProgressDeleteNotification() - verify(deleteUserData).wipeCacheAndPreferences(randomFile) - verify(userManager.d2?.userModule()?.accountManager())?.deleteCurrentAccount() - verify(view).cancelNotifications() - verify(view).goToLogin(1, true) - } - - @Test - fun `Should go to manage account`() { - val firstRandomUserAccount = - DatabaseAccount - .builder() - .username("random") - .serverUrl("https://www.random.com/") - .encrypted(false) - .databaseName("none") - .databaseCreationDate("16/2/2012") - .build() - val secondRandomUserAccount = - DatabaseAccount - .builder() - .username("random") - .serverUrl("https://www.random.com/") - .encrypted(false) - .databaseName("none") - .databaseCreationDate("16/2/2012") - .build() - - val randomFile = File("random") - - whenever(view.obtainFileView()) doReturn randomFile - whenever(userManager.d2) doReturn mock() - whenever(userManager.d2.userModule()) doReturn mock() - whenever(userManager.d2.userModule().accountManager()) doReturn mock() - whenever( - userManager.d2 - .userModule() - .accountManager() - .getAccounts(), - ) doReturn - listOf( - firstRandomUserAccount, - secondRandomUserAccount, - ) - whenever(repository.accountsCount()) doReturn 2 - - presenter.onDeleteAccount() - - verify(deleteUserData).wipeCacheAndPreferences(randomFile) - verify(userManager.d2?.userModule()?.accountManager())?.deleteCurrentAccount() - verify(view).showProgressDeleteNotification() - verify(view).cancelNotifications() - verify(view).goToLogin(2, true) - } - - @Test - fun `Should track server first time`() { - val serverVersion = "2.38" - whenever(repository.getServerVersion()) doReturn Single.just(systemInfo()) - whenever(preferences.getString(DHIS2, "")) doReturn "" - - presenter.trackDhis2Server() - - verify(matomoAnalyticsController).trackEvent(HOME, SERVER_ACTION, serverVersion) - verify(preferences).setValue(DHIS2, serverVersion) - } - - @Test - fun `Should track server when there is an update`() { - val oldVersion = "2.37" - val newVersion = "2.38" - whenever(repository.getServerVersion()) doReturn Single.just(systemInfo()) - whenever(preferences.getString(DHIS2, "")) doReturn oldVersion - - presenter.trackDhis2Server() - - verify(matomoAnalyticsController).trackEvent(HOME, SERVER_ACTION, newVersion) - verify(preferences).setValue(DHIS2, newVersion) - } - - @Test - fun `Should not track server`() { - whenever(repository.getServerVersion()) doReturn Single.just(systemInfo("")) - whenever(preferences.getString(DHIS2, "")) doReturn "" - - presenter.trackDhis2Server() - - verifyNoMoreInteractions(matomoAnalyticsController) - } - - private fun presenterMocks() { - // UserModule - whenever(repository.user()) doReturn Single.just(createUser()) - - // categoryModule - whenever(repository.defaultCatCombo()) doReturn Single.just(createCategoryCombo()) - whenever(repository.defaultCatOptCombo()) doReturn Single.just(createCategoryOptionCombo()) - - val oldVersion = "2.37" - whenever(repository.getServerVersion()) doReturn Single.just(systemInfo()) - whenever(preferences.getString(DHIS2, "")) doReturn oldVersion - } - - private fun systemInfo(server: String = "2.38") = - SystemInfo - .builder() - .systemName("random") - .contextPath("random too") - .dateFormat("dd/mm/yyyy") - .serverDate(Date()) - .version(server) - .build() - - private fun createUser(): User = - User - .builder() - .uid("userUid") - .firstName("test_name") - .surname("test_surName") - .build() - - private fun createCategoryCombo(): CategoryCombo = - CategoryCombo - .builder() - .uid("uid") - .build() - - private fun createCategoryOptionCombo(): CategoryOptionCombo = - CategoryOptionCombo - .builder() - .uid("uid") - .build() -} diff --git a/app/src/test/java/org/dhis2/usescases/main/MainViewModelIntegrationTest.kt b/app/src/test/java/org/dhis2/usescases/main/MainViewModelIntegrationTest.kt new file mode 100644 index 00000000000..9f14b3c78b5 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/MainViewModelIntegrationTest.kt @@ -0,0 +1,462 @@ +package org.dhis2.usescases.main + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.filters.data.FilterRepository +import org.dhis2.commons.matomo.MatomoAnalyticsController +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.mobile.commons.providers.PreferenceProvider +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncStatusData +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.domain.CheckSingleNavigation +import org.dhis2.usescases.main.domain.ConfigureHomeNavigationBar +import org.dhis2.usescases.main.domain.DeleteAccount +import org.dhis2.usescases.main.domain.DownloadNewVersion +import org.dhis2.usescases.main.domain.GetHomeFilters +import org.dhis2.usescases.main.domain.GetLockAction +import org.dhis2.usescases.main.domain.GetUserName +import org.dhis2.usescases.main.domain.LaunchInitialSync +import org.dhis2.usescases.main.domain.LogoutUser +import org.dhis2.usescases.main.domain.ScheduleNewVersionAlert +import org.dhis2.usescases.main.domain.UpdateInitialSyncStatus +import org.dhis2.usescases.main.ui.model.HomeEffect +import org.dhis2.utils.MainCoroutineScopeRule +import org.hisp.dhis.android.core.user.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File + +/** + * Integration test for [MainViewModel]. + * + * Use cases are real instances wired with a mocked [HomeRepository]. Only infrastructure + * boundaries (repository, sync, analytics) are mocked. This validates the contract between + * the ViewModel and the domain layer end-to-end. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelIntegrationTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val coroutineScopeRule = MainCoroutineScopeRule() + + private val testingDispatcher = StandardTestDispatcher() + + // --- Repository mocks (boundary under test) --- + private val homeRepository: HomeRepository = mock() + private val filterRepository: FilterRepository = mock() + + // --- Infrastructure mocks --- + private val filterManager: FilterManager = mock() + private val matomoAnalyticsController: MatomoAnalyticsController = mock() + private val syncStatusController: SyncStatusController = mock() + private val resourceManager: ResourceManager = mock() + private val mainNavigator: MainNavigator = mock() + private val preferences: PreferenceProvider = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() + private val downloadNewVersion: DownloadNewVersion = mock() + private val scheduleNewVersionAlert: ScheduleNewVersionAlert = mock() + + private val syncStatusFlow = MutableStateFlow(SyncStatusData()) + private val newVersionFlow = MutableSharedFlow() + private val filterManagerFlow = MutableStateFlow(0) + + // --- Real use cases (wired with mocked repos) --- + private lateinit var getUserName: GetUserName + private lateinit var configureHomeNavigationBar: ConfigureHomeNavigationBar + private lateinit var getHomeFilters: GetHomeFilters + private lateinit var getLockAction: GetLockAction + private lateinit var checkSingleNavigation: CheckSingleNavigation + private lateinit var launchInitialSync: LaunchInitialSync + private lateinit var logOutUser: LogoutUser + private lateinit var deleteAccount: DeleteAccount + private lateinit var updateInitialSyncStatus: UpdateInitialSyncStatus + + private lateinit var viewModel: MainViewModel + + @Before + fun setUp() = runTest { + Dispatchers.setMain(testingDispatcher) + + getUserName = GetUserName(homeRepository) + configureHomeNavigationBar = ConfigureHomeNavigationBar(homeRepository, resourceManager) + getHomeFilters = GetHomeFilters(filterRepository) + getLockAction = GetLockAction(homeRepository) + checkSingleNavigation = CheckSingleNavigation(homeRepository) + updateInitialSyncStatus = UpdateInitialSyncStatus(homeRepository) + logOutUser = LogoutUser(homeRepository, syncBackgroundJobAction, syncStatusController, filterManager) + deleteAccount = DeleteAccount(filterManager, homeRepository) + // skipSync = true avoids triggering a background sync in every test + launchInitialSync = LaunchInitialSync( + skipSync = true, + homeRepository = homeRepository, + versionRepository = mock(), + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + whenever(syncStatusController.observeDownloadProcess()) doReturn syncStatusFlow + whenever(scheduleNewVersionAlert.newVersionFlow) doReturn newVersionFlow + whenever(filterManager.asFlow(any())) doReturn filterManagerFlow + whenever(filterManager.periodRequest) doReturn mock() + whenever(filterManager.ouTreeFlowable()) doReturn mock() + // ConfigureHomeNavigationBar builds NavigationBarItem(label = getString(...)); a null + // label causes a NPE that silently crashes loadData. Stub it globally here. + whenever(resourceManager.getString(any())) doReturn "" + + // Default: multiple programs → CheckSingleNavigation fails silently, no effect emitted + whenever(homeRepository.homeItemCount()) doReturn 2 + } + + // ------------------------------------------------------------------ helpers + + private fun buildViewModel( + initialScreen: MainScreenType = MainScreenType.Home(HomeScreen.Programs), + ) { + viewModel = MainViewModel( + preferences = preferences, + filterManager = filterManager, + matomoAnalyticsController = matomoAnalyticsController, + syncStatusController = syncStatusController, + mainNavigator = mainNavigator, + getUserName = getUserName, + configureHomeNavigationBar = configureHomeNavigationBar, + getHomeFilters = getHomeFilters, + downloadNewVersion = downloadNewVersion, + logOutUser = logOutUser, + deleteAccount = deleteAccount, + getLockAction = getLockAction, + updateInitialSyncStatus = updateInitialSyncStatus, + checkSingleNavigation = checkSingleNavigation, + launchInitialSync = launchInitialSync, + scheduleNewVersionAlert = scheduleNewVersionAlert, + syncBackgroundJobAction = syncBackgroundJobAction, + initialScreen = initialScreen, + dispatcher = object : DispatcherProvider { + override fun io(): CoroutineDispatcher = testingDispatcher + override fun computation(): CoroutineDispatcher = testingDispatcher + override fun ui(): CoroutineDispatcher = testingDispatcher + }, + ) + } + + // NOTE: The User mock must be created before any `whenever` call is opened, because + // Mockito's stubbing state machine gets confused if another mock is configured while a + // stubbing is "in progress" (hint #3 of UnfinishedStubbingException). + private fun mockUser(firstName: String?, surname: String?): User = mock { + on { firstName() } doReturn firstName + on { surname() } doReturn surname + } + + // ------------------------------------------------------------------ state tests + + @Test + fun `should expose full name built by GetUserName use case`() = runTest { + val user = mockUser("Jane", "Doe") + whenever(homeRepository.user()) doReturn user + whenever(homeRepository.hasHomeAnalytics()) doReturn false + whenever(filterRepository.homeFilters()) doReturn emptyList() + whenever(filterManager.totalFilters) doReturn 0 + + buildViewModel() + + viewModel.homeScreenState.test { + awaitItem() // default / loading state + assertEquals("Jane Doe", awaitItem().userName) + } + } + + @Test + fun `should expose only first name when surname is absent`() = runTest { + val user = mockUser("Jane", null) + whenever(homeRepository.user()) doReturn user + whenever(homeRepository.hasHomeAnalytics()) doReturn false + whenever(filterRepository.homeFilters()) doReturn emptyList() + whenever(filterManager.totalFilters) doReturn 0 + + buildViewModel() + + viewModel.homeScreenState.test { + awaitItem() + assertEquals("Jane", awaitItem().userName) + } + } + + @Test + fun `should show two nav items and bottom bar when analytics are configured`() = runTest { + val user = mockUser("User", null) + whenever(homeRepository.user()) doReturn user + whenever(homeRepository.hasHomeAnalytics()) doReturn true + whenever(filterRepository.homeFilters()) doReturn emptyList() + whenever(filterManager.totalFilters) doReturn 0 + whenever(resourceManager.getString(any())) doReturn "" + + buildViewModel() + + viewModel.homeScreenState.test { + awaitItem() + val state = awaitItem() + assertEquals(2, state.navigationBarItems.size) + assertTrue(state.bottomNavigationBarVisible) + } + } + + @Test + fun `should show one nav item and hide bottom bar when analytics are not configured`() = runTest { + val user = mockUser("User", null) + whenever(homeRepository.user()) doReturn user + whenever(homeRepository.hasHomeAnalytics()) doReturn false + whenever(filterRepository.homeFilters()) doReturn emptyList() + whenever(filterManager.totalFilters) doReturn 0 + + buildViewModel() + + viewModel.homeScreenState.test { + awaitItem() + val state = awaitItem() + assertEquals(1, state.navigationBarItems.size) + assertFalse(state.bottomNavigationBarVisible) + } + } + + @Test + fun `should expose active filter count from filterManager`() = runTest { + // FilterItem is a sealed abstract class and cannot be mocked; test activeFilters + // (from FilterManager) and homeFilters (from FilterRepository) independently. + val user = mockUser("User", null) + whenever(homeRepository.user()) doReturn user + whenever(homeRepository.hasHomeAnalytics()) doReturn false + whenever(filterRepository.homeFilters()) doReturn emptyList() + whenever(filterManager.totalFilters) doReturn 3 + + buildViewModel() + + viewModel.homeScreenState.test { + awaitItem() // default state + // filterManager.asFlow fires before loadData completes → intermediate state with + // activeFilters=3 on the default skeleton. Both this state and the fully-loaded + // state will have activeFilters=3, so checking the first non-default item is enough. + assertEquals(3, awaitItem().activeFilters) + cancelAndIgnoreRemainingEvents() + } + } + + // ------------------------------------------------------------------ lock / pin effects + + @Test + fun `should emit ShowPinDialog when no pin is stored`() = runTest { + whenever(homeRepository.isPinStored()) doReturn false + + buildViewModel() + + viewModel.homeEffects.test { + viewModel.onBlockSession() + assertEquals(HomeEffect.ShowPinDialog, awaitItem()) + } + } + + @Test + fun `should emit BlockSession when a pin is already stored`() = runTest { + whenever(homeRepository.isPinStored()) doReturn true + + buildViewModel() + + viewModel.homeEffects.test { + viewModel.onBlockSession() + assertEquals(HomeEffect.BlockSession, awaitItem()) + } + } + + // ------------------------------------------------------------------ logout + + @Test + fun `should emit GoToLogin with correct account count after logout`() = runTest { + whenever(homeRepository.clearPin()) doReturn Result.success(Unit) + whenever(homeRepository.logOut()) doReturn Result.success(Unit) + whenever(homeRepository.accountsCount()) doReturn 2 + + buildViewModel() + + viewModel.homeEffects.test { + viewModel.logOut() + assertEquals(HomeEffect.GoToLogin(accountsCount = 2, isDeletion = false), awaitItem()) + } + } + + @Test + fun `should cancel sync jobs and restore sync state on logout`() = runTest { + whenever(homeRepository.clearPin()) doReturn Result.success(Unit) + whenever(homeRepository.logOut()) doReturn Result.success(Unit) + whenever(homeRepository.accountsCount()) doReturn 1 + + buildViewModel() + viewModel.logOut() + advanceUntilIdle() + + verify(syncBackgroundJobAction).cancelAll() + verify(syncStatusController).restore() + verify(homeRepository).logOut() + } + + // ------------------------------------------------------------------ delete account + + @Test + fun `should emit ShowDeleteNotification then GoToLogin after account deletion`() = runTest { + // Explicitly stub all suspend funs used by DeleteAccount so mockito-kotlin + // resumes the continuation correctly (unspecified suspend funs may not resume). + whenever(homeRepository.clearCache(any())) doReturn false + whenever(homeRepository.clearPreferences()) doReturn Unit + whenever(homeRepository.wipeAll()) doReturn Unit + whenever(homeRepository.deleteCurrentAccount()) doReturn Unit + whenever(homeRepository.accountsCount()) doReturn 0 + + buildViewModel() + + viewModel.homeEffects.test { + val mockedContext = mock() + whenever(mockedContext.cacheDir) doReturn File("cache") + with(mockedContext) { viewModel.onDeleteAccount() } + + assertEquals(HomeEffect.ShowDeleteNotification, awaitItem()) + assertEquals(HomeEffect.GoToLogin(accountsCount = 0, isDeletion = true), awaitItem()) + } + } + + @Test + fun `should wipe account data and cancel sync on account deletion`() = runTest { + whenever(homeRepository.clearCache(any())) doReturn false + whenever(homeRepository.clearPreferences()) doReturn Unit + whenever(homeRepository.wipeAll()) doReturn Unit + whenever(homeRepository.deleteCurrentAccount()) doReturn Unit + whenever(homeRepository.accountsCount()) doReturn 0 + + buildViewModel() + + val mockedContext = mock() + whenever(mockedContext.cacheDir) doReturn File("cache") + with(mockedContext) { viewModel.onDeleteAccount() } + advanceUntilIdle() + + verify(homeRepository).wipeAll() + verify(homeRepository).deleteCurrentAccount() + verify(syncBackgroundJobAction).cancelAll() + } + + // ------------------------------------------------------------------ sync status + + @Test + fun `should mark initial sync done when sync process completes`() = runTest { + buildViewModel() + + syncStatusFlow.emit(SyncStatusData(running = false)) + advanceUntilIdle() + + verify(homeRepository).setInitialSyncDone() + } + + @Test + fun `should hide filter and sync buttons while sync is running`() = runTest { + val user = mockUser("User", null) + whenever(homeRepository.user()) doReturn user + whenever(homeRepository.hasHomeAnalytics()) doReturn false + whenever(filterRepository.homeFilters()) doReturn emptyList() + whenever(filterManager.totalFilters) doReturn 0 + + buildViewModel() + + viewModel.homeScreenState.test { + awaitItem() // loading state + awaitItem() // loaded state + + syncStatusFlow.emit(SyncStatusData(running = true)) + with(awaitItem()) { + assertFalse(filterButtonVisible) + assertFalse(syncButtonVisible) + assertFalse(bottomNavigationBarVisible) + } + } + } + + @Test + fun `should restore filter and sync buttons after sync completes`() = runTest { + val user = mockUser("User", null) + whenever(homeRepository.user()) doReturn user + whenever(homeRepository.hasHomeAnalytics()) doReturn true + whenever(filterRepository.homeFilters()) doReturn emptyList() + whenever(filterManager.totalFilters) doReturn 0 + whenever(resourceManager.getString(any())) doReturn "" + + buildViewModel() + + viewModel.homeScreenState.test { + awaitItem() + awaitItem() // loaded state + + syncStatusFlow.emit(SyncStatusData(running = true)) + awaitItem() // running state + + syncStatusFlow.emit(SyncStatusData(running = false)) + with(awaitItem()) { + assertTrue(filterButtonVisible) + assertTrue(syncButtonVisible) + } + } + } + + // ------------------------------------------------------------------ single program navigation + + @Test + fun `should emit SingleProgramNavigation when only one home item exists`() = runTest { + val singleItem = HomeItemData.EventProgram( + uid = "program_uid", + label = "Only Program", + accessDataWrite = true, + ) + whenever(homeRepository.homeItemCount()) doReturn 1 + whenever(homeRepository.singleHomeItemData()) doReturn singleItem + + buildViewModel() + + viewModel.homeEffects.test { + val effect = awaitItem() + assertTrue(effect is HomeEffect.SingleProgramNavigation) + assertEquals(singleItem, (effect as HomeEffect.SingleProgramNavigation).homeItemData) + } + } + + @Test + fun `should not emit SingleProgramNavigation when multiple home items exist`() = runTest { + whenever(homeRepository.homeItemCount()) doReturn 3 + + buildViewModel() + + viewModel.homeEffects.test { + advanceUntilIdle() + expectNoEvents() + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt new file mode 100644 index 00000000000..34d6b3e81f6 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/MainViewModelTest.kt @@ -0,0 +1,287 @@ +package org.dhis2.usescases.main + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import app.cash.turbine.test +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.matomo.Actions.Companion.BLOCK_SESSION_PIN +import org.dhis2.commons.matomo.Actions.Companion.SETTINGS +import org.dhis2.commons.matomo.Categories.Companion.HOME +import org.dhis2.commons.matomo.Labels.Companion.CLICK +import org.dhis2.commons.matomo.MatomoAnalyticsController +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.providers.PreferenceProvider +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncStatusData +import org.dhis2.usescases.main.domain.CheckSingleNavigation +import org.dhis2.usescases.main.domain.ConfigureHomeNavigationBar +import org.dhis2.usescases.main.domain.DeleteAccount +import org.dhis2.usescases.main.domain.DownloadNewVersion +import org.dhis2.usescases.main.domain.GetHomeFilters +import org.dhis2.usescases.main.domain.GetLockAction +import org.dhis2.usescases.main.domain.GetUserName +import org.dhis2.usescases.main.domain.InitialSyncAction +import org.dhis2.usescases.main.domain.LaunchInitialSync +import org.dhis2.usescases.main.domain.LogoutUser +import org.dhis2.usescases.main.domain.ScheduleNewVersionAlert +import org.dhis2.usescases.main.domain.UpdateInitialSyncStatus +import org.dhis2.usescases.main.domain.model.LockAction +import org.dhis2.usescases.main.ui.Form +import org.dhis2.usescases.main.ui.model.HomeAction +import org.dhis2.usescases.main.ui.model.HomeEffect +import org.dhis2.usescases.main.ui.model.VersionToUpdateState +import org.dhis2.utils.MainCoroutineScopeRule +import org.dhis2.utils.analytics.CLOSE_SESSION +import org.dhis2.utils.customviews.navigationbar.NavigationPage +import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem +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.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val coroutineScopeRule = MainCoroutineScopeRule() + + private val testingDispatcher = StandardTestDispatcher() + + private val preferences: PreferenceProvider = mock() + private val filterManager: FilterManager = mock() + private val matomoAnalyticsController: MatomoAnalyticsController = mock() + private val syncStatusController: SyncStatusController = mock() + private val resourceManager: ResourceManager = mock() + private val mainNavigator: MainNavigator = mock() + private val getUserName: GetUserName = mock() + private val configureHomeNavigationBar: ConfigureHomeNavigationBar = mock() + private val getHomeFilters: GetHomeFilters = mock() + private val downloadNewVersion: DownloadNewVersion = mock() + private val logOutUser: LogoutUser = mock() + private val deleteAccount: DeleteAccount = mock() + private val getLockAction: GetLockAction = mock() + private val updateInitialSyncStatus: UpdateInitialSyncStatus = mock() + private val checkSingleNavigation: CheckSingleNavigation = mock() + private val launchInitialSync: LaunchInitialSync = mock() + private val scheduleNewVersionAlert: ScheduleNewVersionAlert = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() + + private lateinit var viewModel: MainViewModel + + private val mockedFilterManagerMock = MutableStateFlow(0) + + private val syncStatusFlow = MutableStateFlow(SyncStatusData()) + private val newVersionFlow = MutableSharedFlow() + + @Before + fun setUp() = runTest { + Dispatchers.setMain(testingDispatcher) + + whenever(syncStatusController.observeDownloadProcess()) doReturn syncStatusFlow + whenever(scheduleNewVersionAlert.newVersionFlow) doReturn newVersionFlow + whenever(filterManager.asFlow(any())) doReturn mockedFilterManagerMock + whenever(filterManager.periodRequest) doReturn mock() + whenever(filterManager.ouTreeFlowable()) doReturn mock() + whenever(launchInitialSync()) doReturn Result.success(InitialSyncAction.Skip) + whenever(checkSingleNavigation()) doReturn Result.failure(Exception("no single navigation")) + + viewModel = MainViewModel( + preferences = preferences, + filterManager = filterManager, + matomoAnalyticsController = matomoAnalyticsController, + syncStatusController = syncStatusController, + mainNavigator = mainNavigator, + getUserName = getUserName, + configureHomeNavigationBar = configureHomeNavigationBar, + getHomeFilters = getHomeFilters, + downloadNewVersion = downloadNewVersion, + logOutUser = logOutUser, + deleteAccount = deleteAccount, + getLockAction = getLockAction, + updateInitialSyncStatus = updateInitialSyncStatus, + checkSingleNavigation = checkSingleNavigation, + launchInitialSync = launchInitialSync, + scheduleNewVersionAlert = scheduleNewVersionAlert, + syncBackgroundJobAction = syncBackgroundJobAction, + initialScreen = MainScreenType.Home(HomeScreen.Programs), + dispatcher = object : DispatcherProvider { + override fun io(): CoroutineDispatcher = testingDispatcher + + override fun computation(): CoroutineDispatcher = testingDispatcher + + override fun ui(): CoroutineDispatcher = testingDispatcher + }, + ) + } + + @Test + fun `Should save default settings and render user name when the activity is resumed`() = + runTest { + whenever(getUserName()) doReturn Result.success("mocked_user_name") + whenever(configureHomeNavigationBar()) doReturn Result.success(emptyList()) + whenever(getHomeFilters()) doReturn Result.success(emptyList()) + whenever(resourceManager.getString(-1)) doReturn "" + whenever(filterManager.totalFilters) doReturn 0 + + viewModel.homeScreenState.test { + awaitItem() + assertTrue(awaitItem().userName == "mocked_user_name") + } + } + + @Test + fun `Should log out`() = + runTest { + whenever(logOutUser()) doReturn Result.success(1) + viewModel.homeEffects.test { + viewModel.logOut() + advanceUntilIdle() + verify(matomoAnalyticsController).trackEvent(HOME, CLOSE_SESSION, CLICK) + assertTrue(awaitItem() == HomeEffect.GoToLogin(1, false)) + } + } + + @Test + fun `Should block session`() = runTest { + + whenever(getLockAction()) doReturn Result.success(LockAction.BlockSession) + + viewModel.homeEffects.test { + viewModel.onBlockSession() + assertTrue(awaitItem() == HomeEffect.BlockSession) + verify(matomoAnalyticsController).trackEvent( + HOME, BLOCK_SESSION_PIN, CLICK + ) + } + } + + @Test + fun `Should open drawer when menu is clicked`() = runTest { + viewModel.homeEffects.test { + viewModel.onAction(HomeAction.MenuClicked) + assertTrue(awaitItem() == HomeEffect.ToggleSideMenu) + } + } + + @Test + fun `should return to home section when user taps back in a different section`() = runTest { + whenever(mainNavigator.openHome()) doReturn MainScreenType.Home(HomeScreen.Programs) + viewModel.onChangeScreen(MainScreenType.Settings) + viewModel.onAction(HomeAction.BackPressed) + advanceUntilIdle() + verify(mainNavigator, times(1)).openHome() + } + + @Test + fun `should close app when user taps back in a home section`() = runTest { + viewModel.homeEffects.test { + viewModel.onChangeScreen(MainScreenType.Home(HomeScreen.Programs)) + viewModel.onAction(HomeAction.BackPressed) + assertTrue(awaitItem() is HomeEffect.BlockSession) + } + } + + @Test + fun `Should track event when clicking on SyncManager`() = runTest { + viewModel.onChangeScreen(MainScreenType.Settings) + advanceUntilIdle() + verify(matomoAnalyticsController).trackEvent(HOME, SETTINGS, CLICK) + } + + @Test + fun `Should go to delete account`() = runTest { + + whenever(deleteAccount(any())) doReturn Result.success(1) + + viewModel.homeEffects.test { + val mockedContext = mock() + whenever(mockedContext.cacheDir) doReturn File("random") + with(mockedContext) { + viewModel.onDeleteAccount() + } + + assertTrue(awaitItem() == HomeEffect.ShowDeleteNotification) + verify(syncBackgroundJobAction).cancelAll() + assertTrue(awaitItem() == HomeEffect.GoToLogin(1, true)) + + } + } + + @Test + fun shouldSetVersionToUpdate() = runTest { + whenever(getUserName()) doReturn Result.success("username") + whenever(configureHomeNavigationBar()) doReturn Result.success(emptyList()) + whenever(getHomeFilters()) doReturn Result.success(emptyList()) + whenever(filterManager.totalFilters) doReturn 0 + + viewModel.homeScreenState.test { + awaitItem() + newVersionFlow.emit("test.test.test") + with(awaitItem()) { + assertTrue(versionToUpdate is VersionToUpdateState.New) + assertTrue((versionToUpdate as VersionToUpdateState.New).version == "test.test.test") + } + } + } + + @Test + fun shouldSendGranularSyncEvent() = runTest { + viewModel.homeEffects.test { + viewModel.onAction(HomeAction.SyncClicked) + assertTrue(awaitItem() is HomeEffect.ShowGranularSync) + } + } + + @Test + fun shouldToggleFilters() = runTest { + whenever(getUserName()) doReturn Result.success("username") + whenever(configureHomeNavigationBar()) doReturn Result.success( + listOf( + NavigationBarItem( + id = NavigationPage.PROGRAMS, + icon = Icons.Filled.Form, + label = "Program" + ), + NavigationBarItem( + id = NavigationPage.ANALYTICS, + icon = Icons.Filled.BarChart, + label = "Analytics", + ) + ) + ) + whenever(getHomeFilters()) doReturn Result.success(emptyList()) + whenever(filterManager.totalFilters) doReturn 0 + + viewModel.homeScreenState.test { + viewModel.onAction(HomeAction.FilterClicked) + viewModel.homeEffects.test { + assertTrue((this).awaitItem() is HomeEffect.ToggleFilters) + } + assertTrue((this@test).awaitItem().bottomNavigationBarVisible.not()) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/CheckSingleNavigationTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/CheckSingleNavigationTest.kt new file mode 100644 index 00000000000..afa1fbd1c78 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/CheckSingleNavigationTest.kt @@ -0,0 +1,70 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.HomeItemData +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class CheckSingleNavigationTest { + private val homeRepository: HomeRepository = mock() + private lateinit var checkSingleNavigation: CheckSingleNavigation + + @Before + fun setUp() { + checkSingleNavigation = CheckSingleNavigation(homeRepository) + } + + @Test + fun `should return HomeDataItem if there is only one program`() = + runTest { + whenever(homeRepository.homeItemCount()) doReturn 1 + whenever(homeRepository.singleHomeItemData()) doReturn + HomeItemData.EventProgram( + "eventUid", + "eventLabel", + true, + ) + assertTrue(checkSingleNavigation().isSuccess) + } + + @Test + fun `should return failure if more than one program`() = + runTest { + whenever(homeRepository.homeItemCount()) doReturn 2 + with(checkSingleNavigation()) { + verify(homeRepository, times(0)).singleHomeItemData() + assertTrue(isFailure) + } + } + + @Test + fun `should return failure if there are no programs`() = + runTest { + whenever(homeRepository.homeItemCount()) doReturn 0 + with(checkSingleNavigation()) { + verify(homeRepository, times(0)).singleHomeItemData() + assertTrue(isFailure) + } + } + + @Test + fun `should return failure if there is a domain exception`() = + runTest { + given(homeRepository.homeItemCount()) willAnswer { throw DomainError.DatabaseError("Test") } + with(checkSingleNavigation()) { + assertTrue(isFailure) + assertTrue(exceptionOrNull() is DomainError) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBarTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBarTest.kt new file mode 100644 index 00000000000..63b2cb40869 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/ConfigureHomeNavigationBarTest.kt @@ -0,0 +1,63 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.R +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class ConfigureHomeNavigationBarTest { + private val homeRepository: HomeRepository = mock() + private val resourceManager: ResourceManager = mock() + lateinit var configureHomeNavigationBar: ConfigureHomeNavigationBar + + @Before + fun setUp() { + whenever(resourceManager.getString(R.string.navigation_programs)) doReturn "Programs" + whenever(resourceManager.getString(R.string.navigation_charts)) doReturn "Analytics" + configureHomeNavigationBar = + ConfigureHomeNavigationBar( + homeRepository = homeRepository, + resourceManager = resourceManager, + ) + } + + @Test + fun `should return programs and analytics items if configured`() = + runTest { + whenever(homeRepository.hasHomeAnalytics()) doReturn true + with(configureHomeNavigationBar()) { + assertTrue(isSuccess) + assertTrue(getOrNull()?.size == 2) + } + } + + @Test + fun `should return just programs if analytics is not configured`() = + runTest { + whenever(homeRepository.hasHomeAnalytics()) doReturn false + with(configureHomeNavigationBar()) { + assertTrue(isSuccess) + assertTrue(getOrNull()?.size == 1) + } + } + + @Test + fun `should return just programs if an exception is thrown`() = + runTest { + given(homeRepository.hasHomeAnalytics()) willAnswer { throw DomainError.DatabaseError("Test") } + with(configureHomeNavigationBar()) { + assertTrue(isSuccess) + assertTrue(getOrNull()?.size == 1) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/DeleteAccountTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/DeleteAccountTest.kt new file mode 100644 index 00000000000..a3f6f112651 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/DeleteAccountTest.kt @@ -0,0 +1,65 @@ +package org.dhis2.usescases.main.domain + +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.filters.FilterManager +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer +import kotlin.io.path.createTempFile + +class DeleteAccountTest { + private val filterManager: FilterManager = mock() + private val repository: HomeRepository = mock() + private lateinit var deleteAccount: DeleteAccount + + @Before + fun setUp() { + deleteAccount = + DeleteAccount( + filterManager = filterManager, + repository = repository, + ) + } + + @Test + fun `should successfully return and clear all data`() = + runTest { + val cacheFile = createTempFile().toFile() + whenever(repository.clearCache(any())) doReturn true + whenever(repository.accountsCount()) doReturn 3 + with(deleteAccount(cacheFile)) { + verify(filterManager).clearAllFilters() + verify(repository).clearCache(cacheFile) + verify(repository).clearPreferences() + verify(repository).wipeAll() + verify(repository).deleteCurrentAccount() + assertTrue(isSuccess) + assertTrue(getOrNull() == 3) + } + } + + @Test + fun `should return failure when domain exception is thrown`() = + runTest { + val cacheFile = createTempFile().toFile() + whenever(repository.clearCache(any())) doReturn true + given(repository.deleteCurrentAccount()) willAnswer { throw DomainError.DatabaseError("Test") } + with(deleteAccount(cacheFile)) { + verify(filterManager).clearAllFilters() + verify(repository).clearCache(cacheFile) + verify(repository).clearPreferences() + verify(repository).wipeAll() + verify(repository).deleteCurrentAccount() + assertTrue(isFailure) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/DownloadNewVersionTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/DownloadNewVersionTest.kt new file mode 100644 index 00000000000..caaec27534d --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/DownloadNewVersionTest.kt @@ -0,0 +1,60 @@ +package org.dhis2.usescases.main.domain + +import android.content.Context +import kotlinx.coroutines.test.runTest +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.error.DomainError +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class DownloadNewVersionTest { + private val versionRepository: VersionRepository = mock() + private lateinit var downloadNewVersion: DownloadNewVersion + + @Before + fun setUp() { + downloadNewVersion = DownloadNewVersion(versionRepository) + } + + @Test + fun `should successfully download new version`() = + runTest { + // GIVEN + val fakeUriPath = "fakeUriPath" + whenever( + versionRepository.download( + context = any(), + onDownloadCompleted = any(), + onDownloading = any(), + ), + ).thenAnswer { + // Simulate the callback being called upon successful download + val onDownloadCompletedCallback = it.getArgument<(String) -> Unit>(1) + onDownloadCompletedCallback.invoke(fakeUriPath) + } + val context: Context = mock() + + with(downloadNewVersion(context)) { + assertTrue(isSuccess) + } + } + + @Test + fun `should return failure if an exception is thrown`() = + runTest { + given(versionRepository.download(any(), any(), any())) willAnswer { + throw DomainError.DatabaseError("Test") + } + val context: Context = mock() + + with(downloadNewVersion(context)) { + assertTrue(isFailure) + } + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/GetHomeFiltersTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/GetHomeFiltersTest.kt new file mode 100644 index 00000000000..cc5fb863bd5 --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/GetHomeFiltersTest.kt @@ -0,0 +1,44 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.filters.FilterItem +import org.dhis2.commons.filters.data.FilterRepository +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class GetHomeFiltersTest { + private val filterRepository: FilterRepository = mock() + private lateinit var getHomeFilters: GetHomeFilters + + @Before + fun setUp() { + getHomeFilters = GetHomeFilters(filterRepository) + } + + @Test + fun `should return a list of home filters`() = + runTest { + val expectedFilters: List = mock() + whenever(filterRepository.homeFilters()) doReturn expectedFilters + val result = getHomeFilters() + assert(result.isSuccess) + assert(result.getOrNull() == expectedFilters) + } + + @Test + fun `should return a failure when an exception is thrown`() = + runTest { + given(filterRepository.homeFilters()) willAnswer { + throw DomainError.DatabaseError("Test") + } + val result = getHomeFilters() + assert(result.isFailure) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/GetLockActionTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/GetLockActionTest.kt new file mode 100644 index 00000000000..3db9ea7323e --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/GetLockActionTest.kt @@ -0,0 +1,53 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.dhis2.usescases.main.domain.model.LockAction +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class GetLockActionTest { + private val homeRepository: HomeRepository = mock() + private lateinit var getLockAction: GetLockAction + + @Before + fun setUp() { + getLockAction = GetLockAction(homeRepository) + } + + @Test + fun `should return BlockSession action if pin is set`() = + runTest { + whenever(homeRepository.isPinStored()) doReturn true + val result = getLockAction() + assertTrue(result.isSuccess) + assertTrue(result.getOrNull() is LockAction.BlockSession) + } + + @Test + fun `should return CreatePin action if pin is set`() = + runTest { + whenever(homeRepository.isPinStored()) doReturn false + val result = getLockAction() + assertTrue(result.isSuccess) + assertTrue(result.getOrNull() is LockAction.CreatePin) + } + + @Test + fun `should return failure if an exception is thrown`() = + runTest { + given(homeRepository.isPinStored()) willAnswer { + throw DomainError.DatabaseError("Test") + } + val result = getLockAction() + assertTrue(result.isFailure) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/GetUserNameTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/GetUserNameTest.kt new file mode 100644 index 00000000000..aee8436306d --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/GetUserNameTest.kt @@ -0,0 +1,99 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.hisp.dhis.android.core.user.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.willAnswer + +class GetUserNameTest { + private val homeRepository: HomeRepository = mock() + private lateinit var getUserName: GetUserName + + @Before + fun setUp() { + getUserName = GetUserName(homeRepository) + } + + @Test + fun `should return full name if user exists`() = + runTest { + val user: User = + mock { + on { firstName() } doReturn "Peter" + on { surname() } doReturn "Jones" + } + whenever(homeRepository.user()) doReturn user + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("Peter Jones", result.getOrNull()) + } + + @Test + fun `should return surname if first name is null`() = + runTest { + val user: User = + mock { + on { firstName() } doReturn null + on { surname() } doReturn "Jones" + } + whenever(homeRepository.user()) doReturn user + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("Jones", result.getOrNull()) + } + + @Test + fun `should return first name if surname is null`() = + runTest { + val user: User = + mock { + on { firstName() } doReturn "Peter" + on { surname() } doReturn null + } + whenever(homeRepository.user()) doReturn user + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("Peter", result.getOrNull()) + } + + @Test + fun `should return empty string if user is null`() = + runTest { + whenever(homeRepository.user()) doReturn null + + val result = getUserName() + + assertTrue(result.isSuccess) + assertEquals("", result.getOrNull()) + } + + @Test + fun `should return failure if repository throws error`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(homeRepository.user()) willAnswer { + throw exception + } + + val result = getUserName() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt new file mode 100644 index 00000000000..17d861dfa9a --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/LaunchInitialSyncTest.kt @@ -0,0 +1,144 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.data.service.VersionRepository +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class LaunchInitialSyncTest { + private val homeRepository: HomeRepository = mock() + private val versionRepository: VersionRepository = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() + private lateinit var launchInitialSync: LaunchInitialSync + + @Test + fun `should return Skip if skipSync is true`() = + runTest { + launchInitialSync = + LaunchInitialSync( + skipSync = true, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Skip, result.getOrNull()) + verifyNoInteractions(homeRepository) + verifyNoInteractions(versionRepository) + } + + @Test + fun `should return Skip if database is imported`() = + runTest { + whenever(homeRepository.isImportedDb()) doReturn true + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Skip, result.getOrNull()) + } + + @Test + fun `should return Skip if initial sync is done`() = + runTest { + whenever(homeRepository.isImportedDb()) doReturn false + whenever(homeRepository.getInitialSyncDone()) doReturn true + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Skip, result.getOrNull()) + } + + @Test + fun `should return Syncing and launch initial sync`() = + runTest { + whenever(homeRepository.isImportedDb()) doReturn false + whenever(homeRepository.getInitialSyncDone()) doReturn false + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isSuccess) + assertEquals(InitialSyncAction.Syncing, result.getOrNull()) + verify(versionRepository).checkVersionUpdates() + } + + @Test + fun `should return failure if check version update fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + whenever(homeRepository.isImportedDb()) doReturn false + whenever(homeRepository.getInitialSyncDone()) doReturn false + given(versionRepository.checkVersionUpdates()).willAnswer { + throw exception + } + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + @Test + fun `should return failure if home repository fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(homeRepository.isImportedDb()).willAnswer { + throw exception + } + launchInitialSync = + LaunchInitialSync( + skipSync = false, + homeRepository = homeRepository, + versionRepository = versionRepository, + syncBackgroundJobAction = syncBackgroundJobAction, + ) + + val result = launchInitialSync() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt index df31b8e52fa..c45b523215b 100644 --- a/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/domain/LogoutUserTest.kt @@ -2,10 +2,11 @@ package org.dhis2.usescases.main.domain import kotlinx.coroutines.test.runTest import org.dhis2.commons.filters.FilterManager -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.commons.domain.invoke import org.dhis2.mobile.commons.error.DomainError -import org.dhis2.usescases.main.HomeRepository +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.usescases.main.data.HomeRepository import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before @@ -18,8 +19,8 @@ import org.mockito.kotlin.whenever class LogoutUserTest { private val repository: HomeRepository = mock() - private val workManagerController: WorkManagerController = mock() private val syncStatusController: SyncStatusController = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() private val filterManager: FilterManager = mock() private lateinit var logoutUser: LogoutUser @@ -29,7 +30,7 @@ class LogoutUserTest { logoutUser = LogoutUser( repository, - workManagerController, + syncBackgroundJobAction, syncStatusController, filterManager, ) @@ -41,10 +42,10 @@ class LogoutUserTest { whenever(repository.logOut()) doReturn Result.success(Unit) whenever(repository.accountsCount()) doReturn 1 val result = logoutUser() - verify(workManagerController).cancelAllWorkAndWait() + verify(syncBackgroundJobAction).cancelAll() verify(syncStatusController).restore() verify(filterManager).clearAllFilters() - verify(repository).clearSessionLock() + verify(repository).clearPin() verify(repository).logOut() verify(repository).accountsCount() @@ -55,12 +56,12 @@ class LogoutUserTest { fun `GIVEN the user logs out WHEN domain error THEN exception is returned`() = runTest { val testException = DomainError.UnexpectedError("test") - whenever(repository.clearSessionLock()) doReturn Result.failure(testException) + whenever(repository.clearPin()) doReturn Result.failure(testException) val result = logoutUser() - verify(workManagerController).cancelAllWorkAndWait() + verify(syncBackgroundJobAction).cancelAll() verify(syncStatusController).restore() verify(filterManager).clearAllFilters() - verify(repository).clearSessionLock() + verify(repository).clearPin() verify(repository, never()).logOut() verify(repository, never()).accountsCount() @@ -80,10 +81,10 @@ class LogoutUserTest { assertTrue(e == testException) } - verify(workManagerController).cancelAllWorkAndWait() + verify(syncBackgroundJobAction).cancelAll() verify(syncStatusController).restore() verify(filterManager).clearAllFilters() - verify(repository).clearSessionLock() + verify(repository).clearPin() verify(repository).logOut() verify(repository, never()).accountsCount() } diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlertTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlertTest.kt new file mode 100644 index 00000000000..77f6c45144e --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/ScheduleNewVersionAlertTest.kt @@ -0,0 +1,55 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.data.service.VersionRepository +import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class ScheduleNewVersionAlertTest { + private val workManagerController: WorkManagerController = mock() + private val versionRepository: VersionRepository = mock() + private lateinit var scheduleNewVersionAlert: ScheduleNewVersionAlert + + @Before + fun setUp() { + scheduleNewVersionAlert = + ScheduleNewVersionAlert( + workManagerController = workManagerController, + versionRepository = versionRepository, + ) + } + + @Test + fun `should schedule new version alert and remove version info`() = + runTest { + val result = scheduleNewVersionAlert() + + assertTrue(result.isSuccess) + + verify(workManagerController).beginUniqueWork(any()) + verify(versionRepository).removeVersionInfo() + } + + @Test + fun `should return failure when remove version info fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(versionRepository.removeVersionInfo()).willAnswer { + throw exception + } + + val result = scheduleNewVersionAlert() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatusTest.kt b/app/src/test/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatusTest.kt new file mode 100644 index 00000000000..11589c76b5f --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/main/domain/UpdateInitialSyncStatusTest.kt @@ -0,0 +1,46 @@ +package org.dhis2.usescases.main.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.usescases.main.data.HomeRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.given +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class UpdateInitialSyncStatusTest { + private val homeRepository: HomeRepository = mock() + private lateinit var updateInitialSyncStatus: UpdateInitialSyncStatus + + @Before + fun setUp() { + updateInitialSyncStatus = UpdateInitialSyncStatus(homeRepository) + } + + @Test + fun `should call set initial sync done`() = + runTest { + val result = updateInitialSyncStatus() + + assertTrue(result.isSuccess) + verify(homeRepository).setInitialSyncDone() + } + + @Test + fun `should return failure when repository fails`() = + runTest { + val exception = DomainError.DatabaseError("Error") + given(homeRepository.setInitialSyncDone()).willAnswer { + throw exception + } + + val result = updateInitialSyncStatus() + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt index ff8c3653a59..00994f23b3f 100644 --- a/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/program/ProgramRepositoryImplTest.kt @@ -12,12 +12,13 @@ import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.dhislogic.DhisProgramUtils import org.dhis2.data.schedulers.TrampolineSchedulerProvider -import org.dhis2.data.service.SyncStatusData import org.dhis2.mobile.commons.model.MetadataIconData +import org.dhis2.mobile.sync.model.SyncStatusData import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.Access import org.hisp.dhis.android.core.common.DataAccess import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.dataset.DataSet import org.hisp.dhis.android.core.dataset.DataSetInstanceSummary @@ -88,6 +89,7 @@ class ProgramRepositoryImplTest { .builder() .uid("dataSetUid") .description("description") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .style( ObjectStyle .builder() @@ -242,7 +244,7 @@ class ProgramRepositoryImplTest { filterPresenter.filteredTrackerProgram(any()).offlineFirst(), ) doReturn mock() whenever( - filterPresenter.filteredTrackerProgram(any()).offlineFirst().blockingGetUids(), + filterPresenter.filteredTrackerProgram(any()).offlineFirst().blockingGetUids(), ) doReturn listOf("0", "1") } @@ -274,6 +276,8 @@ class ProgramRepositoryImplTest { .displayName("program1") .programType(ProgramType.WITHOUT_REGISTRATION) .style(ObjectStyle.builder().build()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), Program .builder() @@ -281,6 +285,8 @@ class ProgramRepositoryImplTest { .displayName("program2") .programType(ProgramType.WITH_REGISTRATION) .style(ObjectStyle.builder().build()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .trackedEntityType( TrackedEntityType .builder() diff --git a/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt index 0b3804dde34..5f8271dc73c 100644 --- a/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/main/program/ProgramViewModelTest.kt @@ -16,10 +16,10 @@ import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.matomo.MatomoAnalyticsController import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.schedulers.TestSchedulerProvider -import org.dhis2.data.service.SyncStatusController -import org.dhis2.data.service.SyncStatusData import org.dhis2.mobile.commons.extensions.toColor import org.dhis2.mobile.commons.model.MetadataIconData +import org.dhis2.mobile.sync.domain.SyncStatusController +import org.dhis2.mobile.sync.model.SyncStatusData import org.dhis2.utils.MainCoroutineScopeRule import org.hisp.dhis.android.core.common.State import org.hisp.dhis.mobile.ui.designsystem.component.ImageCardData diff --git a/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt index b931b2efaa0..87cb0762bcd 100644 --- a/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/notes/NotesRepositoryTest.kt @@ -4,6 +4,7 @@ import io.reactivex.Single import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.Access import org.hisp.dhis.android.core.common.DataAccess +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.note.Note import org.hisp.dhis.android.core.program.Program @@ -94,6 +95,8 @@ class NotesRepositoryTest { Program .builder() .uid(UUID.randomUUID().toString()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .access( Access .builder() @@ -171,6 +174,11 @@ class NotesRepositoryTest { .eq(teiUid) .one() .blockingGet(), - ) doReturn Enrollment.builder().uid(enrollmentUid).build() + ) doReturn + Enrollment + .builder() + .uid(enrollmentUid) + .attributeOptionCombo("attributeOptionComboUid") + .build() } } diff --git a/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt index e228758c385..c8c2fd556ae 100644 --- a/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/notes/noteDetail/NoteDetailRepositoryTest.kt @@ -153,6 +153,11 @@ class NoteDetailRepositoryTest { .eq(teiUid) .one() .blockingGet(), - ) doReturn Enrollment.builder().uid("EnrollmentUid").build() + ) doReturn + Enrollment + .builder() + .uid("EnrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() } } diff --git a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt index 494382f094c..bffb0e8e37f 100644 --- a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventDetailPresenterTest.kt @@ -15,6 +15,7 @@ import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider import org.hisp.dhis.android.core.category.CategoryCombo import org.hisp.dhis.android.core.category.CategoryOptionCombo +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.junit.After import org.junit.Assert.assertTrue @@ -69,7 +70,13 @@ class ProgramEventDetailPresenterTest { @Test fun `Should init screen`() { - val program = Program.builder().uid("programUid").build() + val program = + Program + .builder() + .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(repository.getAccessDataWrite()) doReturn true whenever(repository.program()) doReturn Single.just(program) diff --git a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt index cb20610a333..3c180e57073 100644 --- a/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/programEventDetail/ProgramEventMapperTest.kt @@ -7,6 +7,7 @@ import org.dhis2.mobile.commons.model.MetadataIconData import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.category.CategoryOptionCombo import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventStatus @@ -49,7 +50,7 @@ class ProgramEventMapperTest { val result = mapper.eventToProgramEvent(event) assert(!result.isExpired) - assert(event.uid() == result.uid()) + assert(event.uid() == result.uid) assert(result.eventState == State.TO_UPDATE) } @@ -64,7 +65,7 @@ class ProgramEventMapperTest { val result = mapper.eventToProgramEvent(event) assert(!result.isExpired) - assert(event.uid() == result.uid()) + assert(event.uid() == result.uid) assert(result.eventState == State.SYNCED) } @@ -226,6 +227,8 @@ class ProgramEventMapperTest { .uid("programUid") .completeEventsExpiryDays(0) .expiryDays(0) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() private fun dummyCategoryOptionCombo() = diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt index b2437a4242b..044b2c1aff8 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryTest.kt @@ -6,26 +6,29 @@ import org.dhis2.commons.date.DateUtils import org.dhis2.commons.filters.Filters import org.dhis2.commons.filters.data.FilterPresenter import org.dhis2.commons.filters.sorting.SortingItem -import org.dhis2.commons.network.NetworkUtils -import org.dhis2.commons.resources.DhisPeriodUtils -import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.forms.dataentry.SearchTEIRepository -import org.dhis2.data.sorting.SearchSortingValueSetter import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FieldUiModelImpl import org.dhis2.form.model.UiRenderType -import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.extensions.getTodayAsInstant +import org.dhis2.mobile.commons.extensions.toKtxInstant +import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.tracker.data.ProfilePictureProvider +import org.dhis2.tracker.search.model.GeometryFeatureType +import org.dhis2.tracker.search.model.SyncState +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.dhis2.tracker.search.model.TrackedEntityTypeDomain import org.dhis2.ui.ThemeManager import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.filters.internal.BooleanFilterConnector import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.enrollment.Enrollment @@ -46,8 +49,8 @@ import org.hisp.dhis.android.core.trackedentity.TrackedEntityType import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemAttribute import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemHelper -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -59,11 +62,10 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.util.Calendar import java.util.Date +import kotlin.time.Instant class SearchRepositoryTest { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) - private val fieldViewModelFactory: FieldViewModelFactory = mock() - private val metadataIconProvider: MetadataIconProvider = mock() private val dispatchers: DispatcherProvider = mock { on { io() } doReturn Dispatchers.IO @@ -75,26 +77,29 @@ class SearchRepositoryTest { private val trackedEntitySearchItemHelper: TrackedEntitySearchItemHelper = mock() private val enrollmentCollectionRepository: EnrollmentCollectionRepository = mock() - private val stringFilterConnector: StringFilterConnector = mock() - private val booleanFilterConnector: BooleanFilterConnector = mock() + private val stringFilterConnector: StringFilterConnector = + mock() + private val booleanFilterConnector: BooleanFilterConnector = + mock() private val programCollectionRepository: ProgramCollectionRepository = mock() - private val programReadOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() + private val programReadOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = + mock() private val eventCollectionRepository: EventCollectionRepository = mock() private val eventStatusFilterConnector: EventStatusFilterConnector = mock() - private val stringEventFilterConnector: StringFilterConnector = mock() + private val stringEventFilterConnector: StringFilterConnector = + mock() private val orgUnitCollectionRepository: OrganisationUnitCollectionRepository = mock() - private val readOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() + private val readOnlyOneObjectRepository: ReadOnlyOneObjectRepositoryFinalImpl = + mock() private val filterPresenter: FilterPresenter = mock() private val resourceManager: ResourceManager = mock() - private val sortingValueSetter: SearchSortingValueSetter = mock() - private val dhisPeriodUtils: DhisPeriodUtils = mock() private val charts: Charts = mock() private val crashReporterController: CrashReportController = mock() - private val networkUtils: NetworkUtils = mock() + private val networkUtils: NetworkStatusProvider = mock() private val searchTEIRepository: SearchTEIRepository = mock() private val themeManager: ThemeManager = mock() private val profilePictureProvider: ProfilePictureProvider = mock() @@ -111,7 +116,9 @@ class SearchRepositoryTest { ) val trackedEntityAttributeCollection = mock() - whenever(d2.trackedEntityModule().trackedEntityAttributes()).thenReturn(trackedEntityAttributeCollection) + whenever(d2.trackedEntityModule().trackedEntityAttributes()).thenReturn( + trackedEntityAttributeCollection, + ) whenever(trackedEntityAttributeCollection.uid(anyString())).thenAnswer { invocation -> val uid = invocation.arguments[0] as String trackedEntityAttributes[uid] ?: createTrackedEntityAttributeRepository(uid, false) @@ -122,8 +129,6 @@ class SearchRepositoryTest { searchRepositoryJava = mock(), d2 = d2, dispatcher = dispatchers, - fieldViewModelFactory = fieldViewModelFactory, - metadataIconProvider = metadataIconProvider, trackedEntityInstanceInfoProvider = mock(), eventInfoProvider = mock(), customIntentRepository = customIntentRepository, @@ -136,132 +141,73 @@ class SearchRepositoryTest { d2, filterPresenter, resourceManager, - sortingValueSetter, - dhisPeriodUtils, charts, crashReporterController, networkUtils, searchTEIRepository, themeManager, - metadataIconProvider, - profilePictureProvider, dateUtils, customIntentRepository, + dispatchers ) } - @Test - fun shouldSortSearchParametersCorrectly() { - val mockData = createMockData() - val sortedData = searchRepository.sortSearchParameters(mockData) - - assertEquals("unique-code", sortedData[0].uid) - assertEquals("bp-number", sortedData[1].uid) - assertEquals("qr-code", sortedData[2].uid) - assertEquals("bar-code", sortedData[3].uid) - assertEquals("unique-id", sortedData[4].uid) - assertEquals("national-id", sortedData[5].uid) - assertEquals("first-name", sortedData[6].uid) - assertEquals("last-name", sortedData[7].uid) - assertEquals("phone-number", sortedData[8].uid) - assertEquals("state", sortedData[9].uid) - } - @Test fun shouldTransformToSearchTeiModelWithOverdueEvents() { - val searchItem = getTrackedEntitySearchItem("header") - val program = Program.builder().uid("programUid").build() - val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) - val tei = TrackedEntitySearchItemHelper.toTrackedEntityInstance(searchItem) - val overdueDate = dateUtils.getCalendarByDate(Date()) overdueDate.add(Calendar.DATE, -2) - val enrollmentsInProgram = - listOf( - createEnrollment("enrollmentUid", "orgUnit", program.uid()), - createEnrollment("enrollmentUid_2", "orgUnit", program.uid()), - ) - val allEnrollments = - listOf( - createEnrollment("enrollmentUid_3", "orgUnit", "uid"), - createEnrollment("enrollmentUid_4", "orgUnit_2", "uid"), - ) - val events = - listOf( - createEvent("eventUid", EventStatus.OVERDUE, overdueDate.time), - createEvent("eventUid", EventStatus.SCHEDULE, overdueDate.time), - ) - - mockedSdkCalls(searchItem, tei, enrollmentsInProgram, allEnrollments, events) + val searchItemResult = createTrackedEntitySearchItemResult( + uid = "teiUid", + overDueDate = overdueDate.time.toKtxInstant(), + ) + val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) - val result = searchRepositoryJava.transform(searchItem, program, true, sorting) + val result = searchRepository.mapTrackedEntitySearchItemResultToSearchTeiModel( + searchItemResult, + sorting, + ) - assertTrue(result.isHasOverdue) + assertNotNull(result.tei.overDueDate) + assertTrue(result.tei.overDueDate!! < getTodayAsInstant()) } @Test fun shouldTransformToSearchTeiModelWithOverdueScheduledEvents() { - val searchItem = getTrackedEntitySearchItem("header") - val program = Program.builder().uid("programUid").build() - val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) - val tei = TrackedEntitySearchItemHelper.toTrackedEntityInstance(searchItem) - val overdueDate = dateUtils.getCalendarByDate(Date()) overdueDate.add(Calendar.DATE, -2) - val enrollmentsInProgram = - listOf( - createEnrollment("enrollmentUid", "orgUnit", program.uid()), - createEnrollment("enrollmentUid_2", "orgUnit", program.uid()), - ) - val allEnrollments = - listOf( - createEnrollment("enrollmentUid_3", "orgUnit", "uid"), - createEnrollment("enrollmentUid_4", "orgUnit_2", "uid"), - ) - val events = - listOf( - createEvent("eventUid", EventStatus.SCHEDULE, overdueDate.time), - ) - - mockedSdkCalls(searchItem, tei, enrollmentsInProgram, allEnrollments, events) + val searchItemResult = createTrackedEntitySearchItemResult( + uid = "teiUid", + overDueDate = overdueDate.time.toKtxInstant(), + ) + val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) - val result = searchRepositoryJava.transform(searchItem, program, true, sorting) + val result = searchRepository.mapTrackedEntitySearchItemResultToSearchTeiModel( + searchItemResult, + sorting, + ) - assertTrue(result.isHasOverdue) + assertNotNull(result.tei.overDueDate) + assertTrue(result.tei.overDueDate!! < getTodayAsInstant()) } @Test fun shouldTransformToSearchTeiModelWithOutOverdueEvents() { - val searchItem = getTrackedEntitySearchItem("header") - val program = Program.builder().uid("programUid").build() + // Create a search result without overdue date (null means no overdue events) + val searchItemResult = createTrackedEntitySearchItemResult( + uid = "teiUid", + overDueDate = null, + ) val sorting = SortingItem.create(Filters.ENROLLMENT_DATE) - val tei = TrackedEntitySearchItemHelper.toTrackedEntityInstance(searchItem) - - val overdueDate = dateUtils.getCalendarByDate(Date()) - overdueDate.add(Calendar.DATE, 2) - val enrollmentsInProgram = - listOf( - createEnrollment("enrollmentUid", "orgUnit", program.uid()), - createEnrollment("enrollmentUid_2", "orgUnit", program.uid()), - ) - val allEnrollments = - listOf( - createEnrollment("enrollmentUid_3", "orgUnit", "uid"), - createEnrollment("enrollmentUid_4", "orgUnit_2", "uid"), - ) - val events = - listOf( - createEvent("eventUid", EventStatus.SCHEDULE, overdueDate.time), - ) - - mockedSdkCalls(searchItem, tei, enrollmentsInProgram, allEnrollments, events) - - val result = searchRepositoryJava.transform(searchItem, program, true, sorting) + val result = searchRepository.mapTrackedEntitySearchItemResultToSearchTeiModel( + searchItemResult, + sorting, + ) - assertFalse(result.isHasOverdue) + // When there are no overdue events, overDueDate should be null + assertNull(result.tei.overDueDate) } private fun mockedSdkCalls( @@ -343,7 +289,8 @@ class SearchRepositoryTest { enrollmentCollectionRepository.blockingGet(), ) doReturn enrollmentsForInfoToReturn - val programUid = if (enrollmentsForInfoToReturn.isNotEmpty()) enrollmentsForInfoToReturn[0].program() else "programUid" + val programUid = + if (enrollmentsForInfoToReturn.isNotEmpty()) enrollmentsForInfoToReturn[0].program() else "programUid" whenever(d2.programModule().programs()) doReturn programCollectionRepository whenever( programCollectionRepository.uid(any()), @@ -355,6 +302,8 @@ class SearchRepositoryTest { .builder() .uid(programUid) .displayFrontPageList(true) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() // Mock setOverdueEvents @@ -389,13 +338,23 @@ class SearchRepositoryTest { whenever( eventCollectionRepository.blockingGet(), ) doReturn eventsToReturn.filter { it.status() == EventStatus.OVERDUE } - whenever(eventStatusFilterConnector.eq(EventStatus.SCHEDULE)).thenReturn(eventCollectionRepository) - whenever(eventCollectionRepository.byStatus().eq(EventStatus.SCHEDULE)).thenReturn(eventCollectionRepository) - whenever(eventCollectionRepository.byProgramUid().eq(any())).thenReturn(eventCollectionRepository) - whenever(eventCollectionRepository.orderByDueDate(RepositoryScope.OrderByDirection.DESC)).thenReturn(eventCollectionRepository) + whenever(eventStatusFilterConnector.eq(EventStatus.SCHEDULE)).thenReturn( + eventCollectionRepository, + ) + whenever(eventCollectionRepository.byStatus().eq(EventStatus.SCHEDULE)).thenReturn( + eventCollectionRepository, + ) + whenever(eventCollectionRepository.byProgramUid().eq(any())).thenReturn( + eventCollectionRepository, + ) + whenever(eventCollectionRepository.orderByDueDate(RepositoryScope.OrderByDirection.DESC)).thenReturn( + eventCollectionRepository, + ) whenever(eventCollectionRepository.blockingGet()).thenReturn(eventsToReturn.filter { it.status() == EventStatus.SCHEDULE }) // mock orgUnitName(orgUnitUid) - whenever(d2.organisationUnitModule().organisationUnits()) doReturn orgUnitCollectionRepository + whenever( + d2.organisationUnitModule().organisationUnits(), + ) doReturn orgUnitCollectionRepository whenever( orgUnitCollectionRepository.uid(any()), ) doReturn readOnlyOneObjectRepository @@ -451,6 +410,7 @@ class SearchRepositoryTest { .organisationUnit(orgUnitUid) .program(programUid) .status(status) + .attributeOptionCombo("attributeOptionComboUid") .build() private fun createEvent( @@ -478,6 +438,41 @@ class SearchRepositoryTest { } } + private fun createTrackedEntitySearchItemResult( + uid: String, + overDueDate: Instant?, + ): TrackedEntitySearchItemResult { + return TrackedEntitySearchItemResult( + uid = uid, + created = null, + lastUpdated = null, + createdAtClient = null, + lastUpdatedAtClient = null, + ownerOrgUnit = null, + enrollmentOrgUnit = null, + shouldDisplayOrgUnit = false, + geometry = null, + syncState = SyncState.SYNCED, + aggregatedSyncState = SyncState.SYNCED, + deleted = false, + isOnline = false, + teTypeName = "Person", + type = TrackedEntityTypeDomain( + trackedEntityTypeAttributeDomains = emptyList(), + featureType = GeometryFeatureType.NONE, + ), + header = "Test Header", + overDueDate = overDueDate, + selectedEnrollment = null, + profilePicture = null, + enrolledPrograms = null, + enrollments = null, + relationships = null, + defaultTypeIcon = null, + attributeValues = emptyList(), + ) + } + private fun createMockData(): List = listOf( FieldUiModelImpl( 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 bcc7ee39226..c67c020138d 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -1,23 +1,17 @@ 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 import androidx.compose.material.icons.automirrored.outlined.List import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.outlined.Map -import androidx.lifecycle.MutableLiveData import androidx.paging.PagingData import androidx.paging.testing.asSnapshot import app.cash.turbine.test import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take import kotlinx.coroutines.test.StandardTestDispatcher @@ -29,28 +23,28 @@ import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.network.NetworkUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider -import org.dhis2.data.search.SearchParametersModel -import org.dhis2.form.model.FieldUiModel -import org.dhis2.form.model.FieldUiModelImpl -import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.customintent.CustomIntentResult import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.geometry.mapper.EventsByProgramStage import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.mobile.commons.model.CustomIntentModel import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase import org.dhis2.simprints.SimprintsSearchViewModel +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.TrackerInputAction +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.SearchTrackedEntitiesInput import org.dhis2.usescases.searchTrackEntity.listView.SearchResult.SearchResultType import org.dhis2.utils.customviews.navigationbar.NavigationPage -import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.android.core.trackedentity.TrackedEntityType -import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem +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.assertFalse -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -58,13 +52,10 @@ import org.junit.Test import org.maplibre.geojson.BoundingBox import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.clearInvocations -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq 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 @@ -77,10 +68,7 @@ class SearchTEIViewModelTest { private val initialProgram = "programUid" private val initialQuery = mutableMapOf?>() private val repository: SearchRepository = mock() - private val repositoryKt: SearchRepositoryKt = - mock { - on { searchTrackedEntities(any(), any()) } doReturn flowOf(PagingData.empty()) - } + private val repositoryKt: SearchRepositoryKt = mock() private val pageConfigurator: SearchPageConfigurator = mock() private val mapDataRepository: MapDataRepository = mock() private val networkUtils: NetworkUtils = mock() @@ -88,11 +76,15 @@ class SearchTEIViewModelTest { private val resourceManager: ResourceManager = mock() private val displayNameProvider: DisplayNameProvider = mock() private val filterManager: FilterManager = mock() + private val searchTrackedEntities: SearchTrackedEntities = + mock { + onBlocking { invoke(any()) } doReturn Result.success(flowOf(PagingData.empty())) + } + + private val fetchSearchParameters: FetchSearchParameters = mock() + private val fetchOptionSetOptions: FetchOptionSetOptions = mock() private val simprintsSearchViewModel: SimprintsSearchViewModel = mock() private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase = mock() - private val simprintsBiometricSearchNavigation = MutableSharedFlow(extraBufferCapacity = 1) - private val simprintsBiometricSearch = MutableLiveData(false) - private val simprintsUseLastBiometricsLabel = MutableLiveData(false) @ExperimentalCoroutinesApi private val testingDispatcher = StandardTestDispatcher() @@ -106,12 +98,8 @@ class SearchTEIViewModelTest { whenever(repository.canCreateInProgramWithoutSearch()) doReturn true whenever(repository.getTrackedEntityType()) doReturn testingTrackedEntityType() whenever(repository.filtersApplyOnGlobalSearch()) doReturn true - whenever(filterManager.stateFilters) doReturn emptyList() - whenever(simprintsSearchViewModel.simprintsBiometricSearchNavigation) doReturn - simprintsBiometricSearchNavigation - whenever(simprintsSearchViewModel.isSimprintsBiometricSearch) doReturn simprintsBiometricSearch - whenever(simprintsSearchViewModel.isSimprintsUseLastBiometricsLabel) doReturn - simprintsUseLastBiometricsLabel + whenever(repositoryKt.getExcludeValues()) doReturn HashSet() + whenever(repositoryKt.saveSearchValuesAndGetAllowCache(any(), any())) doReturn true viewModel = SearchTEIViewModel( initialProgram, @@ -132,6 +120,9 @@ class SearchTEIViewModelTest { resourceManager = resourceManager, displayNameProvider = displayNameProvider, filterManager = filterManager, + searchTrackedEntities = searchTrackedEntities, + fetchSearchParameters = fetchSearchParameters, + fetchOptionSetOptions = fetchOptionSetOptions, simprintsSearchViewModel = simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) @@ -206,28 +197,6 @@ class SearchTEIViewModelTest { assertTrue(screenState is SearchList) } - @Test - fun `Should keep Search screen open after Simprints biometric no matches when list screen is refreshed`() { - viewModel.onSimprintsBiometricNoMatches() - - viewModel.setListScreen() - - val screenState = viewModel.screenState.value as SearchList - assertTrue(screenState.searchForm.isOpened) - assertFalse(screenState.searchForm.isForced) - } - - @Test - fun `Should stop keeping Search screen open after follow up search`() { - viewModel.onSimprintsBiometricNoMatches() - - viewModel.onSearch() - testingDispatcher.scheduler.advanceUntilIdle() - - val screenState = viewModel.screenState.value as SearchList - assertFalse(screenState.searchForm.isOpened) - } - @Test fun `Should set previous screen`() { viewModel.setListScreen() @@ -247,74 +216,61 @@ class SearchTEIViewModelTest { @Test fun `Should update query data`() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) - val queryData = viewModel.queryData + val queryData = viewModel.queryDataList assertTrue(queryData.isNotEmpty()) - assertTrue(queryData["testingUid"]?.size == 1) - val values = queryData["testingUid"] - assertTrue(values?.contains("testingValue") == true) + assertTrue(queryData.size == 1) + val data = queryData.first { it.attributeId == "testingUid" } + assertTrue(data.values?.get(0) == "testingValue") } @Test fun `Should update query data when list of values is passed`() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue,testingValue2", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue,testingValue2", ) - val queryData = viewModel.queryData + val queryData = viewModel.queryDataList assertTrue(queryData.isNotEmpty()) - assertTrue(queryData.containsKey("testingUid")) - val values = queryData["testingUid"] - assertTrue(values?.size == 2) - assertTrue(values?.contains("testingValue") == true) - assertTrue(values?.contains("testingValue2") == true) + assertTrue(queryData.any { it.attributeId == "testingUid" }) + val data = queryData.first { it.attributeId == "testingUid" } + assertTrue(data.values?.size == 2) + assertTrue(data.values?.contains("testingValue") == true) + assertTrue(data.values?.contains("testingValue2") == true) } @Test fun `Should update query data when various list of values are passed`() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue,testingValue2", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue,testingValue2", ) - - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid2", - value = "testingValue,testingValue2", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid2", + value = "testingValue,testingValue2", ) - val queryData = viewModel.queryData + val queryData = viewModel.queryDataList assertTrue(queryData.isNotEmpty()) - assertTrue(queryData.containsKey("testingUid")) - val values1 = queryData["testingUid"] - assertTrue(values1?.size == 2) - assertTrue(values1?.contains("testingValue") == true) - assertTrue(values1?.contains("testingValue2") == true) - - assertTrue(queryData.containsKey("testingUid")) - val values2 = queryData["testingUid2"] - assertTrue(values2?.size == 2) - assertTrue(values2?.contains("testingValue") == true) - assertTrue(values2?.contains("testingValue2") == true) + assertTrue(queryData.any { it.attributeId == "testingUid" }) + val data1 = queryData.first { it.attributeId == "testingUid" } + assertTrue(data1.values?.size == 2) + assertTrue(data1.values?.contains("testingValue") == true) + assertTrue(data1.values?.contains("testingValue2") == true) + + assertTrue(queryData.any { it.attributeId == "testingUid2" }) + val data2 = queryData.first { it.attributeId == "testingUid2" } + assertTrue(data2.values?.size == 2) + assertTrue(data2.values?.contains("testingValue") == true) + assertTrue(data2.values?.contains("testingValue2") == true) } @ExperimentalCoroutinesApi @@ -326,12 +282,17 @@ class SearchTEIViewModelTest { viewModel.searchPagingData.take(1).asSnapshot() - verify(repositoryKt).searchTrackedEntities( - SearchParametersModel( - selectedProgram = testingProgram, - queryData = mutableMapOf(), + verify(searchTrackedEntities).invoke( + eq( + SearchTrackedEntitiesInput( + selectedProgram = testingProgram.uid(), + queryDataList = mutableListOf(), + allowCache = true, + excludeValues = emptySet(), + hasStateFilters = false, + isOnline = false, + ), ), - false, ) } @@ -342,21 +303,7 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram) viewModel.searchPagingData.test { awaitItem() - verify(repositoryKt, times(0)).searchTrackedEntities( - SearchParametersModel( - selectedProgram = testingProgram, - queryData = mutableMapOf(), - ), - true, - ) - - verify(repositoryKt, times(0)).searchTrackedEntities( - SearchParametersModel( - selectedProgram = testingProgram, - queryData = mutableMapOf(), - ), - false, - ) + verify(searchTrackedEntities, never()).invoke(any()) } } @@ -367,184 +314,6 @@ class SearchTEIViewModelTest { assertTrue(result.isEmpty()) } - @Test - fun `Should return ordered Simprints biometric search results when available`() = - runTest { - val testingProgram = testingProgram(displayFrontPageList = false) - setCurrentProgram(testingProgram) - setAllowCreateBeforeSearch(false) - whenever(networkUtils.isOnline()) doReturn true - val firstModel = searchTeiModel("first") - val secondModel = searchTeiModel("second") - whenever( - loadSimprintsBiometricSearchResultsUseCase( - any(), - any(), - any(), - any(), - anyOrNull(), - ), - ) doReturn flowOf(PagingData.from(listOf(secondModel, firstModel))) - viewModel.searchParametersUiState = - viewModel.searchParametersUiState.copy( - items = listOf(simprintsBiometricSearchField()), - ) - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "biometric", - value = "guid-1,guid-2", - valueType = ValueType.TEXT, - ), - ) - viewModel.setListScreen() - viewModel.setSearchScreen() - testingDispatcher.scheduler.advanceUntilIdle() - - val result = - async { - viewModel.searchPagingData - .drop(1) - .take(1) - .asSnapshot() - } - viewModel.onSearch() - testingDispatcher.scheduler.advanceUntilIdle() - - assertEquals(listOf(secondModel, firstModel), result.await()) - verify(repositoryKt, times(0)).searchTrackedEntities(any(), any()) - } - - @Test - fun `Should fall back to regular search when Simprints ordering is not available`() = - runTest { - val testingProgram = testingProgram(displayFrontPageList = false) - setCurrentProgram(testingProgram) - setAllowCreateBeforeSearch(false) - whenever(networkUtils.isOnline()) doReturn true - val searchItem = trackedEntitySearchItem("tei-1") - val searchModel = searchTeiModel("regular") - whenever( - loadSimprintsBiometricSearchResultsUseCase( - any(), - any(), - any(), - any(), - anyOrNull(), - ), - ) doReturn null - whenever(repositoryKt.searchTrackedEntities(any(), any())) doReturn - flowOf(PagingData.from(listOf(searchItem))) - whenever(repository.transform(searchItem, testingProgram, false, null)) doReturn searchModel - viewModel.searchParametersUiState = - viewModel.searchParametersUiState.copy( - items = listOf(simprintsBiometricSearchField()), - ) - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "biometric", - value = "guid-1,guid-2", - valueType = ValueType.TEXT, - ), - ) - viewModel.setListScreen() - viewModel.setSearchScreen() - testingDispatcher.scheduler.advanceUntilIdle() - - val result = - async { - viewModel.searchPagingData - .drop(1) - .take(1) - .asSnapshot() - } - viewModel.onSearch() - testingDispatcher.scheduler.advanceUntilIdle() - - assertEquals(listOf(searchModel), result.await()) - verify(repositoryKt).searchTrackedEntities(any(), any()) - } - - @Test - fun `Should return Simprints possible duplicates search results when query contains multiple GUIDs`() = - runTest { - val testingProgram = testingProgram(displayFrontPageList = false) - setCurrentProgram(testingProgram) - setAllowCreateBeforeSearch(false) - whenever(networkUtils.isOnline()) doReturn true - viewModel.setSimprintsPossibleDuplicatesSearch(true) - - viewModel.queryData["biometric"] = listOf("guid-1", "guid-2") - - val searchItem1 = trackedEntitySearchItem("tei-1") - val searchItem2 = trackedEntitySearchItem("tei-2") - whenever(repositoryKt.searchTrackedEntitiesImmediate(any(), any())).thenAnswer { - val params = it.arguments[0] as SearchParametersModel - when (params.queryData?.get("biometric")?.firstOrNull()) { - "guid-1" -> listOf(searchItem1) - "guid-2" -> listOf(searchItem2) - else -> emptyList() - } - } - - val model1 = searchTeiModel("model-1", teiUid = "tei-1") - val model2 = searchTeiModel("model-2", teiUid = "tei-2") - whenever(repository.transform(searchItem1, testingProgram, false, null)) doReturn model1 - whenever(repository.transform(searchItem2, testingProgram, false, null)) doReturn model2 - - viewModel.setListScreen() - viewModel.setSearchScreen() - testingDispatcher.scheduler.advanceUntilIdle() - - val result = - async { - viewModel.searchPagingData - .drop(1) - .take(1) - .asSnapshot() - } - viewModel.onSearch() - testingDispatcher.scheduler.advanceUntilIdle() - - assertEquals(listOf(model1, model2), result.await()) - verify(repositoryKt, times(2)).searchTrackedEntitiesImmediate(any(), any()) - verify(repositoryKt, times(0)).searchTrackedEntities(any(), any()) - } - - @Test - fun `Should auto finish Simprints possible duplicates search when there are no DHIS2 matches`() = - runTest { - val testingProgram = testingProgram(displayFrontPageList = false, minAttributesToSearch = 2) - setCurrentProgram(testingProgram) - setAllowCreateBeforeSearch(false) - whenever(networkUtils.isOnline()) doReturn true - viewModel.setSimprintsPossibleDuplicatesSearch(true) - - viewModel.queryData["biometric"] = listOf("guid-1", "guid-2") - whenever(repositoryKt.searchTrackedEntitiesImmediate(any(), any())) doReturn emptyList() - - viewModel.setListScreen() - viewModel.setSearchScreen() - testingDispatcher.scheduler.advanceUntilIdle() - - viewModel.simprintsNavigation.test { - val snapshot = - async { - viewModel.searchPagingData - .drop(1) - .take(1) - .asSnapshot() - } - viewModel.onSearch() - testingDispatcher.scheduler.advanceUntilIdle() - - assertTrue(awaitItem() is SimprintsNavigationAction.AutoEnrollLastBiometricsFromSimprintsPossibleDuplicates) - snapshot.await() - cancelAndIgnoreRemainingEvents() - } - verify(simprintsSearchViewModel, never()).markPendingEnrollmentFromSimprintsPossibleDuplicates() - verify(repositoryKt, times(0)).searchTrackedEntities(any(), any()) - } - @ExperimentalCoroutinesApi @Test fun `Should fetch map results`() { @@ -564,7 +333,7 @@ class SearchTEIViewModelTest { whenever( mapDataRepository.getTrackerMapData( testingProgram(), - viewModel.queryData, + viewModel.queryDataAsMap(), ), ) doReturn trackerMapData @@ -581,6 +350,7 @@ class SearchTEIViewModelTest { fun `Should use callback to perform min attributes warning`() = runTest { setCurrentProgram(testingProgram(displayFrontPageList = false)) + setAllowCreateBeforeSearch(false) viewModel.onSearch() viewModel.searchParametersUiState.shouldShowMinAttributeWarning.test { assertTrue(awaitItem()) @@ -592,13 +362,11 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram()) viewModel.setListScreen() viewModel.setSearchScreen() - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) + viewModel.onSearch() assertTrue(viewModel.refreshData.value != null) @@ -610,7 +378,7 @@ class SearchTEIViewModelTest { whenever( mapDataRepository.getTrackerMapData( testingProgram(), - viewModel.queryData, + viewModel.queryDataAsMap(), ), ) doReturn TrackerMapData( @@ -628,13 +396,11 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram()) viewModel.setMapScreen() viewModel.setSearchScreen() - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) + viewModel.onSearch() testingDispatcher.scheduler.advanceUntilIdle() @@ -642,425 +408,14 @@ class SearchTEIViewModelTest { assertTrue(viewModel.refreshData.value != null) verify(mapDataRepository).getTrackerMapData( testingProgram(), - viewModel.queryData, + viewModel.queryDataAsMap(), ) } @Test fun `Should filter query data for new program`() { viewModel.queryDataByProgram("programUid") - verify(repository).filterQueryForProgram(viewModel.queryData, "programUid") - } - - @Test - fun `Should emit launch Simprints confirm identity navigation when opening dashboard requires callout`() = - runTest { - val intent: Intent = mock() - whenever( - simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any(), any()), - ) doReturn SimprintsSearchViewModel.DashboardAction.LaunchConfirmIdentity(intent) - - viewModel.simprintsNavigation.test { - viewModel.onOpenDashboardRequested("teiUid", "programUid", "enrollmentUid") - testingDispatcher.scheduler.advanceUntilIdle() - - val action = awaitItem() - assertTrue(action is SimprintsNavigationAction.LaunchConfirmIdentity) - assertEquals(intent, (action as SimprintsNavigationAction.LaunchConfirmIdentity).intent) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `Should emit open dashboard navigation when Simprints dashboard request resolves directly`() = - runTest { - whenever( - simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any(), any()), - ) doReturn - SimprintsSearchViewModel.DashboardAction.OpenDashboard( - SimprintsSearchViewModel.PendingDashboardNavigation( - teiUid = "teiUid", - programUid = "programUid", - enrollmentUid = "enrollmentUid", - ), - ) - - viewModel.simprintsNavigation.test { - viewModel.onOpenDashboardRequested("teiUid", "programUid", "enrollmentUid") - testingDispatcher.scheduler.advanceUntilIdle() - - val action = awaitItem() - assertTrue(action is SimprintsNavigationAction.OpenDashboard) - action as SimprintsNavigationAction.OpenDashboard - assertEquals("teiUid", action.teiUid) - assertEquals("programUid", action.programUid) - assertEquals("enrollmentUid", action.enrollmentUid) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `Should emit open dashboard navigation after Simprints confirm identity succeeds`() = - runTest { - whenever(simprintsSearchViewModel.onConfirmIdentityResult(RESULT_OK)) doReturn - SimprintsSearchViewModel.PendingDashboardNavigation( - teiUid = "teiUid", - programUid = "programUid", - enrollmentUid = "enrollmentUid", - ) - - viewModel.simprintsNavigation.test { - viewModel.onConfirmIdentityResult(RESULT_OK) - testingDispatcher.scheduler.advanceUntilIdle() - - val action = awaitItem() - assertTrue(action is SimprintsNavigationAction.OpenDashboard) - action as SimprintsNavigationAction.OpenDashboard - assertEquals("teiUid", action.teiUid) - assertEquals("programUid", action.programUid) - assertEquals("enrollmentUid", action.enrollmentUid) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `Should emit error message when Simprints confirm identity setup fails`() = - runTest { - whenever( - simprintsSearchViewModel.onDashboardRequested(any(), any(), any(), any(), any()), - ).thenThrow(RuntimeException()) - whenever(resourceManager.getString(R.string.custom_intent_error)) doReturn "Custom intent error" - - viewModel.simprintsNavigation.test { - viewModel.onOpenDashboardRequested("teiUid", "programUid", "enrollmentUid") - testingDispatcher.scheduler.advanceUntilIdle() - - val action = awaitItem() - assertTrue(action is SimprintsNavigationAction.ShowMessage) - assertEquals("Custom intent error", (action as SimprintsNavigationAction.ShowMessage).message) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `Should refresh Simprints last biometrics label state`() { - simprintsUseLastBiometricsLabel.value = true - clearInvocations(simprintsSearchViewModel) - - viewModel.refreshSimprintsUiState() - - assertTrue(viewModel.isSimprintsUseLastBiometricsLabel.value == true) - verify(simprintsSearchViewModel).refreshSimprintsUiState(any()) - } - - @Test - fun `Should delegate enrollment query preparation to SimprintsSearchViewModel`() { - val queryData = mapOf("name" to listOf("Name")) - whenever(simprintsSearchViewModel.prepareEnrollmentQueryData(any(), eq(queryData))) doReturn - hashMapOf("name" to listOf("Name")) - - val result = viewModel.prepareEnrollmentQueryData(queryData) - - assertEquals(hashMapOf("name" to listOf("Name")), result) - verify(simprintsSearchViewModel).prepareEnrollmentQueryData(any(), eq(queryData)) - } - - @Test - fun `Should delegate Simprints biometric identification result`() { - viewModel.onSimprintsBiometricIdentificationResult( - uid = "biometric", - value = "guid-1", - hasAutoOpenEligibleSimprintsIdentification = true, - ) - - verify(simprintsSearchViewModel).onSimprintsBiometricIdentificationResult( - uid = "biometric", - value = "guid-1", - hasAutoOpenEligibleSimprintsIdentification = true, - ) - } - - @Test - fun `Should delegate Simprints confirm identity launch failures`() { - viewModel.onConfirmIdentityLaunchFailed() - - verify(simprintsSearchViewModel).onConfirmIdentityLaunchFailed() - } - - @Test - fun `Should rehydrate initial query and enable search when search parameters are fetched`() = - runTest { - whenever(repositoryKt.searchParameters(initialProgram, "teiTypeUid")) doReturn - listOf(simprintsBiometricSearchField()) - val viewModel = - SearchTEIViewModel( - initialProgramUid = initialProgram, - initialQuery = mutableMapOf("biometric" to listOf("guid-1")), - searchRepository = repository, - searchRepositoryKt = repositoryKt, - searchNavPageConfigurator = pageConfigurator, - mapDataRepository = mapDataRepository, - networkUtils = networkUtils, - dispatchers = - object : DispatcherProvider { - override fun io(): CoroutineDispatcher = testingDispatcher - - override fun computation(): CoroutineDispatcher = testingDispatcher - - override fun ui(): CoroutineDispatcher = testingDispatcher - }, - mapStyleConfig = mapStyleConfiguration, - resourceManager = resourceManager, - displayNameProvider = displayNameProvider, - filterManager = filterManager, - simprintsSearchViewModel = simprintsSearchViewModel, - loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, - ) - - viewModel.fetchSearchParameters(initialProgram, "teiTypeUid") - testingDispatcher.scheduler.advanceUntilIdle() - - val fieldUiModel = viewModel.searchParametersUiState.items.single() - - assertEquals("guid-1", fieldUiModel.value) - assertTrue(viewModel.searchParametersUiState.clearSearchEnabled) - assertTrue(viewModel.searchParametersUiState.searchEnabled) - assertTrue(viewModel.searchParametersUiState.searchedItems.isNotEmpty()) - assertTrue(viewModel.screenState.value is SearchList) - } - - @Test - fun `Should preserve entered search parameter values when parameters are refetched`() = - runTest { - whenever(repositoryKt.searchParameters(initialProgram, "teiTypeUid")) doReturn - listOf(simprintsBiometricSearchField()) - viewModel.searchParametersUiState = - viewModel.searchParametersUiState.copy( - items = - listOf( - simprintsBiometricSearchField().copy( - value = "guid-1", - displayName = "guid-1", - ), - ), - ) - - viewModel.fetchSearchParameters(initialProgram, "teiTypeUid") - testingDispatcher.scheduler.advanceUntilIdle() - - val fieldUiModel = viewModel.searchParametersUiState.items.single() - - assertEquals("guid-1", fieldUiModel.value) - assertEquals("guid-1", fieldUiModel.displayName) - } - - @Test - fun `Should forward Simprints biometric search navigation flow`() = - runTest { - viewModel.simprintsBiometricSearchNavigation.test { - simprintsBiometricSearchNavigation.emit(Unit) - - awaitItem() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `Should switch to list screen and clear fetched results for Simprints biometric search navigation`() { - viewModel.onNavigationPageChanged(NavigationPage.MAP_VIEW) - viewModel.setMapScreen() - viewModel.queryData["testingUid"] = listOf("testingValue") - clearInvocations(repository) - - viewModel.onSimprintsBiometricSearchNavigation() - testingDispatcher.scheduler.advanceUntilIdle() - - assertEquals(NavigationPage.LIST_VIEW, viewModel.navigationBarUIState.value.selectedItem) - assertTrue(viewModel.screenState.value?.screenState == SearchScreenState.LIST) - verify(repository).clearFetchedList() - } - - @Test - fun `Should open dashboard when Simprints save resolves direct navigation`() = - runTest { - whenever( - simprintsSearchViewModel.onSimprintsParameterSaved( - any(), - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn - SimprintsSearchViewModel.PendingDashboardNavigation( - teiUid = "teiUid", - programUid = "matchedProgramUid", - enrollmentUid = "enrollmentUid", - isOnline = true, - ) - viewModel.onNavigationPageChanged(NavigationPage.MAP_VIEW) - viewModel.setMapScreen() - viewModel.queryData["existingQuery"] = listOf("value") - - viewModel.searchParametersUiState = - viewModel.searchParametersUiState.copy( - items = - listOf( - simprintsBiometricSearchField().copy( - value = "guid-1", - displayName = "guid-1", - ), - ), - ) - - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "biometric", - value = "guid-1", - valueType = ValueType.TEXT, - ), - ) - testingDispatcher.scheduler.advanceUntilIdle() - - val action = viewModel.legacyInteraction.value - assertTrue(action is LegacyInteraction.OnTeiClick) - action as LegacyInteraction.OnTeiClick - assertEquals("teiUid", action.teiUid) - assertEquals("enrollmentUid", action.enrollmentUid) - assertTrue(action.online) - assertEquals(NavigationPage.LIST_VIEW, viewModel.navigationBarUIState.value.selectedItem) - assertEquals(SearchScreenState.LIST, viewModel.screenState.value?.screenState) - assertTrue(viewModel.queryData.isEmpty()) - assertTrue(!viewModel.searchParametersUiState.clearSearchEnabled) - assertTrue(viewModel.searchParametersUiState.searchedItems.isEmpty()) - val searchItem = viewModel.searchParametersUiState.items.single() - assertNull(searchItem.value) - assertNull(searchItem.displayName) - } - - @Test - fun `Should return Simprints biometric global results when available`() = - runTest { - setCurrentProgram(testingProgram(displayFrontPageList = false)) - setAllowCreateBeforeSearch(false) - whenever(networkUtils.isOnline()) doReturn true - val globalModel = searchTeiModel("global") - whenever( - loadSimprintsBiometricSearchResultsUseCase( - any(), - any(), - any(), - any(), - anyOrNull(), - ), - ) doReturn flowOf(PagingData.from(listOf(globalModel))) - viewModel.searchParametersUiState = - viewModel.searchParametersUiState.copy( - items = listOf(simprintsBiometricSearchField()), - ) - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "biometric", - value = "guid-1,guid-2", - valueType = ValueType.TEXT, - ), - ) - viewModel.setListScreen() - viewModel.setSearchScreen() - viewModel.onSearch() - testingDispatcher.scheduler.advanceUntilIdle() - - val result = viewModel.fetchGlobalResults()?.asSnapshot() - - assertEquals(listOf(globalModel), result) - } - - @Test - fun `Should delegate Simprints biometric save handling to SimprintsSearchViewModel`() = - runTest { - viewModel.searchParametersUiState = - viewModel.searchParametersUiState.copy( - items = listOf(simprintsBiometricSearchField()), - ) - - whenever( - simprintsSearchViewModel.onSimprintsParameterSaved( - any(), - anyOrNull(), - any(), - any(), - any(), - ), - ) doAnswer { - simprintsBiometricSearchNavigation.tryEmit(Unit) - null - } - - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "biometric", - value = "guid-1", - valueType = ValueType.TEXT, - ), - ) - testingDispatcher.scheduler.advanceUntilIdle() - - verify(simprintsSearchViewModel).onSimprintsParameterSaved( - eq("biometric"), - eq("guid-1"), - any(), - any(), - any(), - ) - } - - @Test - fun `Should clear only Simprints biometric query data when requested to do so`() { - val biometricField = - simprintsBiometricSearchField().copy( - value = "guid-1", - displayName = "guid-1", - ) - val textField = - FieldUiModelImpl( - uid = "name", - label = "Name", - value = "Name", - displayName = "Name", - autocompleteList = emptyList(), - optionSetConfiguration = null, - valueType = ValueType.TEXT, - ) - viewModel.queryData["biometric"] = listOf("guid-1") - viewModel.queryData["name"] = listOf("Name") - viewModel.searchParametersUiState = - viewModel.searchParametersUiState.copy( - items = listOf(biometricField, textField), - ) - whenever( - simprintsSearchViewModel.clearSimprintsBiometricQueryData( - any(), - any(), - ), - ) doAnswer { invocation -> - @Suppress("UNCHECKED_CAST") - val queryData = invocation.arguments[1] as MutableMap?> - queryData.remove("biometric") - listOf( - biometricField.copy(value = null, displayName = null), - textField, - ) - } - - viewModel.clearSimprintsBiometricQueryData() - - assertEquals(mapOf("name" to listOf("Name")), viewModel.queryData) - val biometricFieldUiModel = viewModel.searchParametersUiState.items.first() - - assertEquals(null, biometricFieldUiModel.value) - assertEquals("Name", viewModel.searchParametersUiState.items[1].value) - assertEquals(mapOf("name" to "Name"), viewModel.searchParametersUiState.searchedItems) - verify(simprintsSearchViewModel).clearSimprintsBiometricQueryData(any(), any()) + verify(repository).filterQueryForProgram(viewModel.queryDataAsMap(), "programUid") } @Test @@ -1152,8 +507,11 @@ class SearchTEIViewModelTest { setCurrentProgram(testingProgram(maxTeiCountToReturn = 1)) setAllowCreateBeforeSearch(false) whenever( - repository.filterQueryForProgram(viewModel.queryData, null), - ) doReturn mapOf("field" to listOf("value")) + repository.filterQueryForProgram( + any(), + anyOrNull(), + ), + ) doReturn mapOf("testingUid" to listOf("testingValue")) performSearch() viewModel.onDataLoaded(1) @@ -1168,7 +526,7 @@ class SearchTEIViewModelTest { fun `Should return unable to search outside result for search`() { setCurrentProgram(testingProgram(maxTeiCountToReturn = 1)) setAllowCreateBeforeSearch(false) - whenever(repository.filterQueryForProgram(viewModel.queryData, null)) doReturn mapOf() + whenever(repository.filterQueryForProgram(viewModel.queryDataAsMap(), null)) doReturn mapOf() whenever(repository.trackedEntityTypeFields()) doReturn listOf("Field_1", "Field_2") performSearch() @@ -1359,7 +717,8 @@ class SearchTEIViewModelTest { @Test fun `should return user-friendly names on search parameters fields`() { - viewModel.searchParametersUiState = viewModel.searchParametersUiState.copy(items = getFieldUIModels()) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = getTrackerInputModels()) val expectedMap = mapOf( "uid1" to "Friendly OrgUnit Name", @@ -1367,7 +726,7 @@ class SearchTEIViewModelTest { "uid3" to "21/02/2024", "uid4" to "21/02/2024 - 01:00", "uid5" to "Boolean: false", - "uid6" to "Yes Only", + "uid6" to "Yes Only: true", "uid7" to "Text value", "uid9" to "18%", ) @@ -1379,18 +738,19 @@ class SearchTEIViewModelTest { @Test fun `should clear uiState when clearing data`() { - viewModel.searchParametersUiState = viewModel.searchParametersUiState.copy(items = getFieldUIModels()) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = getTrackerInputModels()) performSearch() viewModel.clearQueryData() - assert(viewModel.queryData.isEmpty()) + assert(viewModel.queryDataList.isEmpty()) assert(viewModel.searchParametersUiState.items.all { it.value == null }) assert(viewModel.searchParametersUiState.searchedItems.isEmpty()) - verify(simprintsSearchViewModel).clearPendingSession() } @Test fun `should return date without format`() { - viewModel.searchParametersUiState = viewModel.searchParametersUiState.copy(items = getMalformedDateFieldUIModels()) + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = getMalformedDateFieldUIModels()) val expectedMap = mapOf( "uid1" to "04", @@ -1435,6 +795,9 @@ class SearchTEIViewModelTest { resourceManager = resourceManager, displayNameProvider = displayNameProvider, filterManager = filterManager, + searchTrackedEntities = searchTrackedEntities, + fetchSearchParameters = fetchSearchParameters, + fetchOptionSetOptions = fetchOptionSetOptions, simprintsSearchViewModel = simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) @@ -1483,6 +846,9 @@ class SearchTEIViewModelTest { }, displayNameProvider = displayNameProvider, filterManager = filterManager, + searchTrackedEntities = searchTrackedEntities, + fetchSearchParameters = fetchSearchParameters, + fetchOptionSetOptions = fetchOptionSetOptions, simprintsSearchViewModel = simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, ) @@ -1510,146 +876,295 @@ class SearchTEIViewModelTest { ) } - private fun getMalformedDateFieldUIModels(): List = + @Test + fun `should send launch custom intent action`() = + runTest { + val customIntentModel: CustomIntentModel = mock() + whenever(repositoryKt.getCustomIntent(any())) doReturn customIntentModel + viewModel.searchActions.test { + viewModel.launchCustomIntent("fieldUid", "customIntentUid") + assertTrue(awaitItem() is TrackerInputAction.LaunchCustomIntent) + } + } + + @Test + fun `should set error if custom intent result is error`() { + whenever(resourceManager.getString(R.string.custom_intent_error)) doReturn "Custom intent error message" + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = customIntentFieldUIModels()) + viewModel.handleCustomIntentResult( + CustomIntentResult.Error("fieldUid"), + ) + assertTrue( + viewModel.searchParametersUiState.items + .first() + .error != null, + ) + } + + @Test + fun `should update values if custom intent result is successful`() = + runTest { + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy(items = customIntentFieldUIModels()) + viewModel.handleCustomIntentResult( + CustomIntentResult.Success("fieldUid", "customValue", null, null), + ) + assertTrue( + viewModel.searchParametersUiState.items + .first() + .error == null, + ) + assertTrue( + viewModel.searchParametersUiState.items + .first() + .value == "customValue", + ) + } + + private fun customIntentFieldUIModels() = listOf( - FieldUiModelImpl( + TrackerInputUiState( + uid = "fieldUid", + label = "CustomIntent", + value = null, + focused = false, + valueType = TrackerInputType.ORGANISATION_UNIT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, + optionSetConfiguration = null, + customIntentUid = "customIntentUid", + displayName = "Friendly OrgUnit Name", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, + ), + ) + + private fun getMalformedDateFieldUIModels(): List = + listOf( + TrackerInputUiState( uid = "uid1", label = "Date", value = "04", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.DATE, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.DATE, + customIntentUid = null, + displayName = "Friendly OrgUnit Name", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), ) - private fun getFieldUIModels(): List = + private fun getTrackerInputModels(): List = listOf( - FieldUiModelImpl( + TrackerInputUiState( uid = "uid1", label = "Org Unit", value = "orgUnitUid", - displayName = "Friendly OrgUnit Name", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.ORGANISATION_UNIT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.ORGANISATION_UNIT, + customIntentUid = null, + displayName = "Friendly OrgUnit Name", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid2", label = "Gender", value = "M", - displayName = "Male", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.MULTI_SELECTION, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.MULTI_TEXT, + customIntentUid = null, + displayName = "Male", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid3", label = "Date", value = "2024-02-21", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.DATE, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.DATE, + customIntentUid = null, + displayName = "21/02/2024", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid4", label = "Date and Time", value = "2024-02-21T01:00", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.DATE_TIME, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.DATETIME, + customIntentUid = null, + displayName = "21/02/2024 - 01:00", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid5", label = "Boolean", value = "false", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.HORIZONTAL_CHECKBOXES, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.BOOLEAN, + customIntentUid = null, + displayName = "Boolean: false", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid6", label = "Yes Only", value = "true", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.YES_ONLY_SWITCH, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.TRUE_ONLY, + customIntentUid = null, + displayName = "Yes Only; true", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid7", label = "Text", value = "Text value", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.TEXT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.TEXT, + customIntentUid = null, + displayName = "Text value", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid8", label = "Other field", value = null, - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.TEXT, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.TEXT, + customIntentUid = null, + displayName = "Male", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), - FieldUiModelImpl( + TrackerInputUiState( uid = "uid9", label = "Percentage", value = "18", - autocompleteList = emptyList(), + focused = false, + valueType = TrackerInputType.PERCENTAGE, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = Orientation.HORIZONTAL, optionSetConfiguration = null, - valueType = ValueType.PERCENTAGE, + customIntentUid = null, + displayName = "18%", + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = null, + optionSet = null, + error = null, + warning = null, ), ) - private fun simprintsBiometricSearchField() = - FieldUiModelImpl( - uid = "biometric", - label = "Biometric", - value = null, - autocompleteList = emptyList(), - optionSetConfiguration = null, - valueType = ValueType.TEXT, - customIntent = simprintsIdentifyIntent(), - ) - - private fun simprintsIdentifyIntent() = - CustomIntentModel( - uid = "identify", - name = "Identify", - packageName = "com.simprints.id.IDENTIFY", - customIntentRequest = emptyList(), - customIntentResponse = emptyList(), - ) - - private fun trackedEntitySearchItem(uid: String): TrackedEntitySearchItem = - TrackedEntitySearchItem( - uid = uid, - created = null, - lastUpdated = null, - createdAtClient = null, - lastUpdatedAtClient = null, - organisationUnit = "orgUnit", - geometry = null, - syncState = null, - aggregatedSyncState = null, - deleted = false, - type = TrackedEntityType.builder().uid("teiType").build(), - header = uid, - ) - - private fun searchTeiModel( - header: String, - teiUid: String = header, - ) = SearchTeiModel().apply { - setHeader(header) - tei = - TrackedEntityInstance - .builder() - .uid(teiUid) - .trackedEntityType("teiType") - .organisationUnit("orgUnit") - .build() - } - private fun testingProgram( displayFrontPageList: Boolean = true, minAttributesToSearch: Int = 1, @@ -1661,6 +1176,8 @@ class SearchTEIViewModelTest { .displayFrontPageList(displayFrontPageList) .minAttributesRequiredToSearch(minAttributesToSearch) .trackedEntityType(TrackedEntityType.builder().uid("teTypeUid").build()) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .apply { maxTeiCountToReturn?.let { maxTeiCountToReturn(maxTeiCountToReturn) @@ -1676,12 +1193,9 @@ class SearchTEIViewModelTest { @ExperimentalCoroutinesApi private fun performSearch() { - viewModel.onParameterIntent( - FormIntent.OnSave( - uid = "testingUid", - value = "testingValue", - valueType = ValueType.TEXT, - ), + viewModel.onValueChange( + fieldUid = "testingUid", + value = "testingValue", ) viewModel.setListScreen() viewModel.setSearchScreen() diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt index aaac8009b41..449ecab97c9 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEPresenterTest.kt @@ -10,9 +10,10 @@ import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager import org.dhis2.data.schedulers.TestSchedulerProvider -import org.dhis2.data.service.SyncStatusController +import org.dhis2.mobile.sync.domain.SyncStatusController import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityType import org.junit.After @@ -58,6 +59,8 @@ class SearchTEPresenterTest { .uid(initialProgram) .displayFrontPageList(true) .minAttributesRequiredToSearch(0) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( @@ -98,6 +101,8 @@ class SearchTEPresenterTest { .uid("uid") .displayFrontPageList(true) .minAttributesRequiredToSearch(1) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() presenter.setProgramForTesting(program) @@ -115,6 +120,8 @@ class SearchTEPresenterTest { .uid("uid") .displayFrontPageList(true) .minAttributesRequiredToSearch(1) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() val newSelectedProgram = @@ -123,6 +130,8 @@ class SearchTEPresenterTest { .uid("uid2") .displayFrontPageList(true) .minAttributesRequiredToSearch(1) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( 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 cd9dd6ffdfd..787a71b48d3 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 @@ -6,13 +6,20 @@ 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.mobile.commons.extensions.toJavaDate +import org.dhis2.mobile.commons.extensions.toKtxInstant +import org.dhis2.tracker.input.model.TrackerInputType +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.dhis2.tracker.search.model.GeometryFeatureType +import org.dhis2.tracker.search.model.SyncState +import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.dhis2.tracker.search.model.TrackedEntityTypeAttributeDomain +import org.dhis2.tracker.search.model.TrackedEntityTypeDomain import org.dhis2.usescases.searchTrackEntity.SearchTeiModel -import org.hisp.dhis.android.core.common.State -import org.hisp.dhis.android.core.enrollment.Enrollment -import org.hisp.dhis.android.core.enrollment.EnrollmentStatus -import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue -import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -22,6 +29,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.util.Calendar import java.util.Date +import kotlin.time.Instant class TEICardMapperTest { private val context: Context = mock() @@ -29,6 +37,24 @@ class TEICardMapperTest { private lateinit var mapper: TEICardMapper + private val enrollmentUid = "EnrollmentUid" + private val programUid = "programUid" + private val enrollmentOrgUnit = "OrgUnit" + private val teiUid = "TEIUid" + private val selectedEnrollment = DomainEnrollment( + uid = enrollmentUid, + orgUnit = enrollmentOrgUnit, + program = programUid, + enrollmentDate = Instant.parse("2020-01-01T00:00:00.00Z"), + incidentDate = Instant.parse("2020-01-01T00:00:00.00Z"), + completedDate = Instant.parse("2020-01-01T00:00:00.00Z"), + followUp = true, + status = EnrollmentStatus.COMPLETED, + trackedEntityInstance = teiUid, + ) + private val enrollments = listOf( + selectedEnrollment + ) @Before fun setUp() { whenever(context.getString(R.string.interval_now)) doReturn "now" @@ -49,7 +75,7 @@ class TEICardMapperTest { @Test fun shouldReturnCardFull() { - val model = createFakeModel(isOverdue = true) + val model = createFakeModel() val result = mapper.map( @@ -60,24 +86,25 @@ class TEICardMapperTest { ) assertEquals(result.title, model.header) - assertEquals(result.lastUpdated, model.tei.lastUpdated().toDateSpan(context)) - assertEquals(result.additionalInfo[0].value, model.attributeValues["Name"]?.value()) - assertEquals(result.additionalInfo[1].value, model.enrolledOrgUnit) + assertEquals(result.lastUpdated, model.tei.lastUpdated?.toJavaDate().toDateSpan(context)) + assertEquals(result.additionalInfo[0].value, model.attributeValues["Name"]?.value) + assertEquals(result.additionalInfo[1].value, model.tei.ownerOrgUnit) + assertEquals(result.additionalInfo[2].value, model.tei.enrollmentOrgUnit) assertEquals( - result.additionalInfo[2].value, - model.programInfo.map { it.name() }.joinToString(", "), + result.additionalInfo[3].value, + model.tei.enrolledPrograms?.joinToString(", ") { it.displayName }, ) assertEquals( - result.additionalInfo[3].value, + result.additionalInfo[4].value, "Enrollment Completed", ) assertEquals( - result.additionalInfo[4].value, - model.overdueDate.toOverdueOrScheduledUiText(resourceManager), + result.additionalInfo[5].value, + model.tei.overDueDate?.toJavaDate().toOverdueOrScheduledUiText(resourceManager), ) assertEquals( - result.additionalInfo[5].value, + result.additionalInfo[6].value, resourceManager.getString(R.string.marked_follow_up), ) } @@ -89,7 +116,7 @@ class TEICardMapperTest { whenever(resourceManager.getPlural(any(), any(), any())) doReturn "2 days" - val model = createFakeModel(overdueDate.time, true) + val model = createFakeModel(overdueDate.time) val result = mapper.map( @@ -99,72 +126,82 @@ class TEICardMapperTest { onImageClick = {}, ) assertEquals( - result.additionalInfo[4].value, - model.overdueDate.toOverdueOrScheduledUiText(resourceManager), + result.additionalInfo[5].value, + model.tei.overDueDate?.toJavaDate().toOverdueOrScheduledUiText(resourceManager), ) } private fun createFakeModel( currentDate: Date = Date(), - isOverdue: Boolean = false, ): SearchTeiModel { - val attributeValues = LinkedHashMap() - attributeValues["Name"] = - TrackedEntityAttributeValue - .builder() - .value("Peter") - .build() - - val model = - SearchTeiModel().apply { - header = "TEI header" - tei = - TrackedEntityInstance - .builder() - .uid("TEIUid") - .lastUpdated(currentDate) - .organisationUnit("OrgUnit") - .aggregatedSyncState(State.SYNCED) - .build() - enrolledOrgUnit = "OrgUnit" - displayOrgUnit = true - setCurrentEnrollment( - Enrollment - .builder() - .uid("EnrollmentUid") - .program("programUid") - .status(EnrollmentStatus.COMPLETED) - .build(), - ) - setAttributeValues(attributeValues) - - addProgramInfo( - Program - .builder() - .uid("Program1Uid") - .displayName("Program 1") - .build(), - null, - ) - addProgramInfo( - Program - .builder() - .uid("Program2Uid") - .displayName("Program 2") - .build(), - null, + val attributeValues = LinkedHashMap() + val attribute = TrackedEntitySearchItemAttributeDomain( + attribute = "attrUid1", + displayName = "Name", + displayFormName = "Name", + value = "Peter", + valueType = TrackerInputType.TEXT, + displayInList = true, + optionSet = null + ) + attributeValues["Name"] = attribute + + val tei = TrackedEntitySearchItemResult( + uid = "teiUid", + created = Instant.parse("2020-01-01T00:00:00.00Z"), + lastUpdated = Instant.parse("2020-01-01T00:00:00.00Z"), + createdAtClient = Instant.parse("2020-01-01T00:00:00.00Z"), + lastUpdatedAtClient = Instant.parse("2020-01-01T00:00:00.00Z"), + ownerOrgUnit = "ownerOrgUnit", + enrollmentOrgUnit = "enrollmentOrgUnit", + shouldDisplayOrgUnit = true, + geometry = null, + syncState = SyncState.SYNCED, + aggregatedSyncState = SyncState.SYNCED, + deleted = false, + isOnline = true, + teTypeName = "teTypeName", + type = TrackedEntityTypeDomain( + trackedEntityTypeAttributeDomains = listOf(TrackedEntityTypeAttributeDomain( + trackedEntityTypeUid= "trackedEntityTypeUid", + trackedEntityAttributeUid = "trackedEntityAttributeUid", + displayInList= true, + mandatory = false, + searchable = true, + sortOrder = 1, + )), + featureType = GeometryFeatureType.POINT, + ), + header = "TEI header", + overDueDate = currentDate.toKtxInstant(), + selectedEnrollment = selectedEnrollment, + profilePicture = null, + enrolledPrograms = listOf( + DomainProgram( + uid = "Program1Uid", + displayName = "Program 1", + style = DomainObjectStyle( + icon = "iconUid", + color = "colorUid" ) - overdueDate = currentDate - isHasOverdue = isOverdue - - addEnrollment( - Enrollment - .builder() - .uid("EnrollmentUid") - .followUp(true) - .build(), + ), + DomainProgram( + uid = "Program2Uid", + displayName = "Program 2", + style = DomainObjectStyle( + icon = "iconUid2", + color = "colorUid2" ) - } - return model + ),), + enrollments = enrollments, + relationships = null, + defaultTypeIcon = null, + attributeValues = listOf(attribute), + ) + val searchTeiModel = SearchTeiModel() + searchTeiModel.tei = tei + searchTeiModel.attributeValues = attributeValues + + return searchTeiModel } } diff --git a/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt b/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt index 76e9340f115..ede8a776864 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/DeleteUserDataTest.kt @@ -1,28 +1,39 @@ package org.dhis2.usescases.settings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.service.workManager.WorkManagerController import org.junit.Before import org.junit.Test +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify +@OptIn(ExperimentalCoroutinesApi::class) class DeleteUserDataTest { private lateinit var deleteUserData: DeleteUserData private val workManagerController: WorkManagerController = mock() private val filterManager: FilterManager = mock() private val preferencesProvider: PreferenceProvider = mock() + private val testingDispatcher = UnconfinedTestDispatcher() + private val dispatcherProvider: DispatcherProvider = mock { + on { io() } doReturn testingDispatcher + on { ui() } doReturn testingDispatcher + } @Before fun setup() { deleteUserData = - DeleteUserData(workManagerController, filterManager, preferencesProvider) + DeleteUserData(workManagerController, filterManager, preferencesProvider, dispatcherProvider) } @Test - fun `Should delete user data`() { + fun `Should delete user data`() = runTest { deleteUserData.wipeCacheAndPreferences(null) verify(workManagerController).cancelAllWork() diff --git a/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt index 4f241f06d06..fd3c9394c0f 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/SettingsRepositoryTest.kt @@ -19,6 +19,7 @@ import org.dhis2.commons.prefs.Preference.Companion.TIME_META import org.dhis2.commons.prefs.Preference.Companion.TIME_WEEKLY import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.data.server.UserManager +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.settings.DataSyncPeriod @@ -27,6 +28,7 @@ import org.hisp.dhis.android.core.settings.LimitScope import org.hisp.dhis.android.core.settings.MetadataSyncPeriod import org.hisp.dhis.android.core.settings.ProgramSetting import org.hisp.dhis.android.core.settings.ProgramSettings +import org.hisp.dhis.android.core.settings.SynchronizationSettings import org.hisp.dhis.android.core.sms.domain.interactor.ConfigCase import org.junit.Before import org.junit.Test @@ -42,6 +44,11 @@ class SettingsRepositoryTest { Mockito.mock(UserManager::class.java, Mockito.RETURNS_DEEP_STUBS) private val preferencesProvider: PreferenceProvider = mock() private val featureConfigRepository: FeatureConfigRepository = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock { + on { getNextSettingsSync() } doReturn null + on { getNextMetadataSync() } doReturn null + on { getNextDataSync() } doReturn null + } private val smsConfig: ConfigCase.SmsConfig = mock { on { isModuleEnabled } doReturn true @@ -62,6 +69,7 @@ class SettingsRepositoryTest { d2, preferencesProvider, featureConfigRepository, + syncBackgroundJobAction, ) configurePreferences() configureDataCount() @@ -70,6 +78,7 @@ class SettingsRepositoryTest { @Test fun `Should return metadata period from general settings if exist`() { + configureSyncSettings(true) configureGeneralSettings(true) val testObserver = settingsRepository.metaSync().test() testObserver @@ -92,6 +101,7 @@ class SettingsRepositoryTest { @Test fun `Should return data period from general settings if exist`() { + configureSyncSettings(true) configureGeneralSettings(true) configureDataErrors() val testObserver = settingsRepository.dataSync().test() @@ -116,8 +126,9 @@ class SettingsRepositoryTest { @Test fun `Should return parameters from general settings if exist`() { + configureSyncSettings(true) configureGeneralSettings(true) - configureProgramSettings(true) + val testObserver = settingsRepository.syncParameters().test() testObserver .assertNoErrors() @@ -130,8 +141,9 @@ class SettingsRepositoryTest { @Test fun `Should return parameters from preferences if general settings does not exist`() { + configureSyncSettings(false) configureGeneralSettings(false) - configureProgramSettings(false) + val testObserver = settingsRepository.syncParameters().test() testObserver .assertNoErrors() @@ -146,6 +158,7 @@ class SettingsRepositoryTest { fun `Should return reserved values from settings if exist`() { configureGeneralSettings(true) val testObserver = settingsRepository.reservedValues().test() + testObserver .assertNoErrors() .assertValue { @@ -157,6 +170,7 @@ class SettingsRepositoryTest { fun `Should return reserved values from preferences if settings does not exist`() { configureGeneralSettings(false) val testObserver = settingsRepository.reservedValues().test() + testObserver .assertNoErrors() .assertValue { @@ -168,6 +182,7 @@ class SettingsRepositoryTest { fun `Should return editable sms configuration if settings does not exist`() { configureGeneralSettings(false) val testObserver = settingsRepository.sms().test() + testObserver .assertNoErrors() .assertValue { @@ -175,6 +190,17 @@ class SettingsRepositoryTest { } } + private fun configureSyncSettings(hasSyncSettings: Boolean) { + whenever( + d2.settingModule().synchronizationSettings().blockingExists(), + ) doReturn hasSyncSettings + if (hasSyncSettings) { + whenever( + d2.settingModule().synchronizationSettings().blockingGet(), + ) doReturn mockedSyncSettings() + } + } + private fun configureGeneralSettings(hasGeneralSettings: Boolean) { whenever(d2.settingModule().generalSetting().blockingExists()) doReturn hasGeneralSettings @@ -185,15 +211,6 @@ class SettingsRepositoryTest { } } - private fun configureProgramSettings(hasProgramSettings: Boolean) { - whenever(d2.settingModule().programSetting().blockingExists()) doReturn - hasProgramSettings - if (hasProgramSettings) { - whenever(d2.settingModule().programSetting().blockingGet()) doReturn - mockedProgramSettings() - } - } - private fun configurePreferences() { whenever( preferencesProvider.getString(Constants.LAST_META_SYNC, "-"), @@ -469,11 +486,17 @@ class SettingsRepositoryTest { whenever(d2.smsModule().configCase()) doReturn configCase } - private fun mockedGeneralSettings(): GeneralSettings = - GeneralSettings + private fun mockedSyncSettings() = + SynchronizationSettings .builder() .dataSync(SETTINGS_DATA_PERIOD) .metadataSync(SETTINGS_METADATA_PERIOD) + .programSettings(mockedProgramSettings()) + .build() + + private fun mockedGeneralSettings(): GeneralSettings = + GeneralSettings + .builder() .encryptDB(SETTINGS_ENCRYPT) .reservedValues(SETTINGS_RV) .build() diff --git a/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt index 4d70a7d708a..7fe223dfad8 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/SyncManagerPresenterTest.kt @@ -39,7 +39,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturnConsecutively import org.mockito.kotlin.mock @@ -130,6 +129,8 @@ class SyncManagerPresenterTest { MetadataSettingsViewModel( metadataSyncPeriod = 100, lastMetadataSync = "test", + nextMetadataSync = null, + nextSettingsSync = null, hasErrors = false, canEdit = false, syncInProgress = false, @@ -139,6 +140,7 @@ class SyncManagerPresenterTest { DataSettingsViewModel( dataSyncPeriod = 100, lastDataSync = "test", + nextDataSync = null, syncHasErrors = false, dataHasErrors = false, dataHasWarnings = false, @@ -223,21 +225,20 @@ class SyncManagerPresenterTest { val timeoutTest = 1 whenever( getSettingsState.invoke( - anyOrNull(), - any(), - any(), any(), ), ) doReturnConsecutively listOf( - mockedSettingState(), - mockedSettingState().copy( - smsSettingsViewModel = - mockedSMSViewModel().copy( - isEnabled = true, - gatewayNumber = gatewayNumberTest, - responseTimeout = timeoutTest, - ), + Result.success(mockedSettingState()), + Result.success( + mockedSettingState().copy( + smsSettingsViewModel = + mockedSMSViewModel().copy( + isEnabled = true, + gatewayNumber = gatewayNumberTest, + responseTimeout = timeoutTest, + ), + ), ), ) @@ -254,7 +255,7 @@ class SyncManagerPresenterTest { awaitItem() presenter.enableSmsModule(true, gatewayNumberTest, timeoutTest) awaitItem() - verify(getSettingsState, times(2)).invoke(anyOrNull(), any(), any(), any()) + verify(getSettingsState, times(2)).invoke(any()) } } @@ -262,13 +263,8 @@ class SyncManagerPresenterTest { fun `Should not save gateway if validation fails`() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) val gatewayNumberTest = "+111" whenever( @@ -310,13 +306,8 @@ class SyncManagerPresenterTest { runTest { val smsResultSender = "test" whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) whenever(updateSmsResponse(any())) doReturn UpdateSmsResponse.UpdateSmsResponseResult.ValidationError( GatewayValidator.GatewayValidationResult.Invalid, @@ -364,17 +355,14 @@ class SyncManagerPresenterTest { fun `Should load data when setting manual trigger`() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), + getSettingsState.invoke(any()), ) doReturnConsecutively listOf( - mockedSettingState(), - mockedSettingState().copy( - dataSettingsViewModel = mockedDataViewModel().copy(dataSyncPeriod = 0), + Result.success(mockedSettingState()), + Result.success( + mockedSettingState().copy( + dataSettingsViewModel = mockedDataViewModel().copy(dataSyncPeriod = 0), + ), ), ) @@ -402,13 +390,8 @@ class SyncManagerPresenterTest { fun `Should open clicked item`() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) presenter.settingsState.test { awaitItem() @@ -483,13 +466,8 @@ class SyncManagerPresenterTest { fun shouldUpdateSyncStatus() = runTest { whenever( - getSettingsState.invoke( - anyOrNull(), - any(), - any(), - any(), - ), - ) doReturn mockedSettingState() + getSettingsState.invoke(any()), + ) doReturn Result.success(mockedSettingState()) val dataSyncStartedProgress = LaunchSync.SyncStatusProgress( diff --git a/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt b/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt index 750be9ec62f..36d38577a53 100644 --- a/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt +++ b/app/src/test/java/org/dhis2/usescases/settings/domain/LaunchSyncTest.kt @@ -1,22 +1,19 @@ package org.dhis2.usescases.settings.domain import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.MutableLiveData -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.WorkInfo import app.cash.turbine.test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.dhis2.commons.Constants import org.dhis2.commons.prefs.PreferenceProvider -import org.dhis2.data.service.workManager.WorkManagerController -import org.dhis2.data.service.workManager.WorkerItem -import org.dhis2.data.service.workManager.WorkerType +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.dhis2.utils.analytics.AnalyticsHelper import org.junit.After import org.junit.Assert.assertEquals @@ -39,21 +36,21 @@ class LaunchSyncTest { private val testingDispatcher = UnconfinedTestDispatcher() private lateinit var launchSync: LaunchSync - private val workManagerController: WorkManagerController = mock() private val preferenceProvider: PreferenceProvider = mock() private val analyticsHelper: AnalyticsHelper = mock() - private val mockedMetadataWorkInfo = MutableLiveData>() - private val mockedDataWorkInfo = MutableLiveData>() + private val mockedMetadataWorkInfo = MutableStateFlow>(emptyList()) + private val mockedDataWorkInfo = MutableStateFlow>(emptyList()) + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() @Before fun setUp() { Dispatchers.setMain(testingDispatcher) - whenever(workManagerController.getWorkInfosByTagLiveData(Constants.META_NOW)) doReturn mockedMetadataWorkInfo - whenever(workManagerController.getWorkInfosByTagLiveData(Constants.DATA_NOW)) doReturn mockedDataWorkInfo + whenever(syncBackgroundJobAction.observeMetadataJob()) doReturn mockedMetadataWorkInfo + whenever(syncBackgroundJobAction.observeDataJob()) doReturn mockedDataWorkInfo launchSync = LaunchSync( - workManagerController = workManagerController, + syncBackgroundJobAction = syncBackgroundJobAction, preferenceProvider = preferenceProvider, analyticsHelper = analyticsHelper, ) @@ -67,71 +64,33 @@ class LaunchSyncTest { @Test fun shouldStartSyncMetadata() = runTest { - val expectedWorkerItem = - WorkerItem( - Constants.META_NOW, - WorkerType.METADATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) launchSync(LaunchSync.SyncAction.SyncMetadata) - verify(workManagerController, times(1)).syncDataForWorker(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchMetadataSync(0) } @Test fun shouldStartSyncData() = runTest { - val expectedWorkerItem = - WorkerItem( - Constants.DATA_NOW, - WorkerType.DATA, - null, - null, - ExistingWorkPolicy.KEEP, - null, - ) launchSync(LaunchSync.SyncAction.SyncData) - verify(workManagerController, times(1)).syncDataForWorker(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchDataSync(0) } @Test fun shouldUpdateSyncDataPeriod() = runTest { val newPeriod = 13 - val expectedWorkerItem = - WorkerItem( - Constants.DATA, - WorkerType.DATA, - newPeriod.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) launchSync(LaunchSync.SyncAction.UpdateSyncDataPeriod(newPeriod)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_DATA, newPeriod) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.DATA) - verify(workManagerController, times(1)).enqueuePeriodicWork(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchDataSync(newPeriod.toLong()) } @Test fun shouldUpdateSyncMetadataPeriod() = runTest { val newPeriod = 13 - val expectedWorkerItem = - WorkerItem( - Constants.META, - WorkerType.METADATA, - newPeriod.toLong(), - null, - null, - ExistingPeriodicWorkPolicy.REPLACE, - ) launchSync(LaunchSync.SyncAction.UpdateSyncMetadataPeriod(newPeriod)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_META, newPeriod) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.META) - verify(workManagerController, times(1)).enqueuePeriodicWork(expectedWorkerItem) + verify(syncBackgroundJobAction, times(1)).launchMetadataSync(newPeriod.toLong()) } @Test @@ -139,7 +98,7 @@ class LaunchSyncTest { runTest { launchSync(LaunchSync.SyncAction.UpdateSyncDataPeriod(0)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_DATA, 0) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.DATA) + verify(syncBackgroundJobAction, times(1)).cancelDataSync() } @Test @@ -147,56 +106,123 @@ class LaunchSyncTest { runTest { launchSync(LaunchSync.SyncAction.UpdateSyncMetadataPeriod(0)) verify(preferenceProvider, times(1)).setValue(Constants.TIME_META, 0) - verify(workManagerController, times(1)).cancelUniqueWork(Constants.META) + verify(syncBackgroundJobAction, times(1)).cancelMetadataSync() } @Test fun shouldUpdateProgressStatus() = runTest { val startedMetadataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.RUNNING + mock { + on { status } doReturn SyncStatus.Running } val startedDataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.RUNNING + mock { + on { status } doReturn SyncStatus.Running } val finishedMetadataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.SUCCEEDED + mock { + on { status } doReturn SyncStatus.Succeed } val finishedDataWorkInfo = - mock { - on { state } doReturn WorkInfo.State.SUCCEEDED + mock { + on { status } doReturn SyncStatus.Succeed } launchSync.syncWorkInfo.test { - mockedMetadataWorkInfo.postValue(listOf(startedMetadataWorkInfo)) - assertState(awaitItem(), LaunchSync.SyncStatus.InProgress, LaunchSync.SyncStatus.None) - mockedDataWorkInfo.postValue(listOf(startedDataWorkInfo)) + awaitItem() + awaitItem() + mockedMetadataWorkInfo.emit(listOf(startedMetadataWorkInfo)) + assertState( + awaitItem(), + LaunchSync.SyncStatus.InProgress, + LaunchSync.SyncStatus.None + ) + mockedDataWorkInfo.emit(listOf(startedDataWorkInfo)) with(awaitItem()) { assertState( this, LaunchSync.SyncStatus.InProgress, LaunchSync.SyncStatus.InProgress, ) - assertFalse(this.hasSyncFinished(metadataWasRunning = true, dataWasRunning = false)) + assertFalse( + this.hasSyncFinished( + metadataWasRunning = true, + dataWasRunning = false + ) + ) } - mockedMetadataWorkInfo.postValue(listOf(finishedMetadataWorkInfo)) + mockedMetadataWorkInfo.emit(listOf(finishedMetadataWorkInfo)) with(awaitItem()) { assertState( this, LaunchSync.SyncStatus.Finished, LaunchSync.SyncStatus.InProgress, ) - assertTrue(this.hasSyncFinished(metadataWasRunning = true, dataWasRunning = true)) + assertTrue( + this.hasSyncFinished( + metadataWasRunning = true, + dataWasRunning = true + ) + ) } - mockedDataWorkInfo.postValue(listOf(finishedDataWorkInfo)) - assertState(awaitItem(), LaunchSync.SyncStatus.Finished, LaunchSync.SyncStatus.Finished) + mockedDataWorkInfo.emit(listOf(finishedDataWorkInfo)) + assertState( + awaitItem(), + LaunchSync.SyncStatus.Finished, + LaunchSync.SyncStatus.Finished + ) cancelAndIgnoreRemainingEvents() } } + @Test + fun shouldCombineMetadataProgress() = + runTest { + launchSync.syncWorkInfo.test { + awaitItem() + awaitItem() + syncStatuses.forEach { metadataSyncStatus -> + syncStatuses.forEach { metadataNowSyncStatus -> + mockedMetadataWorkInfo.emit( + listOf( + metadataSyncStatus, + metadataNowSyncStatus + ) + ) + val expectedValue = when { + (metadataSyncStatus.status is SyncStatus.Running) or (metadataNowSyncStatus.status is SyncStatus.Running) -> LaunchSync.SyncStatus.InProgress + (metadataSyncStatus.status is SyncStatus.Blocked) or (metadataNowSyncStatus.status is SyncStatus.Blocked) -> LaunchSync.SyncStatus.InProgress + (metadataSyncStatus.status is SyncStatus.Enqueue) and (metadataNowSyncStatus.status is SyncStatus.Enqueue) -> LaunchSync.SyncStatus.None + (metadataSyncStatus.status is SyncStatus.Cancelled) and (metadataNowSyncStatus.status is SyncStatus.Cancelled) -> LaunchSync.SyncStatus.Cancelled + else -> LaunchSync.SyncStatus.Finished + + + } + + assertState( + awaitItem(), + expectedValue, + LaunchSync.SyncStatus.None + ) + } + } + } + } + + private val syncStatuses = listOf( + mockedMetadataSyncJobStatus(SyncStatus.Enqueue), + mockedMetadataSyncJobStatus(SyncStatus.Running), + mockedMetadataSyncJobStatus(SyncStatus.Succeed), + mockedMetadataSyncJobStatus(SyncStatus.Failed), + mockedMetadataSyncJobStatus(SyncStatus.Blocked), + mockedMetadataSyncJobStatus(SyncStatus.Cancelled), + ) + + private fun mockedMetadataSyncJobStatus(mockedStatus: SyncStatus) = mock { + on { status } doReturn mockedStatus + } + private fun assertState( syncStatusProgress: LaunchSync.SyncStatusProgress, metadataSyncProgress: LaunchSync.SyncStatus, diff --git a/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt index 07b6c765d3b..c54d0634dd2 100644 --- a/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/sync/SyncPresenterTest.kt @@ -1,17 +1,15 @@ package org.dhis2.usescases.sync -import androidx.work.Data -import androidx.work.WorkInfo import io.reactivex.Completable import io.reactivex.Single -import org.dhis2.commons.Constants import org.dhis2.commons.prefs.Preference import org.dhis2.commons.prefs.PreferenceProvider import org.dhis2.commons.schedulers.SchedulerProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider import org.dhis2.data.server.UserManager -import org.dhis2.data.service.METADATA_MESSAGE -import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.model.SyncJobStatus +import org.dhis2.mobile.sync.model.SyncStatus import org.junit.Before import org.junit.Test import org.mockito.Mockito @@ -20,7 +18,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.util.UUID class SyncPresenterTest { lateinit var presenter: SyncPresenter @@ -28,9 +25,10 @@ class SyncPresenterTest { private val userManager: UserManager = Mockito.mock(UserManager::class.java, Mockito.RETURNS_DEEP_STUBS) private val schedulerProvider: SchedulerProvider = TrampolineSchedulerProvider() - private val workManagerController: WorkManagerController = mock() private val preferences: PreferenceProvider = mock() + private val backgroundJobAction: SyncBackgroundJobAction = mock() + @Before fun setUp() { presenter = @@ -38,7 +36,7 @@ class SyncPresenterTest { view, userManager, schedulerProvider, - workManagerController, + backgroundJobAction, preferences, ) } @@ -46,35 +44,31 @@ class SyncPresenterTest { @Test fun `Should start initial sync`() { presenter.sync() - verify(workManagerController, times(1)).syncMetaDataForWorker( - Constants.META_NOW, - Constants.INITIAL_SYNC, - ) + verify(backgroundJobAction, times(1)).launchMetadataSync(0) } @Test fun `Should return work info live data`() { presenter.observeSyncProcess() - verify(workManagerController, times(1)) - .getWorkInfosForUniqueWorkLiveData(Constants.INITIAL_SYNC) + verify(backgroundJobAction, times(1)).observeMetadataJob() } @Test fun `Should set metadata sync started`() { - presenter.handleSyncInfo(arrayListOf(metaWorkInfo(WorkInfo.State.RUNNING))) + presenter.handleSyncInfo(arrayListOf(metaSyncJobStatus(SyncStatus.Running))) verify(view, times(1)).setMetadataSyncStarted() } @Test fun `Should set metadata sync succeeded`() { - presenter.handleSyncInfo(arrayListOf(metaWorkInfo(WorkInfo.State.SUCCEEDED))) + presenter.handleSyncInfo(arrayListOf(metaSyncJobStatus(SyncStatus.Succeed))) verify(view, times(1)).setMetadataSyncSucceed() } @Test fun `Should show metadata sync error message`() { val message = "Error message" - presenter.handleSyncInfo(arrayListOf(metaWorkInfo(WorkInfo.State.FAILED, message))) + presenter.handleSyncInfo(arrayListOf(metaSyncJobStatus(SyncStatus.Failed, message))) verify(view, times(1)).showMetadataFailedMessage(message) } @@ -97,17 +91,12 @@ class SyncPresenterTest { verify(view, times(1)).goToLogin() } - private fun metaWorkInfo( - state: WorkInfo.State, + private fun metaSyncJobStatus( + state: SyncStatus, message: String? = null, - ): WorkInfo = - WorkInfo( - id = UUID.randomUUID(), - state = state, - outputData = Data.Builder().apply { putString(METADATA_MESSAGE, message) }.build(), - tags = setOf(Constants.META_NOW), - progress = Data.EMPTY, - runAttemptCount = 0, - generation = 0, - ) + ) = SyncJobStatus( + status = state, + message = message, + tags = listOf("METADATA_SYNC_NOW"), + ) } diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt index 8e81092c0b1..3fe08ab4fac 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt @@ -102,6 +102,7 @@ class DashboardRepositoryImplTest { .uid("enrollment_1") .deleted(true) .trackedEntityInstance(teiUid) + .attributeOptionCombo("attributeOptionComboUid") .build() val enrollment2 = getMockingEnrollment() @@ -588,6 +589,7 @@ class DashboardRepositoryImplTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build() private fun getMockSingleEvent(): Event = diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt index 48dd0e14fd5..15fafeeb496 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardViewModelTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider @@ -16,6 +17,7 @@ import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.junit.After import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -36,6 +38,11 @@ class DashboardViewModelTest { private val pageConfigurator: NavigationPageConfigurator = mock() private val resoourcesManager: ResourceManager = mock() + @After + fun tearDown() { + Dispatchers.resetMain() + } + @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt index 68e2e2c7c8a..be022f1f663 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiAttributesProviderTest.kt @@ -267,7 +267,13 @@ class TeiAttributesProviderTest { .byTrackedEntityTypeUid() .eq(teType) .blockingGet()[0], - ) doReturn Program.builder().uid(program).build() + ) doReturn + Program + .builder() + .uid(program) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() whenever(d2.programModule().programTrackedEntityAttributes()) doReturn mock() whenever(d2.programModule().programTrackedEntityAttributes().byProgram()) doReturn mock() diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt index 4849f3c652a..6ca5598b905 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt @@ -18,18 +18,6 @@ class TeiDashboardPageConfiguratorTest { assertTrue(pageConfigurator.displayDetails()) } - @Test - fun `Should display analytics screen if the program is configured`() { - whenever(dashboardRepository.programHasAnalytics()) doReturn true - assertTrue(pageConfigurator.displayAnalytics()) - } - - @Test - fun `Should not display analytics screen if the program is configured`() { - whenever(dashboardRepository.programHasAnalytics()) doReturn false - assertTrue(!pageConfigurator.displayAnalytics()) - } - @Test fun `Should display relationships screen if the program is configured`() { whenever(dashboardRepository.programHasRelationships()) doReturn true diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt index f727dbf0576..ed5c643f437 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPresenterTest.kt @@ -12,6 +12,7 @@ import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.utils.analytics.AnalyticsHelper import org.dhis2.utils.analytics.CLICK import org.dhis2.utils.analytics.DELETE_TEI +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import org.hisp.dhis.android.core.organisationunit.OrganisationUnit @@ -65,9 +66,20 @@ class TeiDashboardPresenterTest { @Test fun `Should set program and restore adapter`() { val programUid = "programUid" - val program = Program.builder().uid(programUid).build() + val program = + Program + .builder() + .uid(programUid) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build() val trackedEntityInstance = TrackedEntityInstance.builder().uid(teiUid).build() - val enrollment = Enrollment.builder().uid("enrollmentUid").build() + val enrollment = + Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() val programStages = listOf(ProgramStage.builder().uid("programStageUid").build()) val trackedEntityAttributes = listOf( @@ -81,7 +93,12 @@ class TeiDashboardPresenterTest { val programs = listOf( Pair( - Program.builder().uid(programUid).build(), + Program + .builder() + .uid(programUid) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) + .build(), MetadataIconData.defaultIcon(), ), ) diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt index 7d4574c7d0f..2fe28392f1a 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/data/TeiDataPresenterTest.kt @@ -78,6 +78,7 @@ class TeiDataPresenterTest { private val dispatcherProvider: DispatcherProvider = mock { on { io() } doReturn Dispatchers.Unconfined + on { ui() } doReturn Dispatchers.Unconfined } private val createEventUseCase: CreateEventUseCase = mock() private val d2ErrorUtils: D2ErrorUtils = mock() @@ -399,6 +400,103 @@ class TeiDataPresenterTest { verify(teiDataContractHandler).createEvent(any()) } + @Test + fun `should use enrollment org unit when owner org unit is null`() = + runBlocking { + val enrollmentOrgUnitUid = "enrollmentOrgUnit" + val programStageUid = "programStageUid" + val eventUid = "eventUid" + val programStage = ProgramStage.builder().uid(programStageUid).build() + + val mockedEnrollment: Enrollment = + mock { + on { organisationUnit() } doReturn enrollmentOrgUnitUid + } + whenever(d2.enrollment(enrollmentUid)) doReturn mockedEnrollment + whenever(teiDataRepository.ownerOrgUnit(teiUid)) doReturn null + whenever( + createEventUseCase.invoke( + programUid, + enrollmentOrgUnitUid, + programStageUid, + enrollmentUid, + ), + ) doReturn Result.success(eventUid) + + teiDataPresenter.onAddNewEventOptionSelected( + eventCreationType = EventCreationType.ADDNEW, + stage = programStage, + ) + + verify(createEventUseCase).invoke(programUid, enrollmentOrgUnitUid, programStageUid, enrollmentUid) + verify(view).goToEventDetails(eventUid, EventMode.NEW, programUid) + } + + @Test + fun `should use owner org unit when it is the same as enrollment org unit`() = + runBlocking { + val orgUnitUid = "enrollmentOrgUnit" + val programStageUid = "programStageUid" + val eventUid = "eventUid" + val programStage = ProgramStage.builder().uid(programStageUid).build() + + val mockedEnrollment: Enrollment = + mock { + on { organisationUnit() } doReturn orgUnitUid + } + whenever(d2.enrollment(enrollmentUid)) doReturn mockedEnrollment + whenever(teiDataRepository.ownerOrgUnit(teiUid)) doReturn orgUnitUid + whenever( + createEventUseCase.invoke( + programUid, + orgUnitUid, + programStageUid, + enrollmentUid, + ), + ) doReturn Result.success(eventUid) + + teiDataPresenter.onAddNewEventOptionSelected( + eventCreationType = EventCreationType.ADDNEW, + stage = programStage, + ) + + verify(createEventUseCase).invoke(programUid, orgUnitUid, programStageUid, enrollmentUid) + verify(view).goToEventDetails(eventUid, EventMode.NEW, programUid) + } + + @Test + fun `should use owner org unit when it is different from enrollment org unit`() = + runBlocking { + val enrollmentOrgUnitUid = "enrollmentOrgUnit" + val ownerOrgUnitUid = "ownerOrgUnit" + val programStageUid = "programStageUid" + val eventUid = "eventUid" + val programStage = ProgramStage.builder().uid(programStageUid).build() + + val mockedEnrollment: Enrollment = + mock { + on { organisationUnit() } doReturn enrollmentOrgUnitUid + } + whenever(d2.enrollment(enrollmentUid)) doReturn mockedEnrollment + whenever(teiDataRepository.ownerOrgUnit(teiUid)) doReturn ownerOrgUnitUid + whenever( + createEventUseCase.invoke( + programUid, + ownerOrgUnitUid, + programStageUid, + enrollmentUid, + ), + ) doReturn Result.success(eventUid) + + teiDataPresenter.onAddNewEventOptionSelected( + eventCreationType = EventCreationType.ADDNEW, + stage = programStage, + ) + + verify(createEventUseCase).invoke(programUid, ownerOrgUnitUid, programStageUid, enrollmentUid) + verify(view).goToEventDetails(eventUid, EventMode.NEW, programUid) + } + private fun fakeModel( eventCount: Int = 0, type: EventViewModelType = EventViewModelType.STAGE, diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepositoryTest.kt new file mode 100644 index 00000000000..4fef338aebb --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/BaseIndicatorRepositoryTest.kt @@ -0,0 +1,19 @@ +package org.dhis2.usescases.teiDashboard.dashboardfragments.indicators + +import org.dhis2.usescases.teiDashboard.dashboardfragments.indicators.BaseIndicatorRepository.Companion.getNumberValue +import org.junit.Test + +class BaseIndicatorRepositoryTest { + + @Test + fun `Should parse number value`() { + assert(getNumberValue("54") == 54.0) + assert(getNumberValue("54.3") == 54.3) + assert(getNumberValue("-12.2") == -12.2) + assert(getNumberValue("23456.6") == 23456.6) + assert(getNumberValue("23,456.6") == 23456.6) + assert(getNumberValue("54.1%") == 54.1) + assert(getNumberValue("54.1 %") == 54.1) + assert(getNumberValue("<14322>") == 14322.0) + } +} diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt index 6ef547a9758..3a0ecc6f7b2 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/EventIndicatorRepositoryTest.kt @@ -2,6 +2,7 @@ package org.dhis2.usescases.teiDashboard.dashboardfragments.indicators import dhis2.org.analytics.charts.ui.SectionTitle import io.reactivex.Single +import kotlinx.coroutines.test.runTest import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 @@ -11,6 +12,7 @@ import org.hisp.dhis.android.core.program.ProgramRuleAction import org.hisp.dhis.android.core.program.ProgramRuleActionType import org.hisp.dhis.rules.models.RuleAction import org.hisp.dhis.rules.models.RuleEffect +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mockito @@ -70,7 +72,12 @@ class EventIndicatorRepositoryTest { .eq("programUid") .one() .blockingGet(), - ) doReturn Enrollment.builder().uid("enrollmentUid").build() + ) doReturn + Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() whenever( resourceManager.sectionIndicators(), ) doReturn "Indicators" @@ -91,7 +98,7 @@ class EventIndicatorRepositoryTest { } @Test - fun `Should fetch analytic data for tracker`() { + fun `Should fetch analytic data for tracker`() = runTest { whenever( d2 .programModule() @@ -213,15 +220,14 @@ class EventIndicatorRepositoryTest { ruleEngineHelper.evaluate(), ) doReturn mockedEffects() - val testObserver = repository.fetchData().test() - testObserver.assertNoErrors() - testObserver.assertValue { - it.size == 5 && - it[0] is SectionTitle && - (it[0] as SectionTitle).title == "Feedback" && - it[2] is SectionTitle && - (it[2] as SectionTitle).title == "Indicators" - } + val result = repository.fetchData() + assertTrue( + result.size == 5 && + result[0] is SectionTitle && + (result[0] as SectionTitle).title == "Feedback" && + result[2] is SectionTitle && + (result[2] as SectionTitle).title == "Indicators", + ) } private fun mockedProgramIndicatorList(): List = diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenterTest.kt index 2e1298fadae..041236c9eb9 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/IndicatorsPresenterTest.kt @@ -3,11 +3,15 @@ package org.dhis2.usescases.teiDashboard.dashboardfragments.indicators import dhis2.org.analytics.charts.ui.AnalyticsModel import dhis2.org.analytics.charts.ui.IndicatorModel import dhis2.org.analytics.charts.ui.LOCATION_INDICATOR_WIDGET -import io.reactivex.Flowable -import org.dhis2.commons.schedulers.SchedulerProvider -import org.dhis2.data.schedulers.TrampolineSchedulerProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.dhis2.commons.viewmodel.DispatcherProvider import org.hisp.dhis.android.core.program.ProgramIndicator -import org.junit.Assert +import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -16,33 +20,43 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@OptIn(ExperimentalCoroutinesApi::class) class IndicatorsPresenterTest { - private val schedulers: SchedulerProvider = TrampolineSchedulerProvider() + private val testDispatcher = StandardTestDispatcher() + private val dispatcherProvider: DispatcherProvider = mock { + on { io() } doReturn testDispatcher + on { ui() } doReturn testDispatcher + } private val view: IndicatorsView = mock() private val indicatorRepository: IndicatorRepository = mock() private lateinit var presenter: IndicatorsPresenter @Before fun setUp() { - presenter = IndicatorsPresenter(schedulers, view, indicatorRepository) + Dispatchers.setMain(testDispatcher) + presenter = IndicatorsPresenter(dispatcherProvider, view, indicatorRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() } @Test fun `Should get indicators`() { - whenever( - indicatorRepository.fetchData(), - ) doReturn Flowable.just(analyticsModels()) + runBlocking { + whenever(indicatorRepository.fetchData()) doReturn analyticsModels() + } presenter.init() + testDispatcher.scheduler.advanceUntilIdle() verify(view).swapAnalytics(any()) } @Test - fun `Should clear disposables`() { + fun `Should clear scope on detach`() { presenter.onDettach() - - Assert.assertTrue(presenter.compositeDisposable.size() == 0) } @Test diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt index a9ae3d0ccb1..d2ab6d2e6b9 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/indicators/TrackerAnalyticsRepositoryTest.kt @@ -4,6 +4,7 @@ import dhis2.org.analytics.charts.Charts import dhis2.org.analytics.charts.data.Graph import dhis2.org.analytics.charts.ui.SectionTitle import io.reactivex.Single +import kotlinx.coroutines.test.runTest import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 @@ -15,6 +16,7 @@ import org.hisp.dhis.android.core.program.ProgramRuleAction import org.hisp.dhis.android.core.program.ProgramRuleActionType import org.hisp.dhis.rules.models.RuleAction import org.hisp.dhis.rules.models.RuleEffect +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mockito @@ -76,7 +78,12 @@ class TrackerAnalyticsRepositoryTest { .eq("programUid") .one() .blockingGet(), - ) doReturn Enrollment.builder().uid("enrollmentUid").build() + ) doReturn + Enrollment + .builder() + .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") + .build() whenever( resourceManager.sectionCharts(), ) doReturn "Charts" @@ -104,7 +111,7 @@ class TrackerAnalyticsRepositoryTest { } @Test - fun `Should fetch analytic data for tracker`() { + fun `Should fetch analytic data for tracker`() = runTest { whenever( d2 .programModule() @@ -230,19 +237,18 @@ class TrackerAnalyticsRepositoryTest { charts.geEnrollmentCharts(any()), ) doReturn mockedCharts() - val testObserver = repository.fetchData().test() - testObserver.assertNoErrors() - testObserver.assertValue { - it.size == 7 && - it[0] is SectionTitle && - (it[0] as SectionTitle).title == "Feedback" && - it[2] is SectionTitle && - (it[2] as SectionTitle).title == "Charts and indicators" - } + val result = repository.fetchData() + assertTrue( + result.size == 7 && + result[0] is SectionTitle && + (result[0] as SectionTitle).title == "Feedback" && + result[2] is SectionTitle && + (result[2] as SectionTitle).title == "Charts and indicators", + ) } @Test - fun `Should fetch analytic data for tracker with only indicator section`() { + fun `Should fetch analytic data for tracker with only indicator section`() = runTest { whenever( d2 .programModule() @@ -368,15 +374,14 @@ class TrackerAnalyticsRepositoryTest { charts.geEnrollmentCharts(any()), ) doReturn emptyList() - val testObserver = repository.fetchData().test() - testObserver.assertNoErrors() - testObserver.assertValue { - it[0] is SectionTitle && (it[0] as SectionTitle).title == "Indicators" - } + val result = repository.fetchData() + assertTrue( + result[0] is SectionTitle && (result[0] as SectionTitle).title == "Indicators", + ) } @Test - fun `Should fetch analytic data for tracker with only charts section`() { + fun `Should fetch analytic data for tracker with only charts section`() = runTest { whenever( d2 .programModule() @@ -503,11 +508,10 @@ class TrackerAnalyticsRepositoryTest { charts.geEnrollmentCharts(any()), ) doReturn mockedCharts() - val testObserver = repository.fetchData().test() - testObserver.assertNoErrors() - testObserver.assertValue { - it[0] is SectionTitle && (it[0] as SectionTitle).title == "Charts" - } + val result = repository.fetchData() + assertTrue( + result[0] is SectionTitle && (result[0] as SectionTitle).title == "Charts", + ) } private fun mockedProgramIndicatorList(): List = diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt index bbc4d713712..36fb4159bd1 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/dashboardfragments/relationships/RelationshipPresenterTest.kt @@ -245,6 +245,7 @@ class RelationshipPresenterTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build(), ) 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 7ad7accf8d9..9909ea134db 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 @@ -3,81 +3,57 @@ package org.dhis2.usescases.teiDashboard.dialogs.scheduling import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain -import org.dhis2.commons.bindings.enrollment -import org.dhis2.commons.bindings.programStage import org.dhis2.commons.data.EventCreationType import org.dhis2.commons.viewmodel.DispatcherProvider import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.DateUtils -import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl import org.hisp.dhis.android.core.enrollment.Enrollment -import org.hisp.dhis.android.core.enrollment.EnrollmentCollectionRepository -import org.hisp.dhis.android.core.enrollment.EnrollmentModule -import org.hisp.dhis.android.core.enrollment.EnrollmentObjectRepository -import org.hisp.dhis.android.core.period.PeriodModule -import org.hisp.dhis.android.core.program.ProgramModule +import org.hisp.dhis.android.core.event.EventCollectionRepository +import org.hisp.dhis.android.core.program.ProgramCollectionRepository import org.hisp.dhis.android.core.program.ProgramStage -import org.hisp.dhis.android.core.program.ProgramStageCollectionRepository +import org.junit.After import org.junit.Before import org.junit.Test -import org.mockito.Mockito.spy +import org.mockito.Mockito import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class SchedulingViewModelTest { private lateinit var schedulingViewModel: SchedulingViewModel - private val testingDispatcher = UnconfinedTestDispatcher() + private val testingDispatcher = StandardTestDispatcher() - private val enrollment = Enrollment.builder().uid(ENROLLMENT_UID).build() - private val programStage = ProgramStage.builder().uid(STAGE).build() + private val enrollment = + Enrollment + .builder() + .uid(ENROLLMENT_UID) + .attributeOptionCombo("attributeOptionComboUid") + .build() - private val enrollmentObjectRepository: EnrollmentObjectRepository = - mock { - on { blockingGet() } doReturn enrollment - } - private val enrollmentCollectionRepository: EnrollmentCollectionRepository = - mock { - on { uid(ENROLLMENT_UID) } doReturn enrollmentObjectRepository - } - private val enrollmentModule: EnrollmentModule = - mock { - on { enrollments() } doReturn enrollmentCollectionRepository - } + private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) - private val readOnlyOneObjectRepositoryFinalImpl: ReadOnlyOneObjectRepositoryFinalImpl = - mock { - on { blockingGet() } doReturn programStage - } - private val programStageCollectionRepository: ProgramStageCollectionRepository = - mock { - on { uid(STAGE) } doReturn readOnlyOneObjectRepositoryFinalImpl - } - private val programModule: ProgramModule = - mock { - on { programStages() } doReturn programStageCollectionRepository - } - - private val periodModule: PeriodModule = - mock { - on { periodHelper() } doReturn mock() - } - - private val d2: D2 = - mock { - on { enrollmentModule() } doReturn enrollmentModule - on { programModule() } doReturn programModule - on { periodModule() } doReturn periodModule - } + @After + fun tearDown() { + Dispatchers.resetMain() + } @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { Dispatchers.setMain(testingDispatcher) + whenever(d2.enrollmentModule().enrollments().uid(ENROLLMENT_UID).blockingGet()) doReturn enrollment + whenever(d2.programModule().programStages().uid(STAGE).blockingGet()) doReturn + ProgramStage.builder().uid(STAGE).build() + whenever(d2.eventModule().events()) doReturn + Mockito.mock(EventCollectionRepository::class.java, Mockito.RETURNS_DEEP_STUBS) + whenever(d2.programModule().programs()) doReturn + Mockito.mock(ProgramCollectionRepository::class.java, Mockito.RETURNS_DEEP_STUBS) schedulingViewModel = SchedulingViewModel( d2 = d2, @@ -108,7 +84,7 @@ class SchedulingViewModelTest { @Test fun shouldSetReportDate() { val date = DateUtils.DATE_FORMAT.parse(REPORT_DATE) - val spy = spy(schedulingViewModel) + val spy = Mockito.spy(schedulingViewModel) spy.onDateSet(2024, 4, 14) verify(spy).setUpEventReportDate(date) } @@ -116,7 +92,7 @@ class SchedulingViewModelTest { @Test fun shouldSetReportDateForIncreasedNumberOfMonth() { val date = DateUtils.DATE_FORMAT.parse(REPORT_DATE_INCREASED_MONTH) - val spy = spy(schedulingViewModel) + val spy = Mockito.spy(schedulingViewModel) spy.onDateSet(2024, 13, 31) verify(spy).setUpEventReportDate(date) } 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 c10529a1bd4..ec2de96bd51 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 @@ -5,6 +5,7 @@ import org.dhis2.commons.date.DateUtils import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.usescases.main.program.ProgramViewModelMapper import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentCreateProjection import org.hisp.dhis.android.core.program.Program @@ -61,6 +62,8 @@ class TeiProgramListRepositoryImplTest { .builder() .uid("programUid") .displayIncidentDate(true) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( @@ -73,6 +76,7 @@ class TeiProgramListRepositoryImplTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build() val testObservable = @@ -125,6 +129,8 @@ class TeiProgramListRepositoryImplTest { .builder() .uid("programUid") .displayIncidentDate(false) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( @@ -137,6 +143,7 @@ class TeiProgramListRepositoryImplTest { Enrollment .builder() .uid("enrollmentUid") + .attributeOptionCombo("attributeOptionComboUid") .build() val testObservable = diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt index b9224df9966..b52bee833c6 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/InfoBarMapperTest.kt @@ -5,6 +5,7 @@ import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.usescases.teiDashboard.DashboardEnrollmentModel import org.dhis2.usescases.teiDashboard.ui.model.InfoBarType +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -164,6 +165,7 @@ class InfoBarMapperTest { .status(status) .followUp(followup) .program("Program1Uid") + .attributeOptionCombo("attributeOptionComboUid") .build() private fun setPrograms() = @@ -173,6 +175,8 @@ class InfoBarMapperTest { .builder() .uid("Program1Uid") .displayName("Program 1") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), @@ -181,6 +185,8 @@ class InfoBarMapperTest { .builder() .uid("Program2Uid") .displayName("Program 2") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt index af590ae0550..2343906cd72 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/QuickActionsMapperTest.kt @@ -117,6 +117,7 @@ class QuickActionsMapperTest { .status(status) .followUp(followup) .program("Program1Uid") + .attributeOptionCombo("attributeOptionComboUid") .build() val tei = TrackedEntityInstance diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt index 93de79a22de..f8c2e9e03d4 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt @@ -4,6 +4,7 @@ import org.dhis2.R import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.usescases.teiDashboard.DashboardEnrollmentModel +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus @@ -123,6 +124,7 @@ class TEIDetailMapperTest { .status(EnrollmentStatus.COMPLETED) .program("Program1Uid") .organisationUnit("orgUnitUid") + .attributeOptionCombo("attributeOptionComboUid") .build() private fun setPrograms() = @@ -132,6 +134,8 @@ class TEIDetailMapperTest { .builder() .uid("Program1Uid") .displayName("Program 1") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), @@ -140,6 +144,8 @@ class TEIDetailMapperTest { .builder() .uid("Program2Uid") .displayName("Program 2") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) .build(), MetadataIconData.defaultIcon(), ), diff --git a/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt b/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt index 20b0453b82e..611d01a1214 100644 --- a/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt +++ b/app/src/test/java/org/dhis2/usescases/tracker/TrackedEntityInstanceInfoProviderTests.kt @@ -3,11 +3,13 @@ package org.dhis2.usescases.tracker import androidx.compose.ui.graphics.Color import org.dhis2.commons.date.DateLabelProvider import org.dhis2.commons.resources.MetadataIconProvider +import org.dhis2.data.sorting.SearchSortingValueSetter import org.dhis2.mobile.commons.model.AvatarProviderConfiguration import org.dhis2.mobile.commons.model.MetadataIconData import org.dhis2.tracker.data.ProfilePictureProvider import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.ObjectStyle +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem @@ -26,6 +28,7 @@ class TrackedEntityInstanceInfoProviderTests { private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) private val profilePictureProvider: ProfilePictureProvider = mock() private val dateLabelProvider: DateLabelProvider = mock() + private val searchSortingValueSetter: SearchSortingValueSetter = mock() private val metadataIconProvider: MetadataIconProvider = mock { on { invoke(any()) } doReturn MetadataIconData.defaultIcon() @@ -39,6 +42,8 @@ class TrackedEntityInstanceInfoProviderTests { profilePictureProvider, dateLabelProvider, metadataIconProvider, + searchSortingValueSetter, + ) } @@ -216,7 +221,12 @@ class TrackedEntityInstanceInfoProviderTests { .build() private fun mockProgram(hasStyle: Boolean = false): Program { - val program = Program.builder().uid("programUid") + val program = + Program + .builder() + .uid("programUid") + .categoryCombo(ObjectWithUid.create("categoryComboUid")) + .enrollmentCategoryCombo(ObjectWithUid.create("categoryComboUid")) if (hasStyle) { program.style( diff --git a/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt b/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt index e9e402e3f18..274245d70b5 100644 --- a/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt +++ b/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt @@ -164,6 +164,7 @@ class FilterManagerTest { fun `Should count filters in total for event working list`() { val workingListScope = EventWorkingListScope( + workingListUid = "uid", stageUid = "uid", eventDate = "23/11/2023", eventStatusList = listOf("status"), @@ -179,6 +180,7 @@ class FilterManagerTest { fun `Should count filters in total for tei working list`() { val workingListScope = TeiWorkingListScope( + workingListUid = "uid", enrollmentStatusList = listOf("status", "status"), enrollmentDate = null, eventStatusList = listOf(), diff --git a/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt b/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt index e1fd0ebf854..65993532019 100644 --- a/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncPresenterTest.kt @@ -14,13 +14,19 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain -import org.dhis2.commons.Constants import org.dhis2.commons.sync.ConflictType import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.schedulers.TrampolineSchedulerProvider import org.dhis2.data.service.workManager.WorkManagerController +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction import org.dhis2.usescases.sms.SmsSendingService +import org.dhis2.utils.granularsync.data.GranularSyncRepository +import org.dhis2.utils.granularsync.domain.MissingSyncTargetException +import org.dhis2.utils.granularsync.domain.SyncStatus +import org.dhis2.utils.granularsync.domain.SyncStatusData +import org.dhis2.utils.granularsync.ui.SyncUiState +import org.dhis2.utils.granularsync.ui.SyncUiStateMapper import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.State @@ -36,12 +42,16 @@ import org.junit.Test import org.mockito.ArgumentMatchers.anyList import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -61,16 +71,45 @@ class GranularSyncPresenterTest { } private val workManager = Mockito.mock(WorkManagerController::class.java) private val smsSyncProvider: SMSSyncProvider = mock() + private val mapper: SyncUiStateMapper = mock() private val context: Context = mock() private val syncContext: SyncContext = SyncContext.Global() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() @Before fun setUp() { + startKoin { + modules( + module { + single { syncBackgroundJobAction } + }, + ) + } Dispatchers.setMain(testingDispatcher) + runBlocking { + whenever(repository.getSyncStatus(anyOrNull())) doReturn + SyncStatusData( + syncState = SyncStatus.SYNCED, + lastSyncDate = null, + content = emptyList(), + targetName = "", + ) + } + whenever(mapper.toUiState(any(), any())) doReturn + SyncUiState( + syncState = SyncStatus.SYNCED, + title = "Title", + lastSyncDate = null, + message = null, + mainActionLabel = null, + secondaryActionLabel = null, + content = emptyList(), + ) } @After fun tearDown() { + stopKoin() Dispatchers.resetMain() } @@ -86,11 +125,19 @@ class GranularSyncPresenterTest { SyncContext.TrackerProgram("test_uid"), workManager, smsSyncProvider, + mapper, ) + val mockedStatusData = + SyncStatusData( + syncState = SyncStatus.ERROR, + lastSyncDate = Date(), + content = emptyList(), + targetName = "test", + ) val mockedState = SyncUiState( - syncState = State.ERROR, + syncState = SyncStatus.ERROR, title = "Title", lastSyncDate = SyncDate(Date()), message = "message", @@ -98,13 +145,53 @@ class GranularSyncPresenterTest { secondaryActionLabel = "action 2", content = emptyList(), ) - whenever(repository.getUiState()) doReturn mockedState + runBlocking { + whenever(repository.getSyncStatus(anyOrNull())) doReturn mockedStatusData + } + whenever(mapper.toUiState(any(), any())) doReturn mockedState presenter.refreshContent() val result = presenter.currentState.value assertEquals(mockedState, result) } + @Test + fun `should map missing target exception to error ui state`() { + val presenter = + GranularSyncPresenter( + d2, + view, + repository, + trampolineSchedulerProvider, + testDispatcher, + SyncContext.TrackerProgram("missing_uid"), + workManager, + smsSyncProvider, + mapper, + ) + + val missingTargetUiState = + SyncUiState( + syncState = SyncStatus.ERROR, + title = "Sync error", + lastSyncDate = null, + message = "missing_uid not found", + mainActionLabel = "Refresh", + secondaryActionLabel = "Not now", + content = emptyList(), + ) + + runBlocking { + whenever(repository.getSyncStatus(anyOrNull())) doThrow + MissingSyncTargetException("missing_uid") + } + whenever(mapper.missingTargetUiState("missing_uid")) doReturn missingTargetUiState + + presenter.refreshContent() + + assertEquals(missingTargetUiState, presenter.currentState.value) + } + @Test fun `should block sms for some conflict types`() { whenever( @@ -139,6 +226,7 @@ class GranularSyncPresenterTest { it, workManager, smsSyncProvider, + mapper, ).canSendSMS() it to enable }.forEach { (syncContext, canSendSMS) -> @@ -167,6 +255,7 @@ class GranularSyncPresenterTest { syncContext, workManager, smsSyncProvider, + mapper, ) val testingMsg = "testingMsg" @@ -195,6 +284,7 @@ class GranularSyncPresenterTest { syncContext, workManager, smsSyncProvider, + mapper, ) presenter.onSmsNotManuallySent(context) @@ -213,6 +303,7 @@ class GranularSyncPresenterTest { syncContext, workManager, smsSyncProvider, + mapper, ) whenever(smsSyncProvider.isPlayServicesEnabled()) doReturn false @@ -247,6 +338,7 @@ class GranularSyncPresenterTest { syncContext, workManager, smsSyncProvider, + mapper, ) whenever(smsSyncProvider.expectsResponseSMS()) doReturn false @@ -255,7 +347,9 @@ class GranularSyncPresenterTest { presenter.onSmsManuallySent(context) { } - verify(repository).getUiState() + runBlocking { + verify(repository).getSyncStatus(anyOrNull()) + } } @Test @@ -270,13 +364,16 @@ class GranularSyncPresenterTest { syncContext, workManager, smsSyncProvider, + mapper, ) whenever(smsSyncProvider.expectsResponseSMS()) doReturn true presenter.onSmsManuallySent(context) { } - verify(repository, times(0)).getUiState() + runBlocking { + verify(repository, times(0)).getSyncStatus(anyOrNull()) + } } @Test @@ -291,6 +388,7 @@ class GranularSyncPresenterTest { syncContext, workManager, smsSyncProvider, + mapper, ) whenever(smsSyncProvider.smsSender) doReturn mock() @@ -298,7 +396,9 @@ class GranularSyncPresenterTest { presenter.onConfirmationMessageStateChanged(true) - verify(repository).getUiState() + runBlocking { + verify(repository).getSyncStatus(anyOrNull()) + } } @Test @@ -313,6 +413,7 @@ class GranularSyncPresenterTest { syncContext, workManager, smsSyncProvider, + mapper, ) whenever(smsSyncProvider.smsSender) doReturn mock() @@ -320,7 +421,9 @@ class GranularSyncPresenterTest { presenter.onConfirmationMessageStateChanged(false) - verify(repository).getUiState() + runBlocking { + verify(repository).getSyncStatus(anyOrNull()) + } } @Test @@ -335,6 +438,7 @@ class GranularSyncPresenterTest { SyncContext.TrackerProgram("programUid"), workManager, smsSyncProvider, + mapper, ) val workInfoList = MutableLiveData>(emptyList()) @@ -360,6 +464,7 @@ class GranularSyncPresenterTest { SyncContext.TrackerProgramTei("enrollmentUid"), workManager, smsSyncProvider, + mapper, ) val workInfoList = MutableLiveData>(emptyList()) @@ -385,6 +490,7 @@ class GranularSyncPresenterTest { SyncContext.Event("eventUid"), workManager, smsSyncProvider, + mapper, ) val workInfoList = MutableLiveData>(emptyList()) @@ -410,6 +516,7 @@ class GranularSyncPresenterTest { SyncContext.DataSet("dataSetUid"), workManager, smsSyncProvider, + mapper, ) val workInfoList = MutableLiveData>(emptyList()) @@ -440,6 +547,7 @@ class GranularSyncPresenterTest { ), workManager, smsSyncProvider, + mapper, ) val mockedDataSet: DataSet = @@ -503,6 +611,7 @@ class GranularSyncPresenterTest { SyncContext.Global(), workManager, smsSyncProvider, + mapper, ) val workInfoList = MutableLiveData>(emptyList()) @@ -512,7 +621,6 @@ class GranularSyncPresenterTest { val resultLiveData = presenter.initGranularSync() resultLiveData.observeForever(workInfoObserver) - verify(workManager).syncDataForWorker(Constants.DATA_NOW, Constants.INITIAL_SYNC) verify(workInfoObserver).onChanged(anyList()) } @@ -531,6 +639,7 @@ class GranularSyncPresenterTest { SyncContext.Global(), workManager, smsSyncProvider, + mapper, ) presenter.checkServerAvailability() @@ -553,6 +662,7 @@ class GranularSyncPresenterTest { SyncContext.Global(), workManager, smsSyncProvider, + mapper, ) presenter.checkServerAvailability() diff --git a/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncRepositoryTest.kt b/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncRepositoryTest.kt new file mode 100644 index 00000000000..6776c8c9025 --- /dev/null +++ b/app/src/test/java/org/dhis2/utils/granularsync/GranularSyncRepositoryTest.kt @@ -0,0 +1,124 @@ +package org.dhis2.utils.granularsync + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.resources.DhisPeriodUtils +import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.sync.SyncContext +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.data.dhislogic.DhisProgramUtils +import org.dhis2.utils.granularsync.data.GranularSyncRepository +import org.dhis2.utils.granularsync.domain.MissingSyncTargetException +import org.hisp.dhis.android.core.D2 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class GranularSyncRepositoryTest { + private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val preferenceProvider: PreferenceProvider = mock() + private val dhisProgramUtils: DhisProgramUtils = mock() + private val periodUtils: DhisPeriodUtils = mock() + private val resourceManager: ResourceManager = mock() + private val testingDispatcher = UnconfinedTestDispatcher() + private val dispatcherProvider: DispatcherProvider = + mock { + on { io() } doReturn testingDispatcher + } + + @Before + fun setUp() { + Dispatchers.setMain(testingDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `should return error ui state when program is missing`() { + runTest { + whenever(d2.programModule().programs().uid("programUid").blockingGet()) doReturn null + + assertMissingSyncTarget( + syncContext = SyncContext.TrackerProgram("programUid"), + expectedRecordUid = "programUid", + ) + } + } + + @Test + fun `should return error ui state when tei is missing`() { + runTest { + whenever(d2.enrollmentModule().enrollments().uid("enrollmentUid").blockingGet()) doReturn null + whenever(d2.trackedEntityModule().trackedEntityInstances().uid("enrollmentUid").blockingGet()) doReturn null + + assertMissingSyncTarget( + syncContext = SyncContext.TrackerProgramTei("enrollmentUid"), + expectedRecordUid = "enrollmentUid", + ) + } + } + + @Test + fun `should return error ui state when event is missing`() { + runTest { + whenever(d2.eventModule().events().uid("eventUid").blockingGet()) doReturn null + + assertMissingSyncTarget( + syncContext = SyncContext.Event("eventUid"), + expectedRecordUid = "eventUid", + ) + } + } + + @Test + fun `should return error ui state when data set is missing`() { + runTest { + whenever(d2.dataSetModule().dataSets().uid("dataSetUid").blockingGet()) doReturn null + + assertMissingSyncTarget( + syncContext = SyncContext.DataSet("dataSetUid"), + expectedRecordUid = "dataSetUid", + ) + } + } + + private suspend fun assertMissingSyncTarget( + syncContext: SyncContext, + expectedRecordUid: String, + ) { + val repository = repositoryFor(syncContext) + + try { + repository.getSyncStatus() + fail("Expected MissingSyncTargetException") + } catch (exception: MissingSyncTargetException) { + assertEquals(expectedRecordUid, exception.recordUid) + } + } + + private fun repositoryFor(syncContext: SyncContext) = + GranularSyncRepository( + d2 = d2, + syncContext = syncContext, + preferenceProvider = preferenceProvider, + dhisProgramUtils = dhisProgramUtils, + periodUtils = periodUtils, + resourceManager = resourceManager, + dispatcher = dispatcherProvider, + ) +} diff --git a/app/src/test/java/org/dhis2/utils/session/PinPresenterTest.kt b/app/src/test/java/org/dhis2/utils/session/PinPresenterTest.kt deleted file mode 100644 index 09e73782e96..00000000000 --- a/app/src/test/java/org/dhis2/utils/session/PinPresenterTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.dhis2.utils.session - -import org.dhis2.commons.prefs.Preference -import org.dhis2.commons.prefs.PreferenceProvider -import org.hisp.dhis.android.core.D2 -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -class PinPresenterTest { - lateinit var presenter: PinPresenter - private var pinView: PinView = mock() - private var preferenceProvider: PreferenceProvider = mock() - private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) - - private val onPinCorrect: () -> Unit = mock() - private val onError: () -> Unit = mock() - private val onTwoManyAttempts: () -> Unit = mock() - - @Before - fun setUp() { - presenter = PinPresenter(pinView, preferenceProvider, d2) - } - - @Test - fun `Should return true if pin is correct`() { - val testPin = "testPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - d2.dataStoreModule().localDataStore().value(Preference.PIN), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet(), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value(), - ) doReturn testPin - - presenter.unlockSession( - testPin, - attempts = 0, - onError = onError, - onPinCorrect = onPinCorrect, - onTwoManyAttempts = onTwoManyAttempts, - ) - - // SESSION_LOCKED stays true (PIN is still configured), no state change - verify(onPinCorrect).invoke() - } - - @Test - fun `Should return false if pin is wrong`() { - val testPin = "testPin" - val wrongPin = "wrongPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - d2.dataStoreModule().localDataStore().value(Preference.PIN), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet(), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value(), - ) doReturn testPin - - presenter.unlockSession( - wrongPin, - attempts = 0, - onError = onError, - onPinCorrect = onPinCorrect, - onTwoManyAttempts = onTwoManyAttempts, - ) - - verify(onError).invoke() - } - - @Test - fun `Should call onTwoManyAttempts when try 3 times`() { - val testPin = "testPin" - val wrongPin = "wrongPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever( - d2.dataStoreModule().localDataStore().value(Preference.PIN), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet(), - ) doReturn mock() - whenever( - d2 - .dataStoreModule() - .localDataStore() - .value(Preference.PIN) - .blockingGet() - ?.value(), - ) doReturn testPin - - presenter.unlockSession( - wrongPin, - attempts = 3, - onError = onError, - onPinCorrect = onPinCorrect, - onTwoManyAttempts = onTwoManyAttempts, - ) - - verify(onTwoManyAttempts).invoke() - } - - @Test - fun `Should save pin and block session`() { - val testPin = "testPin" - - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore().value(Preference.PIN)) doReturn mock() - - presenter.savePin(testPin) - verify(d2.dataStoreModule().localDataStore().value(Preference.PIN)).blockingSet(testPin) - verify(preferenceProvider, times(1)).setValue(Preference.SESSION_LOCKED, true) - } - - @Test - fun `Should clear pin and block session when logout`() { - whenever(d2.dataStoreModule()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore()) doReturn mock() - whenever(d2.dataStoreModule().localDataStore().value(Preference.PIN)) doReturn mock() - - presenter.logOut() - - verify(d2.dataStoreModule().localDataStore().value(Preference.PIN)).blockingDelete() - verify(preferenceProvider, times(1)).setValue(Preference.SESSION_LOCKED, false) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 7b62571c3c3..a923bc4363b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,8 @@ plugins { alias(libs.plugins.kotlin.compose.compiler) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.cyclonedx) + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false } @@ -92,6 +94,8 @@ allprojects { } tasks.withType { + // ensures test results are not cached between test runs + outputs.upToDateWhen { false } afterSuite( KotlinClosure2({ desc: TestDescriptor, result: TestResult -> if (result.resultType == TestResult.ResultType.FAILURE) { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index da9af0110d0..3d207288041 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -2,8 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") - kotlin("kapt") + alias(libs.plugins.legacy.kapt) id("com.google.devtools.ksp") id("kotlin-parcelize") alias(libs.plugins.kotlin.compose.compiler) @@ -61,9 +60,12 @@ kotlin { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) - api(project(":ui-components")) implementation(project(":commonskmm")) api(libs.dhis2.android.sdk) { @@ -81,6 +83,7 @@ dependencies { api(libs.androidx.viewModelKtx) api(libs.androidx.recyclerView) debugApi(libs.androidx.compose.uitooling) + api(libs.androidx.compose.preview) api(libs.androidx.compose.ui) api(libs.androidx.compose.livedata) api(libs.androidx.compose.paging) @@ -103,16 +106,16 @@ dependencies { api(libs.barcodeScanner.zxing.android) { exclude("com.google.zxing", "core") } - testApi(libs.test.junit) - testApi(libs.test.mockitoCore) - testApi(libs.test.mockitoKotlin) - testApi(libs.test.mockitoInline) - androidTestApi(libs.test.mockitoCore) - androidTestApi(libs.test.mockitoKotlin) - androidTestApi(libs.test.dexmaker.mockitoInline) - androidTestApi(libs.test.junit.ext) - androidTestApi(libs.test.espresso) - androidTestApi(libs.test.espresso.idlingresource) + testImplementation(libs.test.junit) + testImplementation(libs.test.mockitoCore) + testImplementation(libs.test.mockitoKotlin) + testImplementation(libs.test.mockitoInline) + androidTestImplementation(libs.test.mockitoCore) + androidTestImplementation(libs.test.mockitoKotlin) + androidTestImplementation(libs.test.dexmaker.mockitoInline) + androidTestImplementation(libs.test.junit.ext) + androidTestImplementation(libs.test.espresso) + androidTestImplementation(libs.test.espresso.idlingresource) api(libs.test.espresso.idlingresource) api(libs.test.espresso.idlingconcurrent) api(libs.analytics.sentry) diff --git a/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt b/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt index a5bbdcd6ae5..3bf11e76cb1 100644 --- a/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/bindings/CommonExtensions.kt @@ -5,7 +5,6 @@ import android.graphics.Color import android.graphics.Outline import android.graphics.PorterDuff import android.graphics.Typeface -import android.os.Build import android.text.method.ScrollingMovementMethod import android.util.TypedValue import android.view.View @@ -116,45 +115,41 @@ val Int.px: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() fun View.clipWithRoundedCorners(curvedRadio: Int = 16.dp) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - outlineProvider = - object : ViewOutlineProvider() { - override fun getOutline( - view: View, - outline: Outline, - ) { - outline.setRoundRect( - 0, - 0, - view.width, - view.height + curvedRadio, - curvedRadio.toFloat(), - ) - } + outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline( + view: View, + outline: Outline, + ) { + outline.setRoundRect( + 0, + 0, + view.width, + view.height + curvedRadio, + curvedRadio.toFloat(), + ) } - clipToOutline = true - } + } + clipToOutline = true } fun View.clipWithAllRoundedCorners(curvedRadio: Int = 16.dp) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - outlineProvider = - object : ViewOutlineProvider() { - override fun getOutline( - view: View, - outline: Outline, - ) { - outline.setRoundRect( - 0, - 0, - view.width, - view.height, - curvedRadio.toFloat(), - ) - } + outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline( + view: View, + outline: Outline, + ) { + outline.setRoundRect( + 0, + 0, + view.width, + view.height, + curvedRadio.toFloat(), + ) } - clipToOutline = true - } + } + clipToOutline = true } fun HorizontalScrollView.scrollToPosition(viewTag: String) { diff --git a/commons/src/main/java/org/dhis2/commons/bindings/SdkExtensions.kt b/commons/src/main/java/org/dhis2/commons/bindings/SdkExtensions.kt index ee3b541d713..d60e42c8918 100644 --- a/commons/src/main/java/org/dhis2/commons/bindings/SdkExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/bindings/SdkExtensions.kt @@ -20,8 +20,6 @@ fun D2.programs(): List = programModule().programs().blockingGet() fun D2.program(programUid: String): Program? = programModule().programs().uid(programUid).blockingGet() -fun D2.observeProgram(programUid: String): Single = programModule().programs().uid(programUid).get() - fun D2.isStockProgram(programUid: String): Boolean = useCaseModule() .stockUseCases() @@ -74,12 +72,6 @@ fun D2.tei(teiUid: String) = .uid(teiUid) .blockingGet() -fun D2.observeTei(teiUid: String) = - trackedEntityModule() - .trackedEntityInstances() - .uid(teiUid) - .get() - fun D2.teisBy( programs: List? = null, aggregatedSynStates: List? = null, @@ -92,13 +84,6 @@ fun D2.teisBy( return repository.blockingGet() } -fun D2.teiImportConflicts(teiUid: String): List = - importModule() - .trackerImportConflicts() - .byTrackedEntityInstanceUid() - .eq(teiUid) - .blockingGet() - fun D2.teiImportConflictsBy( teiUid: String? = null, enrollmentUid: String? = null, @@ -217,12 +202,6 @@ fun D2.event(uid: String) = .uid(uid) .blockingGet() -fun D2.observeEvent(uid: String) = - eventModule() - .events() - .uid(uid) - .get() - fun D2.eventsBy( programUid: String? = null, enrollmentUid: String? = null, @@ -268,26 +247,6 @@ fun D2.dataSetInstancesBy( return repository.blockingGet() } -fun D2.observeDataSetInstancesBy( - dataSetUid: String? = null, - orgUnitUid: String? = null, - periodId: String? = null, - attrOptionComboUid: String? = null, - states: List? = null, -): Single> { - var repository = dataSetModule().dataSetInstances() - repository = dataSetUid?.let { repository.byDataSetUid().eq(dataSetUid) } ?: repository - repository = states?.let { - repository.byState().`in`(states) - } ?: repository - repository = orgUnitUid?.let { repository.byOrganisationUnitUid().eq(orgUnitUid) } ?: repository - repository = periodId?.let { repository.byPeriod().eq(periodId) } ?: repository - repository = attrOptionComboUid?.let { - repository.byAttributeOptionComboUid().eq(attrOptionComboUid) - } ?: repository - return repository.get() -} - fun D2.dataValueConflictsBy(dataSetUid: String): List = dataValueModule() .dataValueConflicts() @@ -311,23 +270,6 @@ fun D2.dataValueConflicts( .eq(attrOptionComboUid) .blockingGet() -fun D2.countDataValueConflicts( - dataSetUid: String, - periodId: String, - orgUnitUid: String, - attrOptionComboUid: String, -): Int = - dataValueModule() - .dataValueConflicts() - .byDataSet(dataSetUid) - .byPeriod() - .eq(periodId) - .byOrganisationUnitUid() - .eq(orgUnitUid) - .byAttributeOptionCombo() - .eq(attrOptionComboUid) - .blockingCount() - fun D2.period(periodId: String) = periodModule() .periods() diff --git a/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt b/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt index 1e8c74bf814..9b529192065 100644 --- a/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/bindings/TEICardExtensions.kt @@ -1,13 +1,5 @@ package org.dhis2.commons.bindings -import android.content.Context -import android.graphics.drawable.GradientDrawable -import android.view.LayoutInflater -import android.view.View -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -27,18 +19,11 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.toColorInt import org.dhis2.commons.R import org.dhis2.commons.data.EnrollmentIconData -import org.dhis2.commons.databinding.ItemFieldValueBinding -import org.dhis2.commons.date.toUiText import org.dhis2.mobile.commons.extensions.toColor import org.dhis2.mobile.commons.model.MetadataIconData -import org.hisp.dhis.android.core.enrollment.Enrollment -import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import org.hisp.dhis.android.core.program.Program -import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue import org.hisp.dhis.mobile.ui.designsystem.component.MetadataAvatar import org.hisp.dhis.mobile.ui.designsystem.component.MetadataAvatarSize import org.hisp.dhis.mobile.ui.designsystem.component.MetadataIcon @@ -49,12 +34,6 @@ import java.util.Date const val ENROLLMENT_ICONS_TO_SHOW = 3 const val MAX_NUMBER_REMAINING_ENROLLMENTS = 99 -fun List.hasFollowUp(): Boolean = - firstOrNull { enrollment -> - enrollment.followUp() == true - }?.let { - it.followUp() == true - } ?: false fun List.getEnrollmentIconsData( currentProgram: String?, @@ -146,7 +125,7 @@ private fun SquareWithNumber(number: Int) { text = "+$number", color = "#6f6f6f".toColor(), textAlign = TextAlign.Center, - fontFamily = FontFamily(Font(org.dhis2.ui.R.font.rubik_regular)), + fontFamily = FontFamily(Font(R.font.rubik_regular)), ) } } @@ -158,99 +137,3 @@ fun getRemainingEnrollmentsForTei(teiEnrollmentCount: Int): Int = teiEnrollmentCount - ENROLLMENT_ICONS_TO_SHOW } -fun Enrollment.setStatusText( - context: Context, - statusTextView: TextView, - isOverdue: Boolean, - dueDate: Date?, -) { - var textToShow: String? = null - var color = -1 - when { - isOverdue -> { - textToShow = dueDate.toUiText(context) - color = "#E91E63".toColorInt() - } - - status() == EnrollmentStatus.CANCELLED -> { - textToShow = context.getString(R.string.cancelled) - color = "#E91E63".toColorInt() - } - - status() == EnrollmentStatus.COMPLETED -> { - textToShow = context.getString(R.string.enrollment_status_completed) - color = "#8A333333".toColorInt() - } - } - statusTextView.visibility = if (textToShow == null) View.GONE else View.VISIBLE - statusTextView.text = textToShow - statusTextView.setTextColor(color) - val bgDrawable = - AppCompatResources.getDrawable( - context, - R.drawable.round_border_box_2, - ) as GradientDrawable? - bgDrawable!!.setStroke(2, color) - statusTextView.background = bgDrawable -} - -fun LinkedHashMap.setAttributeList( - parentLayout: LinearLayout, - showAttributesButton: ImageView, - adapterPosition: Int, - listIsOpen: Boolean, - sortingKey: String?, - sortingValue: String?, - orgUnit: String, - showList: () -> Unit, -) { - parentLayout.removeAllViews() - if (size > 3) { - for (pos in 1 until size) { - val fieldName = - keys.toTypedArray()[pos] - val fieldValue = this[fieldName]?.value() - val itemFieldValueBinding = - ItemFieldValueBinding.inflate(LayoutInflater.from(parentLayout.context)) - itemFieldValueBinding.name = fieldName - itemFieldValueBinding.value = fieldValue - itemFieldValueBinding.root.tag = adapterPosition.toString() + "_" + fieldName - parentLayout.addView(itemFieldValueBinding.root) - } - val orgUnitKey = parentLayout.context.getString(R.string.enrolled_in) - val itemFieldValueBinding = - ItemFieldValueBinding.inflate(LayoutInflater.from(parentLayout.context)) - itemFieldValueBinding.name = orgUnitKey - itemFieldValueBinding.value = orgUnit - itemFieldValueBinding.root.tag = adapterPosition.toString() + "_" + orgUnitKey - parentLayout.addView(itemFieldValueBinding.root) - if (sortingKey != null) { - val binding = - ItemFieldValueBinding.inflate(LayoutInflater.from(parentLayout.context)) - binding.name = sortingKey - binding.fieldName.setTextColor( - ResourcesCompat.getColor( - binding.fieldName.context.resources, - R.color.sorting_attribute_key_color, - null, - ), - ) - binding.value = sortingValue - binding.fieldValue.setTextColor( - ResourcesCompat.getColor( - binding.fieldValue.context.resources, - R.color.sorting_attribute_value_color, - null, - ), - ) - binding.root.tag = adapterPosition.toString() + "_" + sortingValue - parentLayout.addView(binding.root) - } - showAttributesButton.scaleY = if (listIsOpen) -1F else 1F - showAttributesButton.setOnClickListener { - showList() - } - } else { - showAttributesButton.setOnClickListener(null) - } -} diff --git a/commons/src/main/java/org/dhis2/commons/data/CarouselItemModel.kt b/commons/src/main/java/org/dhis2/commons/data/CarouselItemModel.kt deleted file mode 100644 index be453285d0f..00000000000 --- a/commons/src/main/java/org/dhis2/commons/data/CarouselItemModel.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.dhis2.commons.data - -interface CarouselItemModel { - fun uid(): String -} diff --git a/commons/src/main/java/org/dhis2/commons/data/ProgramEventViewModel.kt b/commons/src/main/java/org/dhis2/commons/data/ProgramEventViewModel.kt index 289249854e0..342aa2d155f 100644 --- a/commons/src/main/java/org/dhis2/commons/data/ProgramEventViewModel.kt +++ b/commons/src/main/java/org/dhis2/commons/data/ProgramEventViewModel.kt @@ -17,6 +17,4 @@ data class ProgramEventViewModel( val attributeOptionComboName: String, val geometry: Geometry?, val canBeEdited: Boolean, -) : CarouselItemModel { - override fun uid() = uid -} +) diff --git a/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt b/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt index 478779db2ab..938b3241953 100644 --- a/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt +++ b/commons/src/main/java/org/dhis2/commons/date/DateExtensions.kt @@ -186,10 +186,11 @@ fun Date?.toUi(): String? = this?.let { DateUtils.uiDateFormat().format(this) } fun Dhis2PeriodType.toUiStringResource() = when (this) { Dhis2PeriodType.Weekly, + Dhis2PeriodType.WeeklyWednesday, + Dhis2PeriodType.WeeklyThursday, + Dhis2PeriodType.WeeklyFriday, Dhis2PeriodType.WeeklySaturday, Dhis2PeriodType.WeeklySunday, - Dhis2PeriodType.WeeklyThursday, - Dhis2PeriodType.WeeklyWednesday, -> R.string.period_weekly_title Dhis2PeriodType.BiWeekly -> R.string.period_biweekly_title @@ -205,8 +206,11 @@ fun Dhis2PeriodType.toUiStringResource() = -> R.string.period_six_monthly_title Dhis2PeriodType.Yearly -> R.string.period_yearly_title + Dhis2PeriodType.FinancialFeb, Dhis2PeriodType.FinancialApril, Dhis2PeriodType.FinancialJuly, + Dhis2PeriodType.FinancialAug, + Dhis2PeriodType.FinancialSep, Dhis2PeriodType.FinancialOct, Dhis2PeriodType.FinancialNov, -> R.string.period_financial_year_title diff --git a/commons/src/main/java/org/dhis2/commons/di/FilterModule.kt b/commons/src/main/java/org/dhis2/commons/di/FilterModule.kt new file mode 100644 index 00000000000..acf03d69a6c --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/di/FilterModule.kt @@ -0,0 +1,82 @@ +package org.dhis2.commons.di + +import org.dhis2.commons.filters.FilterManager +import org.dhis2.commons.filters.FilterResources +import org.dhis2.commons.filters.data.FilterPresenter +import org.dhis2.commons.filters.data.FilterRepository +import org.dhis2.commons.filters.data.GetFiltersApplyingWebAppConfig +import org.dhis2.commons.filters.workingLists.EventFilterToWorkingListItemMapper +import org.dhis2.commons.filters.workingLists.ProgramStageToWorkingListItemMapper +import org.dhis2.commons.filters.workingLists.TeiFilterToWorkingListItemMapper +import org.dhis2.commons.resources.EventResourcesProvider +import org.dhis2.commons.resources.ResourceManager +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +val mainFilterModule = + module { + // Singleton for GetFiltersApplyingWebAppConfig + single { + GetFiltersApplyingWebAppConfig() + } + + // FilterManager - singleton per server + single { + FilterManager.initWith(get()) + } + + // FilterResources + factory { + FilterResources( + resourceManager = get(), + eventResourcesProvider = get(), + ) + } + + // EventResourcesProvider + factory { + EventResourcesProvider( + d2 = get(), + resourceManager = get(), + ) + } + + // Working list mappers + factory { + EventFilterToWorkingListItemMapper( + defaultWorkingListLabel = get().defaultWorkingListLabel(), + ) + } + + factory { + TeiFilterToWorkingListItemMapper( + defaultWorkingListLabel = get().defaultWorkingListLabel(), + ) + } + + factory { + ProgramStageToWorkingListItemMapper( + defaultWorkingListLabel = get().defaultWorkingListLabel(), + ) + } + + // FilterRepository + factory { + FilterRepository( + d2 = get(), + resources = get(), + getFiltersApplyingWebAppConfig = get(), + eventFilterToWorkingListItemMapper = get(), + teiFilterToWorkingListItemMapper = get(), + programStageToWorkingListItemMapper = get(), + ) + } + + // FilterPresenter + factoryOf(::FilterPresenter) + } + +val filterModule = + module { + includes(mainFilterModule, resourceManagerModule) + } diff --git a/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt b/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt index e67a160e02a..8f98dff4375 100644 --- a/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt +++ b/commons/src/main/java/org/dhis2/commons/di/ResourceManagerModule.kt @@ -3,6 +3,7 @@ package org.dhis2.commons.di import org.dhis2.commons.periods.data.PeriodLabelProvider import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager +import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val resourceManagerModule = @@ -10,8 +11,9 @@ val resourceManagerModule = single { ColorUtils() } - factory { params -> - ResourceManager(params.get(), get()) + + single { + ResourceManager(androidContext(), get()) } factory { diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt index 411f9948d65..fa052662cd2 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialog.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.text.style.TextDecoration import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonBlock diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt index 9f18de0dcb7..f1ab514d53a 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/BottomSheetDialogContent.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt index fd31a01b0d0..112d4979088 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DeleteBottomSheetDialog.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonBlock diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt index 6261f09a93d..d372c0e526d 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/bottomsheet/DialogButtonStyle.kt @@ -1,7 +1,7 @@ package org.dhis2.commons.dialogs.bottomsheet import androidx.compose.ui.graphics.Color -import org.dhis2.ui.R +import org.dhis2.commons.R import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor diff --git a/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt b/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt index 97b8897df33..67433ea0706 100644 --- a/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt +++ b/commons/src/main/java/org/dhis2/commons/dialogs/calendarpicker/CalendarPicker.kt @@ -3,12 +3,9 @@ package org.dhis2.commons.dialogs.calendarpicker import android.app.AlertDialog import android.app.Dialog import android.content.Context -import android.os.Build import android.view.LayoutInflater import android.view.View import android.widget.DatePicker -import android.widget.LinearLayout -import androidx.databinding.BindingAdapter import org.dhis2.commons.R import org.dhis2.commons.databinding.CalendarPickerViewBinding import org.dhis2.commons.dialogs.calendarpicker.di.CalendarPickerComponentProvider @@ -174,13 +171,3 @@ class CalendarPicker( } } } - -@BindingAdapter("versionCustomVisibility") -fun setCustomVisibility( - linearLayout: LinearLayout, - check: Boolean, -) { - if (check && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - linearLayout.visibility = View.GONE - } -} 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 fb2d4a6b163..d993da4e34f 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java @@ -37,7 +37,6 @@ import kotlinx.coroutines.CoroutineScope; import kotlinx.coroutines.flow.Flow; import kotlinx.coroutines.flow.MutableSharedFlow; -import kotlinx.coroutines.flow.MutableStateFlow; import timber.log.Timber; public class FilterManager implements Serializable { @@ -590,7 +589,7 @@ public void clearAllFilters() { stateFilters.clear(); observableStates.postValue(stateFilters); ouFilters.clear(); - liveDataOUFilter.setValue(ouFilters); + liveDataOUFilter.postValue(ouFilters); periodFilters = new ArrayList<>(); observablePeriodFilters.set(periodFilters); enrollmentPeriodFilters = new ArrayList<>(); @@ -684,7 +683,7 @@ public boolean workingListActive() { } public void setWorkingListScope(WorkingListScope scope) { - if (!currentWorkingListScope.get().equals(scope)) { + if (!Objects.equals(currentWorkingListScope.get().workingListUid(), scope.workingListUid())) { currentWorkingListScope.set(scope); setFilterCountersForWorkingList(scope); } diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/EventProgramFilterSearchHelper.kt b/commons/src/main/java/org/dhis2/commons/filters/data/EventProgramFilterSearchHelper.kt index 0be3b29d4c8..08262650f92 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/EventProgramFilterSearchHelper.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/EventProgramFilterSearchHelper.kt @@ -40,6 +40,7 @@ class EventProgramFilterSearchHelper filterManager.setWorkingListScope( it.scope.mapToEventWorkingListScope( filterRepository.resources, + filterManager.currentWorkingList()?.uid, ), ) } diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt b/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt index b9e2fd347e5..a857bc0013f 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt @@ -34,15 +34,15 @@ class FilterPresenter fun filteredEventProgram(program: Program): EventQueryCollectionRepository = eventProgramFilterSearchHelper.getFilteredEventRepository(program) - fun filteredTrackerProgram(program: Program): TrackedEntitySearchCollectionRepository = - trackerFilterSearchHelper.getFilteredProgramRepository(program.uid()) + fun filteredTrackerProgram(program: String): TrackedEntitySearchCollectionRepository = + trackerFilterSearchHelper.getFilteredProgramRepository(program) fun filteredTrackedEntityTypes(trackedEntityTypeUid: String): TrackedEntitySearchCollectionRepository = trackerFilterSearchHelper .getFilteredTrackedEntityTypeRepository(trackedEntityTypeUid) fun filteredTrackedEntityInstances( - program: Program?, + program: String?, trackedEntityTypeUid: String, ): TrackedEntitySearchCollectionRepository = program?.let { filteredTrackerProgram(program) } 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 ab3d029da70..7387f2b32ba 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 @@ -44,187 +44,207 @@ import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchCollec import javax.inject.Inject class FilterRepository - @Inject - constructor( - private val d2: D2, - val resources: FilterResources, - private val getFiltersApplyingWebAppConfig: GetFiltersApplyingWebAppConfig, - private val eventFilterToWorkingListItemMapper: EventFilterToWorkingListItemMapper, - private val teiFilterToWorkingListItemMapper: TeiFilterToWorkingListItemMapper, - private val programStageToWorkingListItemMapper: ProgramStageToWorkingListItemMapper, - ) { - private val observableSortingInject = ObservableField() - private val observableOpenFilter = ObservableField() - private var orgUnitsCount: Int = -1 - - init { - orgUnitsCount = - d2 - .organisationUnitModule() - .organisationUnits() - .byOrganisationUnitScope(OrganisationUnit.Scope.SCOPE_TEI_SEARCH) - .blockingCount() - } - - fun trackedEntityInstanceQueryByProgram(programUid: String): TrackedEntitySearchCollectionRepository = - d2 - .trackedEntityModule() - .trackedEntitySearch() - .byProgram() - .eq(programUid) - - fun trackedEntityInstanceQueryByType(trackedEntityTypeUid: String): TrackedEntitySearchCollectionRepository = - d2 - .trackedEntityModule() - .trackedEntitySearch() - .byTrackedEntityType() - .eq(trackedEntityTypeUid) - - fun rootOrganisationUnitUids(): List = - d2 - .organisationUnitModule() - .organisationUnits() - .byRootOrganisationUnit(true) - .blockingGetUids() - - fun applyEnrollmentStatusFilter( - repository: TrackedEntitySearchCollectionRepository, - enrollmentStatuses: List, - ): TrackedEntitySearchCollectionRepository = repository.byEnrollmentStatus().`in`(enrollmentStatuses) - - fun applyEventStatusFilter( - repository: TrackedEntitySearchCollectionRepository, - eventStatuses: List, - ): TrackedEntitySearchCollectionRepository = repository.byEventStatus().`in`(eventStatuses) - - fun applyEventStatusFilter( - repository: EventQueryCollectionRepository, - eventStatuses: List, - ): EventQueryCollectionRepository = repository.byStatus().`in`(eventStatuses) - - fun applyCategoryOptionComboFilter( - repository: EventQueryCollectionRepository, - categoryOptionCombos: List, - ): EventQueryCollectionRepository = - repository.byAttributeOptionCombo().`in`( - categoryOptionCombos.map { it.uid() }, - ) - - fun applyOrgUnitFilter( - repository: TrackedEntitySearchCollectionRepository, - ouMode: OrganisationUnitMode, - orgUnitUis: List, - ): TrackedEntitySearchCollectionRepository = - repository - .byOrgUnitMode() - .eq(ouMode) - .byOrgUnits() - .`in`(orgUnitUis) - - fun applyStateFilter( - repository: TrackedEntitySearchCollectionRepository, - states: List, - ): TrackedEntitySearchCollectionRepository = repository.byStates().`in`(states) - - fun applyDateFilter( - repository: TrackedEntitySearchCollectionRepository, - datePeriod: DatePeriod, - ): TrackedEntitySearchCollectionRepository = repository.byEventDate().inDatePeriod(datePeriod) - - fun applyEnrollmentDateFilter( - repository: TrackedEntitySearchCollectionRepository, - datePeriod: DatePeriod, - ): TrackedEntitySearchCollectionRepository = repository.byProgramDate().inDatePeriod(datePeriod) - - fun applyAssignToMe(repository: TrackedEntitySearchCollectionRepository): TrackedEntitySearchCollectionRepository = - repository.byAssignedUserMode().eq(AssignedUserMode.CURRENT) - - fun applyFollowUp(repository: TrackedEntitySearchCollectionRepository): TrackedEntitySearchCollectionRepository = - repository.byFollowUp().isTrue - - fun sortByPeriod( - repository: TrackedEntitySearchCollectionRepository, - orderDirection: RepositoryScope.OrderByDirection, - ): TrackedEntitySearchCollectionRepository = repository.orderByEventDate().eq(orderDirection) - - fun sortByOrgUnit( - repository: TrackedEntitySearchCollectionRepository, - orderDirection: RepositoryScope.OrderByDirection, - ): TrackedEntitySearchCollectionRepository = repository.orderByOrganisationUnitName().eq(orderDirection) - - fun sortByEnrollmentDate( - repository: TrackedEntitySearchCollectionRepository, - orderDirection: RepositoryScope.OrderByDirection, - ): TrackedEntitySearchCollectionRepository = repository.orderByEnrollmentDate().eq(orderDirection) - - fun sortByEnrollmentStatus( - repository: TrackedEntitySearchCollectionRepository, - orderDirection: RepositoryScope.OrderByDirection, - ): TrackedEntitySearchCollectionRepository = repository.orderByEnrollmentStatus().eq(orderDirection) - - fun eventsByProgram(programUid: String): EventQueryCollectionRepository = - d2 - .eventModule() - .eventQuery() - .byIncludeDeleted() - .eq(false) - .byProgram() - .eq(programUid) - - fun applyOrgUnitFilter( - repository: EventQueryCollectionRepository, - orgUnitUis: List, - ): EventQueryCollectionRepository = repository.byOrgUnits().`in`(orgUnitUis) - - fun applyStateFilter( - repository: EventQueryCollectionRepository, - states: List, - ): EventQueryCollectionRepository = repository.byStates().`in`(states) - - fun applyDateFilter( - repository: EventQueryCollectionRepository, - datePeriod: DatePeriod, - ): EventQueryCollectionRepository = repository.byEventDate().inDatePeriod(datePeriod) - - fun applyAssignToMe(repository: EventQueryCollectionRepository): EventQueryCollectionRepository = - repository.byAssignedUser().eq(AssignedUserMode.CURRENT) - - fun sortByEventDate( - repository: EventQueryCollectionRepository, - orderDirection: RepositoryScope.OrderByDirection, - ): EventQueryCollectionRepository = repository.orderByEventDate().eq(orderDirection) - - fun sortByOrgUnit( - repository: EventQueryCollectionRepository, - orderDirection: RepositoryScope.OrderByDirection, - ): EventQueryCollectionRepository = repository.orderByOrganisationUnitName().eq(orderDirection) - - fun dataSetInstanceSummaries(): DataSetInstanceSummaryCollectionRepository = d2.dataSetModule().dataSetInstanceSummaries() - - fun applyOrgUnitFilter( - repository: DataSetInstanceSummaryCollectionRepository, - orgUnitUis: List, - ): DataSetInstanceSummaryCollectionRepository = repository.byOrganisationUnitUid().`in`(orgUnitUis) - - fun applyStateFilter( - repository: DataSetInstanceSummaryCollectionRepository, - states: List, - ): DataSetInstanceSummaryCollectionRepository = repository.byState().`in`(states) - - fun applyPeriodFilter( - repository: DataSetInstanceSummaryCollectionRepository, - datePeriods: List, - ): DataSetInstanceSummaryCollectionRepository = repository.byPeriodStartDate().inDatePeriods(datePeriods) - - fun orgUnitsByName(name: String): List = - d2 - .organisationUnitModule() - .organisationUnits() - .byDisplayName() - .like("%$name%") - .blockingGet() +@Inject +constructor( + private val d2: D2, + val resources: FilterResources, + private val getFiltersApplyingWebAppConfig: GetFiltersApplyingWebAppConfig, + private val eventFilterToWorkingListItemMapper: EventFilterToWorkingListItemMapper, + private val teiFilterToWorkingListItemMapper: TeiFilterToWorkingListItemMapper, + private val programStageToWorkingListItemMapper: ProgramStageToWorkingListItemMapper, +) { + private val observableSortingInject = ObservableField() + private val observableOpenFilter = ObservableField() + private fun orgUnitsCount() = + d2 + .organisationUnitModule() + .organisationUnits() + .byOrganisationUnitScope(OrganisationUnit.Scope.SCOPE_TEI_SEARCH) + .blockingCount() + + fun trackedEntityInstanceQueryByProgram(programUid: String): TrackedEntitySearchCollectionRepository = + d2 + .trackedEntityModule() + .trackedEntitySearch() + .byProgram() + .eq(programUid) + + fun trackedEntityInstanceQueryByType(trackedEntityTypeUid: String): TrackedEntitySearchCollectionRepository = + d2 + .trackedEntityModule() + .trackedEntitySearch() + .byTrackedEntityType() + .eq(trackedEntityTypeUid) + + fun rootOrganisationUnitUids(): List = + d2 + .organisationUnitModule() + .organisationUnits() + .byRootOrganisationUnit(true) + .blockingGetUids() + + fun applyEnrollmentStatusFilter( + repository: TrackedEntitySearchCollectionRepository, + enrollmentStatuses: List, + ): TrackedEntitySearchCollectionRepository = + repository.byEnrollmentStatus().`in`(enrollmentStatuses) + + fun applyEventStatusFilter( + repository: TrackedEntitySearchCollectionRepository, + eventStatuses: List, + ): TrackedEntitySearchCollectionRepository = repository.byEventStatus().`in`(eventStatuses) + + fun applyEventStatusFilter( + repository: EventQueryCollectionRepository, + eventStatuses: List, + ): EventQueryCollectionRepository = repository.byStatus().`in`(eventStatuses) + + fun applyCategoryOptionComboFilter( + repository: EventQueryCollectionRepository, + categoryOptionCombos: List, + ): EventQueryCollectionRepository = + repository.byAttributeOptionCombo().`in`( + categoryOptionCombos.map { it.uid() }, + ) + + fun applyOrgUnitFilter( + repository: TrackedEntitySearchCollectionRepository, + ouMode: OrganisationUnitMode, + orgUnitUis: List, + ): TrackedEntitySearchCollectionRepository = + repository + .byOrgUnitMode() + .eq(ouMode) + .byOrgUnits() + .`in`(orgUnitUis) + + fun applyStateFilter( + repository: TrackedEntitySearchCollectionRepository, + states: List, + ): TrackedEntitySearchCollectionRepository = repository.byStates().`in`(states) + + fun applyDateFilter( + repository: TrackedEntitySearchCollectionRepository, + datePeriod: DatePeriod, + ): TrackedEntitySearchCollectionRepository = repository.byEventDate().inDatePeriod(datePeriod) + + fun applyEnrollmentDateFilter( + repository: TrackedEntitySearchCollectionRepository, + datePeriod: DatePeriod, + ): TrackedEntitySearchCollectionRepository = repository.byProgramDate().inDatePeriod(datePeriod) + + fun applyAssignToMe(repository: TrackedEntitySearchCollectionRepository): TrackedEntitySearchCollectionRepository = + repository.byAssignedUserMode().eq(AssignedUserMode.CURRENT) + + fun applyFollowUp(repository: TrackedEntitySearchCollectionRepository): TrackedEntitySearchCollectionRepository = + repository.byFollowUp().isTrue + + fun sortByPeriod( + repository: TrackedEntitySearchCollectionRepository, + orderDirection: RepositoryScope.OrderByDirection, + ): TrackedEntitySearchCollectionRepository = repository.orderByEventDate().eq(orderDirection) + + fun sortByOrgUnit( + repository: TrackedEntitySearchCollectionRepository, + orderDirection: RepositoryScope.OrderByDirection, + ): TrackedEntitySearchCollectionRepository = + repository.orderByOrganisationUnitName().eq(orderDirection) + + fun sortByEnrollmentDate( + repository: TrackedEntitySearchCollectionRepository, + orderDirection: RepositoryScope.OrderByDirection, + ): TrackedEntitySearchCollectionRepository = + repository.orderByEnrollmentDate().eq(orderDirection) + + fun sortByEnrollmentStatus( + repository: TrackedEntitySearchCollectionRepository, + orderDirection: RepositoryScope.OrderByDirection, + ): TrackedEntitySearchCollectionRepository = + repository.orderByEnrollmentStatus().eq(orderDirection) + + fun eventsByProgram(programUid: String): EventQueryCollectionRepository = + d2 + .eventModule() + .eventQuery() + .byIncludeDeleted() + .eq(false) + .byProgram() + .eq(programUid) + + fun applyOrgUnitFilter( + repository: EventQueryCollectionRepository, + orgUnitUis: List, + ): EventQueryCollectionRepository = repository.byOrgUnits().`in`(orgUnitUis) + + fun applyStateFilter( + repository: EventQueryCollectionRepository, + states: List, + ): EventQueryCollectionRepository = repository.byStates().`in`(states) + + fun applyDateFilter( + repository: EventQueryCollectionRepository, + datePeriod: DatePeriod, + ): EventQueryCollectionRepository = repository.byEventDate().inDatePeriod(datePeriod) + + fun applyAssignToMe(repository: EventQueryCollectionRepository): EventQueryCollectionRepository = + repository.byAssignedUser().eq(AssignedUserMode.CURRENT) + + fun sortByEventDate( + repository: EventQueryCollectionRepository, + orderDirection: RepositoryScope.OrderByDirection, + ): EventQueryCollectionRepository = repository.orderByEventDate().eq(orderDirection) + + fun sortByOrgUnit( + repository: EventQueryCollectionRepository, + orderDirection: RepositoryScope.OrderByDirection, + ): EventQueryCollectionRepository = repository.orderByOrganisationUnitName().eq(orderDirection) + + fun dataSetInstanceSummaries(): DataSetInstanceSummaryCollectionRepository = + d2.dataSetModule().dataSetInstanceSummaries() + + fun applyOrgUnitFilter( + repository: DataSetInstanceSummaryCollectionRepository, + orgUnitUis: List, + ): DataSetInstanceSummaryCollectionRepository = + repository.byOrganisationUnitUid().`in`(orgUnitUis) + + fun applyStateFilter( + repository: DataSetInstanceSummaryCollectionRepository, + states: List, + ): DataSetInstanceSummaryCollectionRepository = repository.byState().`in`(states) + + fun applyPeriodFilter( + repository: DataSetInstanceSummaryCollectionRepository, + datePeriods: List, + ): DataSetInstanceSummaryCollectionRepository = + repository.byPeriodStartDate().inDatePeriods(datePeriods) + + fun orgUnitsByName(name: String): List = + d2 + .organisationUnitModule() + .organisationUnits() + .byDisplayName() + .like("%$name%") + .blockingGet() + + fun programFilters(programUid: String): List = + d2 + .programModule() + .programs() + .uid(programUid) + .get() + .map { + if (it.programType() == + org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION + ) { + getTrackerFilters(it) + } else { + getEventFilters(it, ProgramType.EVENT) + } + }.blockingGet() - fun programFilters(programUid: String): List = + fun workingListFilter(programUid: String): WorkingListFilter? = + try { d2 .programModule() .programs() @@ -234,75 +254,59 @@ class FilterRepository if (it.programType() == org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION ) { - getTrackerFilters(it) + getTrackerWorkingList(it) } else { - getEventFilters(it, ProgramType.EVENT) + getEventWorkingList(it) } }.blockingGet() + } catch (e: Exception) { + null + } - fun workingListFilter(programUid: String): WorkingListFilter? = - try { - d2 - .programModule() - .programs() - .uid(programUid) - .get() - .map { - if (it.programType() == - org.hisp.dhis.android.core.program.ProgramType.WITH_REGISTRATION - ) { - getTrackerWorkingList(it) - } else { - getEventWorkingList(it) - } - }.blockingGet() - } catch (e: Exception) { - null + fun dashboardFilters(programUid: String): List = + d2 + .programModule() + .programs() + .uid(programUid) + .get() + .map { + getEventFilters(it, ProgramType.TRACKER) + }.blockingGet() + + fun globalTrackedEntityFilters(): List { + val orgUnitsCount = orgUnitsCount() + val defaultFilters = createDefaultTrackedEntityFilters() + + if (webAppIsNotConfigured()) { + if (orgUnitsCount == 1) { + defaultFilters.remove(ProgramFilter.ORG_UNIT) } + return defaultFilters.values.toList() + } - fun dashboardFilters(programUid: String): List = + val globalTrackedEntityTypeFiltersWebApp = d2 - .programModule() - .programs() - .uid(programUid) - .get() - .map { - getEventFilters(it, ProgramType.TRACKER) - }.blockingGet() + .settingModule() + .appearanceSettings() + .getTrackedEntityTypeFilters() + ?.toMutableMap() ?: mutableMapOf() - fun globalTrackedEntityFilters(): List { - val defaultFilters = createDefaultTrackedEntityFilters() + globalTrackedEntityTypeFiltersWebApp.remove(ProgramFilter.ASSIGNED_TO_ME) + globalTrackedEntityTypeFiltersWebApp.remove(ProgramFilter.ENROLLMENT_DATE) - if (webAppIsNotConfigured()) { - if (orgUnitsCount == 1) { - defaultFilters.remove(ProgramFilter.ORG_UNIT) - } - return defaultFilters.values.toList() - } - - val globalTrackedEntityTypeFiltersWebApp = - d2 - .settingModule() - .appearanceSettings() - .getTrackedEntityTypeFilters() - ?.toMutableMap() ?: mutableMapOf() - - globalTrackedEntityTypeFiltersWebApp.remove(ProgramFilter.ASSIGNED_TO_ME) - globalTrackedEntityTypeFiltersWebApp.remove(ProgramFilter.ENROLLMENT_DATE) - - if (orgUnitsCount == 1) { - globalTrackedEntityTypeFiltersWebApp.remove(ProgramFilter.ORG_UNIT) - } - - return getFiltersApplyingWebAppConfig.execute( - defaultFilters, - globalTrackedEntityTypeFiltersWebApp, - ) + if (orgUnitsCount == 1) { + globalTrackedEntityTypeFiltersWebApp.remove(ProgramFilter.ORG_UNIT) } - private fun createDefaultTrackedEntityFilters(): LinkedHashMap = - linkedMapOf( - ProgramFilter.ORG_UNIT to + return getFiltersApplyingWebAppConfig.execute( + defaultFilters, + globalTrackedEntityTypeFiltersWebApp, + ) + } + + private fun createDefaultTrackedEntityFilters(): LinkedHashMap = + linkedMapOf( + ProgramFilter.ORG_UNIT to OrgUnitFilter( FilterManager.getInstance().observeOrgUnitFilters(), ProgramType.TRACKER, @@ -310,50 +314,52 @@ class FilterRepository observableOpenFilter, resources.filterOrgUnitLabel(), ), - ProgramFilter.SYNC_STATUS to + ProgramFilter.SYNC_STATUS to SyncStateFilter( ProgramType.TRACKER, observableSortingInject, observableOpenFilter, resources.filterSyncLabel(), ), - ) + ) - fun dataSetFilters(dataSetUid: String): List { - val defaultFilters = createDefaultDatasetFilters(dataSetUid) + fun dataSetFilters(dataSetUid: String): List { + val orgUnitsCount = orgUnitsCount() - if (webAppIsNotConfigured()) { - if (orgUnitsCount == 1) { - defaultFilters.remove(DataSetFilter.ORG_UNIT) - } - return defaultFilters.values.toList() - } - - val datasetFiltersWebApp = - d2 - .settingModule() - .appearanceSettings() - .getDataSetFiltersByUid(dataSetUid) - ?.toMutableMap() ?: mutableMapOf() + val defaultFilters = createDefaultDatasetFilters(dataSetUid) + if (webAppIsNotConfigured()) { if (orgUnitsCount == 1) { - datasetFiltersWebApp.remove(DataSetFilter.ORG_UNIT) + defaultFilters.remove(DataSetFilter.ORG_UNIT) } + return defaultFilters.values.toList() + } - return getFiltersApplyingWebAppConfig.execute(defaultFilters, datasetFiltersWebApp) + val datasetFiltersWebApp = + d2 + .settingModule() + .appearanceSettings() + .getDataSetFiltersByUid(dataSetUid) + ?.toMutableMap() ?: mutableMapOf() + + if (orgUnitsCount == 1) { + datasetFiltersWebApp.remove(DataSetFilter.ORG_UNIT) } - private fun createDefaultDatasetFilters(dataSetUid: String): LinkedHashMap { - val datasetFilters = - linkedMapOf( - DataSetFilter.PERIOD to + return getFiltersApplyingWebAppConfig.execute(defaultFilters, datasetFiltersWebApp) + } + + private fun createDefaultDatasetFilters(dataSetUid: String): LinkedHashMap { + val datasetFilters = + linkedMapOf( + DataSetFilter.PERIOD to PeriodFilter( ProgramType.DATASET, observableSortingInject, observableOpenFilter, resources.filterPeriodLabel(), ), - DataSetFilter.ORG_UNIT to + DataSetFilter.ORG_UNIT to OrgUnitFilter( FilterManager.getInstance().observeOrgUnitFilters(), ProgramType.DATASET, @@ -361,81 +367,82 @@ class FilterRepository observableOpenFilter, resources.filterOrgUnitLabel(), ), - DataSetFilter.SYNC_STATUS to + DataSetFilter.SYNC_STATUS to SyncStateFilter( ProgramType.DATASET, observableSortingInject, observableOpenFilter, resources.filterSyncLabel(), ), - ) + ) - val dataSet = - d2 - .dataSetModule() - .dataSets() - .uid(dataSetUid) - .blockingGet() - val categoryCombo = + val dataSet = + d2 + .dataSetModule() + .dataSets() + .uid(dataSetUid) + .blockingGet() + val categoryCombo = + d2 + .categoryModule() + .categoryCombos() + .uid(dataSet?.categoryCombo()?.uid()) + .blockingGet() + if (categoryCombo?.isDefault == false) { + CatOptionComboFilter( + categoryCombo, d2 .categoryModule() - .categoryCombos() - .uid(dataSet?.categoryCombo()?.uid()) - .blockingGet() - if (categoryCombo?.isDefault == false) { - CatOptionComboFilter( - categoryCombo, - d2 - .categoryModule() - .categoryOptionCombos() - .byCategoryComboUid() - .eq(categoryCombo.uid()) - .blockingGet(), - ProgramType.DATASET, - observableSortingInject, - observableOpenFilter, - categoryCombo.displayName() ?: "", - ).also { datasetFilters[DataSetFilter.CAT_COMBO] = it } - } - - return datasetFilters + .categoryOptionCombos() + .byCategoryComboUid() + .eq(categoryCombo.uid()) + .blockingGet(), + ProgramType.DATASET, + observableSortingInject, + observableOpenFilter, + categoryCombo.displayName() ?: "", + ).also { datasetFilters[DataSetFilter.CAT_COMBO] = it } } - fun homeFilters(): List { - val defaultFilters = createDefaultHomeFilters() - - if (webAppIsNotConfigured()) { - if (orgUnitsCount == 1) { - defaultFilters.remove(HomeFilter.ORG_UNIT) - } - return defaultFilters.values.toList() - } + return datasetFilters + } - val homeFiltersWebApp = - d2 - .settingModule() - .appearanceSettings() - .getHomeFilters() - ?.toMutableMap() ?: mutableMapOf() + fun homeFilters(): List { + val orgUnitsCount = orgUnitsCount() + val defaultFilters = createDefaultHomeFilters() + if (webAppIsNotConfigured()) { if (orgUnitsCount == 1) { - homeFiltersWebApp.remove(HomeFilter.ORG_UNIT) + defaultFilters.remove(HomeFilter.ORG_UNIT) } + return defaultFilters.values.toList() + } + + val homeFiltersWebApp = + d2 + .settingModule() + .appearanceSettings() + .getHomeFilters() + ?.toMutableMap() ?: mutableMapOf() - return getFiltersApplyingWebAppConfig.execute(defaultFilters, homeFiltersWebApp) + if (orgUnitsCount == 1) { + homeFiltersWebApp.remove(HomeFilter.ORG_UNIT) } - private fun createDefaultHomeFilters(): LinkedHashMap { - val homeFilter = - linkedMapOf( - HomeFilter.DATE to + return getFiltersApplyingWebAppConfig.execute(defaultFilters, homeFiltersWebApp) + } + + private fun createDefaultHomeFilters(): LinkedHashMap { + val homeFilter = + linkedMapOf( + HomeFilter.DATE to PeriodFilter( ProgramType.ALL, observableSortingInject, observableOpenFilter, resources.filterDateLabel(), ), - HomeFilter.ORG_UNIT to + HomeFilter.ORG_UNIT to OrgUnitFilter( FilterManager.getInstance().observeOrgUnitFilters(), ProgramType.ALL, @@ -443,367 +450,372 @@ class FilterRepository observableOpenFilter, resources.filterOrgUnitLabel(), ), - HomeFilter.SYNC_STATUS to + HomeFilter.SYNC_STATUS to SyncStateFilter( ProgramType.ALL, observableSortingInject, observableOpenFilter, resources.filterSyncLabel(), ), - ) + ) - val stagesByUserAssignment = - d2 - .programModule() - .programStages() - .byEnableUserAssignment() - .eq(true) - - if (!stagesByUserAssignment.blockingIsEmpty()) { - val assignToMeFilter = - AssignedFilter( - programType = ProgramType.ALL, - sortingItem = observableSortingInject, - openFilter = observableOpenFilter, - filterLabel = resources.filterAssignedToMeLabel(), - ) - homeFilter[HomeFilter.ASSIGNED_TO_ME] = assignToMeFilter - } - return homeFilter + val stagesByUserAssignment = + d2 + .programModule() + .programStages() + .byEnableUserAssignment() + .eq(true) + + if (!stagesByUserAssignment.blockingIsEmpty()) { + val assignToMeFilter = + AssignedFilter( + programType = ProgramType.ALL, + sortingItem = observableSortingInject, + openFilter = observableOpenFilter, + filterLabel = resources.filterAssignedToMeLabel(), + ) + homeFilter[HomeFilter.ASSIGNED_TO_ME] = assignToMeFilter } + return homeFilter + } - private fun webAppIsNotConfigured(): Boolean = !d2.settingModule().appearanceSettings().blockingExists() - - private fun getTrackerFilters(program: Program): List { - val defaultFilters = createGetDefaultTrackerFilter(program) - - if (webAppIsNotConfigured()) { - if (orgUnitsCount == 1) { - defaultFilters.remove(ProgramFilter.ORG_UNIT) - } - return defaultFilters.values.toList() - } + private fun webAppIsNotConfigured(): Boolean = + !d2.settingModule().appearanceSettings().blockingExists() - val trackerFiltersWebApp = - d2 - .settingModule() - .appearanceSettings() - .getProgramFiltersByUid(program.uid()) - ?.toMutableMap() ?: mutableMapOf() + private fun getTrackerFilters(program: Program): List { + val orgUnitsCount = orgUnitsCount() + val defaultFilters = createGetDefaultTrackerFilter(program) + if (webAppIsNotConfigured()) { if (orgUnitsCount == 1) { - trackerFiltersWebApp.remove(ProgramFilter.ORG_UNIT) + defaultFilters.remove(ProgramFilter.ORG_UNIT) } + return defaultFilters.values.toList() + } - val filterPreList = - getFiltersApplyingWebAppConfig.execute(defaultFilters, trackerFiltersWebApp) - - if (filterPreList.isEmpty()) { - return mutableListOf() - } + val trackerFiltersWebApp = + d2 + .settingModule() + .appearanceSettings() + .getProgramFiltersByUid(program.uid()) + ?.toMutableMap() ?: mutableMapOf() - return setupUpFollowUpFilter(program, filterPreList.toMutableList()) + if (orgUnitsCount == 1) { + trackerFiltersWebApp.remove(ProgramFilter.ORG_UNIT) } - private fun setupUpFollowUpFilter( - program: Program, - filtersToShow: MutableList, - ): List { - val teTypeName = - d2 - .trackedEntityModule() - .trackedEntityTypes() - .uid(program.trackedEntityType()?.uid()) - .blockingGet() - ?.displayName() ?: "" - val followUpFilter = - FollowUpFilter( - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - resources.filterFollowUpLabel(teTypeName), - ) + val filterPreList = + getFiltersApplyingWebAppConfig.execute(defaultFilters, trackerFiltersWebApp) - if (filtersToShow.any { it.type == Filters.ASSIGNED_TO_ME }) { - val index = filtersToShow.indexOfFirst { it.type == Filters.ASSIGNED_TO_ME } - filtersToShow.add(index, followUpFilter) - } else { - filtersToShow.add(followUpFilter) - } - return filtersToShow.toList() + if (filterPreList.isEmpty()) { + return mutableListOf() } - private fun createGetDefaultTrackerFilter(program: Program): LinkedHashMap { - val defaultTrackerFilters = linkedMapOf() - - defaultTrackerFilters[ProgramFilter.EVENT_DATE] = - PeriodFilter( - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - resources.filterEventDateLabel(program.uid()), - ) - defaultTrackerFilters[ProgramFilter.ENROLLMENT_DATE] = - EnrollmentDateFilter( - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - program.displayEnrollmentDateLabel() ?: resources - .filterEnrollmentDateLabel(program.uid()), - ) - defaultTrackerFilters[ProgramFilter.ORG_UNIT] = - OrgUnitFilter( - FilterManager.getInstance().observeOrgUnitFilters(), - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - resources.filterOrgUnitLabel(), - ) - defaultTrackerFilters[ProgramFilter.SYNC_STATUS] = - SyncStateFilter( - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - resources.filterSyncLabel(), - ) - defaultTrackerFilters[ProgramFilter.ENROLLMENT_STATUS] = - EnrollmentStatusFilter( - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - resources.filterEnrollmentStatusLabel(program.uid()), - ) - defaultTrackerFilters[ProgramFilter.EVENT_STATUS] = - EventStatusFilter( - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - resources.filterEventStatusLabel(program.uid()), - ) + return setupUpFollowUpFilter(program, filterPreList.toMutableList()) + } - val stagesByProgramUidAndUserAssignment = - d2 - .programModule() - .programStages() - .byProgramUid() - .eq(program.uid()) - .byEnableUserAssignment() - .eq(true) - - if (!stagesByProgramUidAndUserAssignment.blockingIsEmpty()) { - defaultTrackerFilters[ProgramFilter.ASSIGNED_TO_ME] = - AssignedFilter( - programType = ProgramType.TRACKER, - sortingItem = observableSortingInject, - openFilter = observableOpenFilter, - filterLabel = resources.filterAssignedToMeLabel(), - ) - } + private fun setupUpFollowUpFilter( + program: Program, + filtersToShow: MutableList, + ): List { + val teTypeName = + d2 + .trackedEntityModule() + .trackedEntityTypes() + .uid(program.trackedEntityType()?.uid()) + .blockingGet() + ?.displayName() ?: "" + val followUpFilter = + FollowUpFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + resources.filterFollowUpLabel(teTypeName), + ) - return defaultTrackerFilters + if (filtersToShow.any { it.type == Filters.ASSIGNED_TO_ME }) { + val index = filtersToShow.indexOfFirst { it.type == Filters.ASSIGNED_TO_ME } + filtersToShow.add(index, followUpFilter) + } else { + filtersToShow.add(followUpFilter) } + return filtersToShow.toList() + } - private fun getTrackerWorkingList(program: Program): WorkingListFilter? { - val workingLists = - d2 - .trackedEntityModule() - .trackedEntityInstanceFilters() - .byProgram() - .eq(program.uid()) - .withTrackedEntityInstanceEventFilters() - .blockingGet() - .mapNotNull { teiFilterToWorkingListItemMapper.map(it) } - .toMutableList() - - workingLists.addAll( - d2 - .programModule() - .programStageWorkingLists() - .byProgram() - .eq(program.uid()) - .withAttributeValueFilters() - .blockingGet() - .mapNotNull { programStageToWorkingListItemMapper.map(it) } - .toMutableList(), + private fun createGetDefaultTrackerFilter(program: Program): LinkedHashMap { + val defaultTrackerFilters = linkedMapOf() + + defaultTrackerFilters[ProgramFilter.EVENT_DATE] = + PeriodFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + resources.filterEventDateLabel(program.uid()), + ) + defaultTrackerFilters[ProgramFilter.ENROLLMENT_DATE] = + EnrollmentDateFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + program.displayEnrollmentDateLabel() ?: resources + .filterEnrollmentDateLabel(program.uid()), + ) + defaultTrackerFilters[ProgramFilter.ORG_UNIT] = + OrgUnitFilter( + FilterManager.getInstance().observeOrgUnitFilters(), + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + resources.filterOrgUnitLabel(), + ) + defaultTrackerFilters[ProgramFilter.SYNC_STATUS] = + SyncStateFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + resources.filterSyncLabel(), + ) + defaultTrackerFilters[ProgramFilter.ENROLLMENT_STATUS] = + EnrollmentStatusFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + resources.filterEnrollmentStatusLabel(program.uid()), + ) + defaultTrackerFilters[ProgramFilter.EVENT_STATUS] = + EventStatusFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + resources.filterEventStatusLabel(program.uid()), ) - var workingListFilter: WorkingListFilter? = null - if (workingLists.isNotEmpty()) { - workingListFilter = - WorkingListFilter( - workingLists, - ProgramType.TRACKER, - observableSortingInject, - observableOpenFilter, - "", - ) - } - return workingListFilter + val stagesByProgramUidAndUserAssignment = + d2 + .programModule() + .programStages() + .byProgramUid() + .eq(program.uid()) + .byEnableUserAssignment() + .eq(true) + + if (!stagesByProgramUidAndUserAssignment.blockingIsEmpty()) { + defaultTrackerFilters[ProgramFilter.ASSIGNED_TO_ME] = + AssignedFilter( + programType = ProgramType.TRACKER, + sortingItem = observableSortingInject, + openFilter = observableOpenFilter, + filterLabel = resources.filterAssignedToMeLabel(), + ) } - private fun getEventFilters( - program: Program, - programType: ProgramType, - ): List { - val defaultFilters = createDefaultGetEventFilters(program, programType) - if (webAppIsNotConfigured()) { - if (orgUnitsCount == 1) { - defaultFilters.remove(ProgramFilter.ORG_UNIT) - } - return defaultFilters.values.toMutableList() - } + return defaultTrackerFilters + } - val eventFiltersWebApp = - d2 - .settingModule() - .appearanceSettings() - .getProgramFiltersByUid(program.uid()) - ?.toMutableMap() ?: mutableMapOf() + private fun getTrackerWorkingList(program: Program): WorkingListFilter? { + val workingLists = + d2 + .trackedEntityModule() + .trackedEntityInstanceFilters() + .byProgram() + .eq(program.uid()) + .withTrackedEntityInstanceEventFilters() + .blockingGet() + .mapNotNull { teiFilterToWorkingListItemMapper.map(it) } + .toMutableList() + workingLists.addAll( + d2 + .programModule() + .programStageWorkingLists() + .byProgram() + .eq(program.uid()) + .withAttributeValueFilters() + .blockingGet() + .mapNotNull { programStageToWorkingListItemMapper.map(it) } + .toMutableList(), + ) + + var workingListFilter: WorkingListFilter? = null + if (workingLists.isNotEmpty()) { + workingListFilter = + WorkingListFilter( + workingLists, + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + "", + ) + } + return workingListFilter + } + + private fun getEventFilters( + program: Program, + programType: ProgramType, + ): List { + val orgUnitsCount = orgUnitsCount() + val defaultFilters = createDefaultGetEventFilters(program, programType) + if (webAppIsNotConfigured()) { if (orgUnitsCount == 1) { - eventFiltersWebApp.remove(ProgramFilter.ORG_UNIT) + defaultFilters.remove(ProgramFilter.ORG_UNIT) } + return defaultFilters.values.toMutableList() + } - val filtersToShow = - getFiltersApplyingWebAppConfig.execute(defaultFilters, eventFiltersWebApp) - - if (filtersToShow.isEmpty()) { - return mutableListOf() - } + val eventFiltersWebApp = + d2 + .settingModule() + .appearanceSettings() + .getProgramFiltersByUid(program.uid()) + ?.toMutableMap() ?: mutableMapOf() - return filtersToShow.toList() + if (orgUnitsCount == 1) { + eventFiltersWebApp.remove(ProgramFilter.ORG_UNIT) } - private fun getEventWorkingList(program: Program): WorkingListFilter? { - var workingListFilter: WorkingListFilter? = null - val workingLists = - d2 - .eventModule() - .eventFilters() - .byProgram() - .eq(program.uid()) - .blockingGet() - .mapNotNull { eventFilterToWorkingListItemMapper.map(it) } - - if (workingLists.isNotEmpty()) { - workingListFilter = - WorkingListFilter( - workingLists, - ProgramType.EVENT, - observableSortingInject, - observableOpenFilter, - "", - ) - } - return workingListFilter + val filtersToShow = + getFiltersApplyingWebAppConfig.execute(defaultFilters, eventFiltersWebApp) + + if (filtersToShow.isEmpty()) { + return mutableListOf() } - private fun createDefaultGetEventFilters( - program: Program, - programType: ProgramType, - ): LinkedHashMap { - val defaultEventFilter = linkedMapOf() + return filtersToShow.toList() + } - defaultEventFilter[ProgramFilter.EVENT_DATE] = - PeriodFilter( - programType, - observableSortingInject, - observableOpenFilter, - resources.filterDateLabel(), - ) + private fun getEventWorkingList(program: Program): WorkingListFilter? { + var workingListFilter: WorkingListFilter? = null + val workingLists = + d2 + .eventModule() + .eventFilters() + .byProgram() + .eq(program.uid()) + .blockingGet() + .mapNotNull { eventFilterToWorkingListItemMapper.map(it) } - defaultEventFilter[ProgramFilter.ORG_UNIT] = - OrgUnitFilter( - FilterManager.getInstance().observeOrgUnitFilters(), - programType, + if (workingLists.isNotEmpty()) { + workingListFilter = + WorkingListFilter( + workingLists, + ProgramType.EVENT, observableSortingInject, observableOpenFilter, - resources.filterOrgUnitLabel(), + "", ) + } + return workingListFilter + } - defaultEventFilter[ProgramFilter.SYNC_STATUS] = - SyncStateFilter( - programType, - observableSortingInject, - observableOpenFilter, - resources.filterSyncLabel(), - ) + private fun createDefaultGetEventFilters( + program: Program, + programType: ProgramType, + ): LinkedHashMap { + val defaultEventFilter = linkedMapOf() + + defaultEventFilter[ProgramFilter.EVENT_DATE] = + PeriodFilter( + programType, + observableSortingInject, + observableOpenFilter, + resources.filterDateLabel(), + ) + + defaultEventFilter[ProgramFilter.ORG_UNIT] = + OrgUnitFilter( + FilterManager.getInstance().observeOrgUnitFilters(), + programType, + observableSortingInject, + observableOpenFilter, + resources.filterOrgUnitLabel(), + ) + + defaultEventFilter[ProgramFilter.SYNC_STATUS] = + SyncStateFilter( + programType, + observableSortingInject, + observableOpenFilter, + resources.filterSyncLabel(), + ) + + defaultEventFilter[ProgramFilter.EVENT_STATUS] = + EventStatusFilter( + programType, + observableSortingInject, + observableOpenFilter, + resources.filterEventStatusLabel(program.uid()), + ) - defaultEventFilter[ProgramFilter.EVENT_STATUS] = - EventStatusFilter( + val stagesByProgramAndUserAssignment = + d2 + .programModule() + .programStages() + .byProgramUid() + .eq(program.uid()) + .byEnableUserAssignment() + .eq(true) + + if (!stagesByProgramAndUserAssignment.blockingIsEmpty()) { + defaultEventFilter[ProgramFilter.ASSIGNED_TO_ME] = + AssignedFilter( + programType = programType, + sortingItem = observableSortingInject, + openFilter = observableOpenFilter, + filterLabel = resources.filterAssignedToMeLabel(), + ) + } + val categoryCombo = + d2 + .categoryModule() + .categoryCombos() + .uid(program.categoryCombo()?.uid()) + .blockingGet() + if (categoryCombo?.isDefault == false) { + defaultEventFilter[ProgramFilter.CAT_COMBO] = + CatOptionComboFilter( + categoryCombo, + d2 + .categoryModule() + .categoryOptionCombos() + .byCategoryComboUid() + .eq(categoryCombo.uid()) + .blockingGet(), programType, observableSortingInject, observableOpenFilter, - resources.filterEventStatusLabel(program.uid()), + categoryCombo.displayName() ?: "", ) - - val stagesByProgramAndUserAssignment = - d2 - .programModule() - .programStages() - .byProgramUid() - .eq(program.uid()) - .byEnableUserAssignment() - .eq(true) - - if (!stagesByProgramAndUserAssignment.blockingIsEmpty()) { - defaultEventFilter[ProgramFilter.ASSIGNED_TO_ME] = - AssignedFilter( - programType = programType, - sortingItem = observableSortingInject, - openFilter = observableOpenFilter, - filterLabel = resources.filterAssignedToMeLabel(), - ) - } - val categoryCombo = - d2 - .categoryModule() - .categoryCombos() - .uid(program.categoryCombo()?.uid()) - .blockingGet() - if (categoryCombo?.isDefault == false) { - defaultEventFilter[ProgramFilter.CAT_COMBO] = - CatOptionComboFilter( - categoryCombo, - d2 - .categoryModule() - .categoryOptionCombos() - .byCategoryComboUid() - .eq(categoryCombo.uid()) - .blockingGet(), - programType, - observableSortingInject, - observableOpenFilter, - categoryCombo.displayName() ?: "", - ) - } - return defaultEventFilter } + return defaultEventFilter + } - fun applyWorkingList( - teiQuery: TrackedEntitySearchCollectionRepository, - currentWorkingList: WorkingListItem?, - ): TrackedEntitySearchCollectionRepository = - currentWorkingList?.let { - when (it) { - is EventWorkingList -> - null - is ProgramStageWorkingList -> - teiQuery.byProgramStageWorkingList().eq(it.uid) - is TrackedEntityInstanceWorkingList -> - teiQuery.byTrackedEntityInstanceFilter().eq(it.uid) - } - } ?: teiQuery - - fun applyWorkingList( - eventQuery: EventQueryCollectionRepository, - currentWorkingList: WorkingListItem?, - ): EventQueryCollectionRepository = - currentWorkingList?.let { - eventQuery.byEventFilter().eq(it.uid) - } ?: eventQuery - - fun collapseAllFilters() { - observableOpenFilter.set(Filters.NON) - } + fun applyWorkingList( + teiQuery: TrackedEntitySearchCollectionRepository, + currentWorkingList: WorkingListItem?, + ): TrackedEntitySearchCollectionRepository = + currentWorkingList?.let { + when (it) { + is EventWorkingList -> + null + + is ProgramStageWorkingList -> + teiQuery.byProgramStageWorkingList().eq(it.uid) + + is TrackedEntityInstanceWorkingList -> + teiQuery.byTrackedEntityInstanceFilter().eq(it.uid) + } + } ?: teiQuery + + fun applyWorkingList( + eventQuery: EventQueryCollectionRepository, + currentWorkingList: WorkingListItem?, + ): EventQueryCollectionRepository = + currentWorkingList?.let { + eventQuery.byEventFilter().eq(it.uid) + } ?: eventQuery + + fun collapseAllFilters() { + observableOpenFilter.set(Filters.NON) } +} diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/TeiWorkingListScope.kt b/commons/src/main/java/org/dhis2/commons/filters/data/TeiWorkingListScope.kt index bd75e3c9bbc..8f0bd2650b1 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/TeiWorkingListScope.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/TeiWorkingListScope.kt @@ -28,6 +28,8 @@ sealed class WorkingListScope { abstract fun assignCount(): Int abstract fun value(filterType: Filters): String + + abstract fun workingListUid(): String? } data class EmptyWorkingList( @@ -54,9 +56,12 @@ data class EmptyWorkingList( override fun assignCount(): Int = 0 override fun value(filterType: Filters) = "" + + override fun workingListUid() = null } data class TeiWorkingListScope( + val workingListUid: String?, val enrollmentStatusList: List?, val enrollmentDate: String?, val eventStatusList: List?, @@ -99,9 +104,12 @@ data class TeiWorkingListScope( Filters.ENROLLMENT_STATUS -> enrollmentStatusList?.joinToString() ?: "" else -> "" } + + override fun workingListUid(): String? = workingListUid } data class EventWorkingListScope( + val workingListUid: String?, val stageUid: String?, val eventDate: String?, val eventStatusList: List?, @@ -133,10 +141,13 @@ data class EventWorkingListScope( Filters.EVENT_STATUS -> eventStatusList?.joinToString() ?: "" else -> "" } + + override fun workingListUid(): String? = workingListUid } -fun TrackedEntityInstanceQueryRepositoryScope.mapToWorkingListScope(resources: FilterResources): TeiWorkingListScope = +fun TrackedEntityInstanceQueryRepositoryScope.mapToWorkingListScope(resources: FilterResources, workingListUid: String?): TeiWorkingListScope = TeiWorkingListScope( + workingListUid, enrollmentStatus()?.let { resources.enrollmentStatusToText(it) }, programDate()?.let { resources.dateFilterPeriodToText(it) }, resources.eventStatusToText( @@ -154,8 +165,9 @@ fun TrackedEntityInstanceQueryRepositoryScope.mapToWorkingListScope(resources: F dataValue().associateBy({ it.key() }, { it.value() }), ) -fun EventQueryRepositoryScope.mapToEventWorkingListScope(resources: FilterResources): EventWorkingListScope = +fun EventQueryRepositoryScope.mapToEventWorkingListScope(resources: FilterResources, workingListUid: String?): EventWorkingListScope = EventWorkingListScope( + workingListUid, programStage(), eventDate()?.let { resources.dateFilterPeriodToText(it) }, eventStatus()?.let { resources.eventStatusToText(it) }, diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt b/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt index 9b0067c04f5..21f77b9abaa 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt @@ -46,7 +46,10 @@ class TrackerFilterSearchHelper filterManager.currentWorkingList(), ).also { filterManager.setWorkingListScope( - it.scope.mapToWorkingListScope(filterRepository.resources), + it.scope.mapToWorkingListScope( + filterRepository.resources, + filterManager.currentWorkingList()?.uid, + ), ) } } else { diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt index 3470bfd0eea..7aa86a2c200 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/data/FilterPeriodsRepository.kt @@ -25,6 +25,7 @@ class FilterPeriodsRepository( FilterPeriodType.WEEKLY, FilterPeriodType.WEEKLY_WEDNESDAY, FilterPeriodType.WEEKLY_THURSDAY, + FilterPeriodType.WEEKLY_FRIDAY, FilterPeriodType.WEEKLY_SATURDAY, FilterPeriodType.WEEKLY_SUNDAY, FilterPeriodType.BI_WEEKLY, @@ -36,8 +37,11 @@ class FilterPeriodsRepository( FilterPeriodType.SIX_MONTHLY_APRIL, FilterPeriodType.SIX_MONTHLY_NOV, FilterPeriodType.YEARLY, + FilterPeriodType.FINANCIAL_FEB, FilterPeriodType.FINANCIAL_APRIL, FilterPeriodType.FINANCIAL_JULY, + FilterPeriodType.FINANCIAL_AUG, + FilterPeriodType.FINANCIAL_SEP, FilterPeriodType.FINANCIAL_OCT, FilterPeriodType.FINANCIAL_NOV, ) @@ -48,6 +52,7 @@ class FilterPeriodsRepository( FilterPeriodType.WEEKLY -> PeriodType.Weekly FilterPeriodType.WEEKLY_WEDNESDAY -> PeriodType.WeeklyWednesday FilterPeriodType.WEEKLY_THURSDAY -> PeriodType.WeeklyThursday + FilterPeriodType.WEEKLY_FRIDAY -> PeriodType.WeeklyFriday FilterPeriodType.WEEKLY_SATURDAY -> PeriodType.WeeklySaturday FilterPeriodType.WEEKLY_SUNDAY -> PeriodType.WeeklySunday FilterPeriodType.BI_WEEKLY -> PeriodType.BiWeekly @@ -59,8 +64,11 @@ class FilterPeriodsRepository( FilterPeriodType.SIX_MONTHLY_APRIL -> PeriodType.SixMonthlyApril FilterPeriodType.SIX_MONTHLY_NOV -> PeriodType.SixMonthlyNov FilterPeriodType.YEARLY -> PeriodType.Yearly + FilterPeriodType.FINANCIAL_FEB -> PeriodType.FinancialFeb FilterPeriodType.FINANCIAL_APRIL -> PeriodType.FinancialApril FilterPeriodType.FINANCIAL_JULY -> PeriodType.FinancialJuly + FilterPeriodType.FINANCIAL_AUG -> PeriodType.FinancialAug + FilterPeriodType.FINANCIAL_SEP -> PeriodType.FinancialSep FilterPeriodType.FINANCIAL_OCT -> PeriodType.FinancialOct FilterPeriodType.FINANCIAL_NOV -> PeriodType.FinancialNov FilterPeriodType.NONE -> PeriodType.Daily diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt index 6f8730da45a..677f4750651 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/data/PeriodTypeLabelProvider.kt @@ -11,6 +11,7 @@ class PeriodTypeLabelProvider { FilterPeriodType.WEEKLY -> R.string.weekly FilterPeriodType.WEEKLY_WEDNESDAY -> R.string.weekly_start_wednesday FilterPeriodType.WEEKLY_THURSDAY -> R.string.weekly_start_thursday + FilterPeriodType.WEEKLY_FRIDAY -> R.string.weekly_start_friday FilterPeriodType.WEEKLY_SATURDAY -> R.string.weekly_start_saturday FilterPeriodType.WEEKLY_SUNDAY -> R.string.weekly_start_sunday FilterPeriodType.BI_WEEKLY -> R.string.bi_weekly @@ -22,8 +23,11 @@ class PeriodTypeLabelProvider { FilterPeriodType.SIX_MONTHLY_APRIL -> R.string.six_monthly_april FilterPeriodType.SIX_MONTHLY_NOV -> R.string.six_monthly_nov FilterPeriodType.YEARLY -> R.string.YEARLY + FilterPeriodType.FINANCIAL_FEB -> R.string.financial_year_february FilterPeriodType.FINANCIAL_APRIL -> R.string.financial_year_april FilterPeriodType.FINANCIAL_JULY -> R.string.financial_year_july + FilterPeriodType.FINANCIAL_AUG -> R.string.financial_year_august + FilterPeriodType.FINANCIAL_SEP -> R.string.financial_year_september FilterPeriodType.FINANCIAL_OCT -> R.string.financial_year_october FilterPeriodType.FINANCIAL_NOV -> R.string.financial_year_november } diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt index 15bb0d6d421..402e5f224c5 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/di/FilterPeriodsModule.kt @@ -7,7 +7,6 @@ import org.dhis2.commons.filters.periods.domain.GetFilterPeriods import org.dhis2.commons.filters.periods.ui.viewmodel.FilterPeriodsDialogViewmodel import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModel -import org.koin.core.parameter.parametersOf import org.koin.dsl.module val filterPeriodsModule = @@ -34,7 +33,7 @@ val filterPeriodsModule = FilterPeriodsDialogViewmodel( getFilterPeriods = get(), getFilterPeriodTypes = get(), - resourceManager = get { parametersOf(params.get()) }, + resourceManager = get(), periodTypeLabelProvider = get(), launchMode = params.get(), ) diff --git a/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt b/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt index e7af37e9a96..4d0fea03ad1 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/periods/model/FilterPeriodType.kt @@ -6,6 +6,7 @@ enum class FilterPeriodType { WEEKLY, WEEKLY_WEDNESDAY, WEEKLY_THURSDAY, + WEEKLY_FRIDAY, WEEKLY_SATURDAY, WEEKLY_SUNDAY, BI_WEEKLY, @@ -17,8 +18,11 @@ enum class FilterPeriodType { SIX_MONTHLY_APRIL, SIX_MONTHLY_NOV, YEARLY, + FINANCIAL_FEB, FINANCIAL_APRIL, FINANCIAL_JULY, + FINANCIAL_AUG, + FINANCIAL_SEP, FINANCIAL_OCT, FINANCIAL_NOV, } diff --git a/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt b/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt index 2959bc49df4..b924db56dde 100644 --- a/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt +++ b/commons/src/main/java/org/dhis2/commons/locationprovider/LocationProviderImpl.kt @@ -10,7 +10,6 @@ import android.location.LocationListener import android.location.LocationManager import androidx.core.app.ActivityCompat import androidx.core.location.LocationListenerCompat -import okhttp3.internal.toImmutableList private const val FUSED_LOCATION_PROVIDER = "fused" @@ -69,7 +68,7 @@ open class LocationProviderImpl( onLocationProviderChanged() } } - val deviceProviders = locationManager.allProviders.toImmutableList() + val deviceProviders = locationManager.allProviders.toList() if (deviceProviders.contains(LocationManager.NETWORK_PROVIDER)) { locationManager.requestLocationUpdates( LocationManager.NETWORK_PROVIDER, diff --git a/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt b/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt index cecd1dde871..a3b6aa7db02 100644 --- a/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt +++ b/commons/src/main/java/org/dhis2/commons/periods/data/PeriodLabelProvider.kt @@ -57,6 +57,7 @@ class PeriodLabelProvider( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, PeriodType.BiWeekly, @@ -80,9 +81,13 @@ class PeriodLabelProvider( PeriodType.BiMonthly, PeriodType.SixMonthly, PeriodType.SixMonthlyApril, PeriodType.Quarterly, PeriodType.QuarterlyNov, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, + PeriodType.FinancialNov, -> if (periodBetweenYears) { FROM_TO_LABEL.format( @@ -116,6 +121,7 @@ class PeriodLabelProvider( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, -> { @@ -181,9 +187,13 @@ class PeriodLabelProvider( ) } + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, + PeriodType.FinancialNov, -> FROM_TO_LABEL.format( SimpleDateFormat(MONTH_YEAR_FULL_FORMAT, locale).format(periodStartDate), diff --git a/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt b/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt index 4cec7237932..5f6a19cf9ff 100644 --- a/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt +++ b/commons/src/main/java/org/dhis2/commons/popupmenu/AppMenuHelper.kt @@ -1,7 +1,6 @@ package org.dhis2.commons.popupmenu import android.content.Context -import android.os.Build import android.view.ContextThemeWrapper import android.view.Gravity import android.view.View @@ -26,11 +25,7 @@ class AppMenuHelper private constructor( fun show() { val contextWrapper = ContextThemeWrapper(context, R.style.PopupMenuMarginStyle) popupMenu = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - PopupMenu(contextWrapper, anchor, Gravity.END, 0, R.style.PopupMenuMarginStyle) - } else { - PopupMenu(contextWrapper, anchor, Gravity.END) - } + PopupMenu(contextWrapper, anchor, Gravity.END, 0, R.style.PopupMenuMarginStyle) try { val fields = popupMenu.javaClass.declaredFields for (field in fields) { diff --git a/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt b/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt index 31f48c6c5a1..c90bd0f6ce4 100644 --- a/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt +++ b/commons/src/main/java/org/dhis2/commons/reporting/CrashReportModule.kt @@ -11,5 +11,8 @@ import javax.inject.Singleton class CrashReportModule internal constructor() { @Provides @Singleton - fun provideCrashReportController(context: Context): CrashReportController = CrashReportControllerImpl(context) + fun provideCrashReportController(context: Context): CrashReportController = CrashReportControllerImpl( + context = context, + sentryDsn = "", + ) } diff --git a/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt b/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt index cea91e1e319..0cf64b8f39a 100644 --- a/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/resources/ColorUtils.kt @@ -8,8 +8,11 @@ import android.graphics.PorterDuff import android.graphics.drawable.Drawable import android.util.TypedValue import androidx.core.graphics.ColorUtils +import androidx.core.graphics.toColorInt import org.dhis2.commons.R import java.util.Objects +import kotlin.math.pow +import androidx.compose.ui.graphics.Color as ComposeColor class ColorUtils { companion object { @@ -24,16 +27,7 @@ class ColorUtils { val b = hexColor[3] newHexColor = "#$r$r$g$g$b$b" // formatted to #ffff } - return Color.parseColor(newHexColor) - } - - fun getPrimaryColorWithAlpha( - context: Context, - primaryLight: ColorType, - alpha: Float, - ): Int { - val primaryColor = getPrimaryColor(context, primaryLight) - return ColorUtils.setAlphaComponent(primaryColor, 155) + return newHexColor.toColorInt() } fun withAlpha(color: Int): Int = ColorUtils.setAlphaComponent(color, 155) @@ -67,27 +61,18 @@ class ColorUtils { return drawableToTint } - fun tintDrawableWithColor( - drawableToTint: Drawable, - tintColor: Int, - ): Drawable { - drawableToTint.setTint(tintColor) - drawableToTint.setTintMode(PorterDuff.Mode.SRC_IN) - return drawableToTint - } - fun getContrastColor(color: Int): Int = if (getContrast(color) > 0.179) { - Color.parseColor("#b3000000") + "#b3000000".toColorInt() } else { - Color.parseColor("#e6ffffff") + "#e6ffffff".toColorInt() } fun getAlphaContrastColor(color: Int): Int = if (getContrast(color) > 0.500) { - Color.parseColor("#b3000000") + "#b3000000".toColorInt() } else { - Color.parseColor("#e6ffffff") + "#e6ffffff".toColorInt() } private fun getContrast(color: Int): Double { @@ -99,7 +84,7 @@ class ColorUtils { var green: Double? = null var blue: Double? = null rgb.forEach { - if (it <= 0.03928) it / 12.92 else Math.pow((it + 0.055) / 1.055, 2.4) + if (it <= 0.03928) it / 12.92 else ((it + 0.055) / 1.055).pow(2.4) if (red == null) { red = it } else if (green == null) { @@ -111,80 +96,21 @@ class ColorUtils { return 0.2126 * red!! + 0.7152 * green!! + 0.0722 * blue!! } - fun getThemeFromColor(color: String?): Int = - when (color) { - "#ffcdd2" -> R.style.colorPrimary_Pink - "#e57373" -> R.style.colorPrimary_e57 - "#d32f2f" -> R.style.colorPrimary_d32 - "#f06292" -> R.style.colorPrimary_f06 - "#c2185b" -> R.style.colorPrimary_c21 - "#880e4f" -> R.style.colorPrimary_880 - "#f50057" -> R.style.colorPrimary_f50 - "#e1bee7" -> R.style.colorPrimary_e1b - "#ba68c8" -> R.style.colorPrimary_ba6 - "#8e24aa" -> R.style.colorPrimary_8e2 - "#aa00ff" -> R.style.colorPrimary_aa0 - "#7e57c2" -> R.style.colorPrimary_7e5 - "#4527a0" -> R.style.colorPrimary_452 - "#7c4dff" -> R.style.colorPrimary_7c4 - "#6200ea" -> R.style.colorPrimary_620 - "#c5cae9" -> R.style.colorPrimary_c5c - "#7986cb" -> R.style.colorPrimary_798 - "#3949ab" -> R.style.colorPrimary_394 - "#304ffe" -> R.style.colorPrimary_304 - "#e3f2fd" -> R.style.colorPrimary_e3f - "#64b5f6" -> R.style.colorPrimary_64b - "#1976d2" -> R.style.colorPrimary_197 - "#0288d1" -> R.style.colorPrimary_028 - "#40c4ff" -> R.style.colorPrimary_40c - "#00b0ff" -> R.style.colorPrimary_00b - "#80deea" -> R.style.colorPrimary_80d - "#00acc1" -> R.style.colorPrimary_00a - "#00838f" -> R.style.colorPrimary_008 - "#006064" -> R.style.colorPrimary_006 - "#e0f2f1" -> R.style.colorPrimary_e0f - "#80cbc4" -> R.style.colorPrimary_80c - "#00695c" -> R.style.colorPrimary_0069 - "#64ffda" -> R.style.colorPrimary_64f - "#c8e6c9" -> R.style.colorPrimary_c8e - "#66bb6a" -> R.style.colorPrimary_66b - "#2e7d32" -> R.style.colorPrimary_2e7 - "#60ad5e" -> R.style.colorPrimary_60a - "#00e676" -> R.style.colorPrimary_00e - "#aed581" -> R.style.colorPrimary_aed - "#689f38" -> R.style.colorPrimary_689 - "#33691e" -> R.style.colorPrimary_336 - "#76ff03" -> R.style.colorPrimary_76f - "#64dd17" -> R.style.colorPrimary_64d - "#cddc39" -> R.style.colorPrimary_cdd - "#9e9d24" -> R.style.colorPrimary_9e9 - "#827717" -> R.style.colorPrimary_827 - "#fff9c4" -> R.style.colorPrimary_fff - "#fbc02d" -> R.style.colorPrimary_fbc - "#f57f17" -> R.style.colorPrimary_f57 - "#ffff00" -> R.style.colorPrimary_ffff - "#ffcc80" -> R.style.colorPrimary_ffc - "#ffccbc" -> R.style.colorPrimary_ffcc - "#ffab91" -> R.style.colorPrimary_ffa - "#bcaaa4" -> R.style.colorPrimary_bca - "#8d6e63" -> R.style.colorPrimary_8d6 - "#4e342e" -> R.style.colorPrimary_4e3 - "#fafafa" -> R.style.colorPrimary_faf - "#bdbdbd" -> R.style.colorPrimary_bdb - "#757575" -> R.style.colorPrimary_757 - "#424242" -> R.style.colorPrimary_424 - "#cfd8dc" -> R.style.colorPrimary_cfd - "#b0bec5" -> R.style.colorPrimary_b0b - "#607d8b" -> R.style.colorPrimary_607 - "#37474f" -> R.style.colorPrimary_374 - else -> -1 - } + fun getThemeFromColor(color: String?): Int = paletteThemes[color] ?: -1 // fun getPrimaryColor( context: Context, colorType: ColorType, ): Int = context.getPrimaryColor(colorType) + + fun getThemePrimaryColor(context: Context): ComposeColor { + val typedValue = TypedValue() + val a = context.theme.obtainStyledAttributes(typedValue.data, intArrayOf(android.R.attr.colorPrimary)) + val color = a.getColor(0, 0) + a.recycle() + return ComposeColor(color) + } } enum class ColorType { diff --git a/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt b/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt index 834db2aa3f1..b8a90f020c3 100644 --- a/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/resources/DhisPeriodUtils.kt @@ -34,6 +34,7 @@ class DhisPeriodUtils( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, -> { @@ -73,9 +74,13 @@ class DhisPeriodUtils( PeriodType.QuarterlyNov, PeriodType.SixMonthly, PeriodType.SixMonthlyApril, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, + PeriodType.FinancialNov, -> formattedDate = periodString.format( diff --git a/commons/src/main/java/org/dhis2/commons/resources/PaletteThemes.kt b/commons/src/main/java/org/dhis2/commons/resources/PaletteThemes.kt new file mode 100644 index 00000000000..a3a9c88ed45 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/resources/PaletteThemes.kt @@ -0,0 +1,90 @@ +package org.dhis2.commons.resources + +import org.dhis2.commons.R + +/** + * Maps each hex color to its corresponding Android theme style resource. + */ +val paletteThemes: Map = + mapOf( + // Reds / Pinks + "#ffcdd2" to R.style.colorPrimary_Pink, + "#e57373" to R.style.colorPrimary_e57, + "#d32f2f" to R.style.colorPrimary_d32, + "#f06292" to R.style.colorPrimary_f06, + "#c2185b" to R.style.colorPrimary_c21, + "#880e4f" to R.style.colorPrimary_880, + "#f50057" to R.style.colorPrimary_f50, + // Purples + "#e1bee7" to R.style.colorPrimary_e1b, + "#ba68c8" to R.style.colorPrimary_ba6, + "#8e24aa" to R.style.colorPrimary_8e2, + "#aa00ff" to R.style.colorPrimary_aa0, + // Deep Purples / Indigos + "#7e57c2" to R.style.colorPrimary_7e5, + "#4527a0" to R.style.colorPrimary_452, + "#7c4dff" to R.style.colorPrimary_7c4, + "#6200ea" to R.style.colorPrimary_620, + "#c5cae9" to R.style.colorPrimary_c5c, + "#7986cb" to R.style.colorPrimary_798, + "#3949ab" to R.style.colorPrimary_394, + "#304ffe" to R.style.colorPrimary_304, + // Blues + "#e3f2fd" to R.style.colorPrimary_e3f, + "#64b5f6" to R.style.colorPrimary_64b, + "#1976d2" to R.style.colorPrimary_197, + "#0288d1" to R.style.colorPrimary_028, + "#40c4ff" to R.style.colorPrimary_40c, + "#00b0ff" to R.style.colorPrimary_00b, + // Cyans / Teals + "#80deea" to R.style.colorPrimary_80d, + "#00acc1" to R.style.colorPrimary_00a, + "#00838f" to R.style.colorPrimary_008, + "#006064" to R.style.colorPrimary_006, + "#e0f2f1" to R.style.colorPrimary_e0f, + "#80cbc4" to R.style.colorPrimary_80c, + "#00695c" to R.style.colorPrimary_0069, + "#64ffda" to R.style.colorPrimary_64f, + // Greens + "#c8e6c9" to R.style.colorPrimary_c8e, + "#66bb6a" to R.style.colorPrimary_66b, + "#2e7d32" to R.style.colorPrimary_2e7, + "#60ad5e" to R.style.colorPrimary_60a, + "#00e676" to R.style.colorPrimary_00e, + // Light Greens / Limes + "#aed581" to R.style.colorPrimary_aed, + "#689f38" to R.style.colorPrimary_689, + "#33691e" to R.style.colorPrimary_336, + "#76ff03" to R.style.colorPrimary_76f, + "#64dd17" to R.style.colorPrimary_64d, + "#cddc39" to R.style.colorPrimary_cdd, + "#9e9d24" to R.style.colorPrimary_9e9, + "#827717" to R.style.colorPrimary_827, + // Yellows + "#fff9c4" to R.style.colorPrimary_fff, + "#fbc02d" to R.style.colorPrimary_fbc, + "#f57f17" to R.style.colorPrimary_f57, + "#ffff00" to R.style.colorPrimary_ffff, + // Oranges / Browns + "#ffcc80" to R.style.colorPrimary_ffc, + "#ffccbc" to R.style.colorPrimary_ffcc, + "#ffab91" to R.style.colorPrimary_ffa, + "#bcaaa4" to R.style.colorPrimary_bca, + "#8d6e63" to R.style.colorPrimary_8d6, + "#4e342e" to R.style.colorPrimary_4e3, + // Greys + "#fafafa" to R.style.colorPrimary_faf, + "#bdbdbd" to R.style.colorPrimary_bdb, + "#757575" to R.style.colorPrimary_757, + "#424242" to R.style.colorPrimary_424, + // Blue Greys + "#cfd8dc" to R.style.colorPrimary_cfd, + "#b0bec5" to R.style.colorPrimary_b0b, + "#607d8b" to R.style.colorPrimary_607, + "#37474f" to R.style.colorPrimary_374, + // User settings old styles + "#ea5911" to R.style.OrangeTheme, + "#467e4a" to R.style.GreenTheme, + "#276696" to R.style.AppTheme, + "#b40303" to R.style.RedTheme, + ) diff --git a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt index 33b712f230a..11d1f5a7823 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt @@ -19,11 +19,39 @@ class SimprintsOrderSearchResultsByIdentifyResponseUseCase( searchFields: Iterable, queryData: Map?>?, searchTrackedEntities: suspend () -> List, - ): List? { + ): List? = + invoke( + searchFields = searchFields, + queryData = queryData, + searchTrackedEntities = searchTrackedEntities, + getUid = { trackedEntity -> + trackedEntity.uid() + }, + getAttributeValue = { trackedEntity, fieldUid -> + trackedEntity.attributeValues + ?.firstOrNull { attribute -> + attribute.attribute == fieldUid + }?.value + }, + ) + + suspend operator fun invoke( + searchFields: Iterable, + queryData: Map?>?, + searchTrackedEntities: suspend () -> List, + getUid: (T) -> String, + getAttributeValue: (T, String) -> String?, + ): List? { val order = getIdentifyResponseOrder(searchFields, queryData) ?: return null return searchTrackedEntities() .map { trackedEntity -> - trackedEntity to getMatchedGuid(trackedEntity, order) + trackedEntity to + getMatchedGuid( + searchItem = trackedEntity, + order = order, + getUid = getUid, + getAttributeValue = getAttributeValue, + ) }.sortedBy { (_, guid) -> guid?.let(order.orderByGuid::get) ?: Int.MAX_VALUE }.map { (trackedEntity, _) -> @@ -46,17 +74,18 @@ class SimprintsOrderSearchResultsByIdentifyResponseUseCase( } } - private suspend fun getMatchedGuid( - searchItem: TrackedEntitySearchItem, + private suspend fun getMatchedGuid( + searchItem: T, order: IdentifyResponseOrder, + getUid: (T) -> String, + getAttributeValue: (T, String) -> String?, ): String? { - searchItem.attributeValues - ?.firstOrNull { attribute -> - attribute.attribute == order.fieldUid && attribute.value in order.orderByGuid - }?.run { return value } + getAttributeValue(searchItem, order.fieldUid) + ?.takeIf { it in order.orderByGuid } + ?.let { return it } return simprintsD2Repository - .getTrackedEntityAttributeValue(searchItem.uid(), order.fieldUid) + .getTrackedEntityAttributeValue(getUid(searchItem), order.fieldUid) ?.takeIf { it in order.orderByGuid } } } diff --git a/ui-components/src/main/res/drawable/ic_delete.xml b/commons/src/main/res/drawable/ic_delete.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_delete.xml rename to commons/src/main/res/drawable/ic_delete.xml diff --git a/ui-components/src/main/res/drawable/ic_error_outline.xml b/commons/src/main/res/drawable/ic_error_outline.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_error_outline.xml rename to commons/src/main/res/drawable/ic_error_outline.xml diff --git a/ui-components/src/main/res/drawable/ic_event_status_complete.xml b/commons/src/main/res/drawable/ic_event_status_complete.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_event_status_complete.xml rename to commons/src/main/res/drawable/ic_event_status_complete.xml diff --git a/ui-components/src/main/res/drawable/ic_file.xml b/commons/src/main/res/drawable/ic_file.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_file.xml rename to commons/src/main/res/drawable/ic_file.xml diff --git a/ui-components/src/main/res/drawable/ic_file_download.xml b/commons/src/main/res/drawable/ic_file_download.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_file_download.xml rename to commons/src/main/res/drawable/ic_file_download.xml diff --git a/ui-components/src/main/res/drawable/ic_home_negative.xml b/commons/src/main/res/drawable/ic_home_negative.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_home_negative.xml rename to commons/src/main/res/drawable/ic_home_negative.xml diff --git a/ui-components/src/main/res/drawable/ic_home_outline.xml b/commons/src/main/res/drawable/ic_home_outline.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_home_outline.xml rename to commons/src/main/res/drawable/ic_home_outline.xml diff --git a/ui-components/src/main/res/drawable/ic_home_positive.xml b/commons/src/main/res/drawable/ic_home_positive.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_home_positive.xml rename to commons/src/main/res/drawable/ic_home_positive.xml diff --git a/ui-components/src/main/res/drawable/ic_input_info.xml b/commons/src/main/res/drawable/ic_input_info.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_input_info.xml rename to commons/src/main/res/drawable/ic_input_info.xml diff --git a/ui-components/src/main/res/drawable/ic_saved_check.xml b/commons/src/main/res/drawable/ic_saved_check.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_saved_check.xml rename to commons/src/main/res/drawable/ic_saved_check.xml diff --git a/ui-components/src/main/res/drawable/ic_tree_node_close.xml b/commons/src/main/res/drawable/ic_tree_node_close.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_tree_node_close.xml rename to commons/src/main/res/drawable/ic_tree_node_close.xml diff --git a/ui-components/src/main/res/drawable/ic_tree_node_default.xml b/commons/src/main/res/drawable/ic_tree_node_default.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_tree_node_default.xml rename to commons/src/main/res/drawable/ic_tree_node_default.xml diff --git a/ui-components/src/main/res/drawable/ic_tree_node_open.xml b/commons/src/main/res/drawable/ic_tree_node_open.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_tree_node_open.xml rename to commons/src/main/res/drawable/ic_tree_node_open.xml diff --git a/ui-components/src/main/res/drawable/ic_warning_alert.xml b/commons/src/main/res/drawable/ic_warning_alert.xml similarity index 100% rename from ui-components/src/main/res/drawable/ic_warning_alert.xml rename to commons/src/main/res/drawable/ic_warning_alert.xml diff --git a/ui-components/src/main/res/drawable/image_not_supported.xml b/commons/src/main/res/drawable/image_not_supported.xml similarity index 100% rename from ui-components/src/main/res/drawable/image_not_supported.xml rename to commons/src/main/res/drawable/image_not_supported.xml diff --git a/commons/src/main/res/layout/calendar_picker_view.xml b/commons/src/main/res/layout/calendar_picker_view.xml index 9e0dae8fcbf..65fdbd8f1c5 100644 --- a/commons/src/main/res/layout/calendar_picker_view.xml +++ b/commons/src/main/res/layout/calendar_picker_view.xml @@ -31,7 +31,6 @@ diff --git a/commons/src/main/res/layout/item_header_filter.xml b/commons/src/main/res/layout/item_header_filter.xml index 36920bd1b42..45f259dfdbb 100644 --- a/commons/src/main/res/layout/item_header_filter.xml +++ b/commons/src/main/res/layout/item_header_filter.xml @@ -41,7 +41,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:tint="@color/white" + app:tint="?onToolbarColor" tools:src="@drawable/ic_calendar_positive" /> + app:srcCompat="@drawable/ic_sort_deactivated" + app:tint="?onToolbarColor" /> + app:tint="?onToolbarColor" /> diff --git a/commons/src/main/res/values-ar-rIQ/strings.xml b/commons/src/main/res/values-ar-rIQ/strings.xml index c34ec174a81..94cd7cf5951 100644 --- a/commons/src/main/res/values-ar-rIQ/strings.xml +++ b/commons/src/main/res/values-ar-rIQ/strings.xml @@ -8,13 +8,10 @@ البرامج علاقات التفاصيل - التفاصيل البحث حزم البيانات - الحالة التاريخ تحديد نوع الفترة الزمنية - يومي أسبوعي كل اسبوعين شهري @@ -62,8 +59,6 @@ المؤشرات المخططات البيانية مكتمل - القيمة - البيانات اليوم إزالة يومي diff --git a/commons/src/main/res/values-ar/strings.xml b/commons/src/main/res/values-ar/strings.xml index 3259c450693..24fd62e1b7f 100644 --- a/commons/src/main/res/values-ar/strings.xml +++ b/commons/src/main/res/values-ar/strings.xml @@ -8,7 +8,6 @@ إلغاء نعم لا - الغاء التحديد عرض قائمة عرض جدول عرض خريطة @@ -19,7 +18,6 @@ الملاحظات إدخال البيانات التفاصيل - التفاصيل تحديد الخيار بحث الأحداث @@ -33,14 +31,11 @@ لم يتم تطبيق عوامل تصفية الفترة الزمنية الوحدة التنظيمية - فرز - الحالة حالة التزامن تم تعيينه لي التاريخ تمت متابعة %s تحديد نوع الفترة الزمنية - يومي أسبوعي كل اسبوعين أسبوعياً ابتداءً من يوم الأربعاء @@ -59,13 +54,10 @@ تبدأ السنة المالية في اكتوبر تبدأ السنة المالية في نوفمبر - - حالة التزامن للإرسال للتحديث - للحذف خطأ تحذير رفع @@ -158,7 +150,6 @@ كلمة المرور مفقودة. الرجاء إدخال كلمة المرور. اسم المستخدم مفقود. الرجاء إدخال اسم المستخدم. - يقوم هذا الخادم بتشغيل إصدار dhis مختلف عن 2.29. قم بتحديث إصدار الخادم الخاص بك أو استخدم بيئة الاختبار للمتابعة. يقوم هذا الخادم بتشغيل إصدار DHIS2 مختلف عن %s. قم بتحديث إصدار الخادم الخاص بك أو اتصل بالمسؤول. هناك خطأ ما. يرجى التحقق من صحة عنوان URL الخاص بالخادم واسم المستخدم وكلمة المرور وحاول مرة أخرى. آسف! فشل شيء ما من جانب الخادم. @@ -212,7 +203,6 @@ البحث على الإنترنت متاح فقط للوحدات التنظيمية المتاحة للمستخدم للبحث. الخادم غير متوفر حالياً. حاول مرة أخرى في وقت لاحق. أنت غير متصل بالإنترنت. اتصل بالإنترنت وحاول مرة أخرى. - هذا الحقل غير متاح للإدخال بالموبايل إضافة صورة إضافة ملف موافق\! @@ -221,9 +211,7 @@ ليس الان المراجعة مكتمل - القيمة تجاهل التغييرات - بيانات اتصال الشبكة غير متوفر يلزم الاتصال بالإنترنت لاستخدام الخرائط. @@ -231,14 +219,11 @@ موعد استحقاقها اليوم تم تنزيل الملف بنجاح - يجب عليك تقديم سبب للتحقق من %s في هذا البرنامج المحمي. سيتم تسجيل جميع الأنشطة. هذه البيانات غير قابلة للتحرير لأن %s اكتملت أو تم إلغاؤها مسجل في: حذف %s الأسابيع %d %s إلى %s - إعادة فتح - إعادة محاولة المزامنة مزامنة... إزلة @@ -250,4 +235,5 @@ ربع السنة ستة أشهر سنة - + تنظيف الكل + diff --git a/commons/src/main/res/values-bn/strings.xml b/commons/src/main/res/values-bn/strings.xml index b67f407d0cd..057ea8ec990 100644 --- a/commons/src/main/res/values-bn/strings.xml +++ b/commons/src/main/res/values-bn/strings.xml @@ -4,7 +4,6 @@ না মন্তব্য তথ্যের অন্তর্ভুক্তি - অবস্থা তারিখ ভুল সম্পন্ন diff --git a/commons/src/main/res/values-ckb/strings.xml b/commons/src/main/res/values-ckb/strings.xml index e8c0ed47541..cbb8da8f4b8 100644 --- a/commons/src/main/res/values-ckb/strings.xml +++ b/commons/src/main/res/values-ckb/strings.xml @@ -11,7 +11,6 @@ تةواو كرا ماوة - پێگە بەروار هەڵە تەواوبوو @@ -29,7 +28,6 @@ ڕاووبۆچون نیشاندەر تەواوبکە - بةها ئەمڕۆ لابردن diff --git a/commons/src/main/res/values-cs/strings.xml b/commons/src/main/res/values-cs/strings.xml index 16d1303beb7..3890fd67a7c 100644 --- a/commons/src/main/res/values-cs/strings.xml +++ b/commons/src/main/res/values-cs/strings.xml @@ -9,7 +9,6 @@ Zrušit Ano Ne - vymazat výběr Zobrazení seznamu Zobrazení tabulky Zobrazení mapy @@ -21,7 +20,6 @@ Poznámky Vstup dat Detaily - Detaily Vybrat možnost Hledat události @@ -35,8 +33,6 @@ Nejsou použity žádné filtry Období Org. jednotka - Třídění - Stav Synchronizace Přiděleno mně datum @@ -45,7 +41,6 @@ @string/enrollment_status Navázalo na %s Vybrat typ období - Denně Týdně po 14ti dnech Týdně od středy @@ -58,7 +53,6 @@ Čtvrtletně od listopadu Šestiměsíční Od dubna každých šest měsíců - Šestiměsíčně od července Počínaje listopadem šest měsíců Ročně Finanční rok začínající v dubnu @@ -66,13 +60,10 @@ Finanční rok začínající v říjnu Finanční rok začínající v listopadu - - Synchronizace Poslat Aktualizovat - Smazat Chyba Varování Nahrávám @@ -165,7 +156,6 @@ Chybí heslo. Prosím vložte heslo. Chybí uživatelské jméno. Zadejte uživatelské jméno. - Tento server používá jinou verzi DHIS než 2.29. Aktualizujte verzi serveru nebo pokračujte testovacím prostředím. Tento server používá verzi DHIS2 odlišnou od %s. Aktualizujte verzi serveru nebo kontaktujte svého administrátora. Něco se pokazilo. Zkontrolujte prosím správnost adresy URL serveru, uživatelského jména a hesla a zkuste to znovu. Promiňte! Něco selhalo na straně serveru. @@ -225,9 +215,7 @@ V dotazu jsou neplatné znaky. Požádejte správce o ověření konfigurace serveru (relaxedQueryChars,...). Server je momentálně nedostupný. Zkuste to později. Jste offline. Připojte se k internetu a zkuste to znovu. - Toto pole není k dispozici pro mobilní zachycení Přidat obrázek - Přidat podpis Přidat soubor Ok Poloha vašeho zařízení je odpojena. Povolte jej k zachycení souřadnic @@ -235,9 +223,7 @@ Teď ne Posouzení Dokončit - Hodnota Zrušit změny - Data Síťové připojení není k dispozici K používání map je nutné připojení k internetu. @@ -272,12 +258,10 @@ Soubor úspěšně stažen - Zobrazit souřadnice %s %s data %s souřadnice %s datum - %s stav V tomto chráněném programu musíte uvést důvod kontroly na %s. Veškerá činnost bude zaznamenána. Důvod pro kontrolu %s Tyto data nelze upravovat, protože %s je dokončen nebo zrušen @@ -285,14 +269,12 @@ %s zrušeno Nový %s Zapsáno v: - Program %s Více zápisů Odebrat z Obnovit tento záznam Zobrazit časovou osu Skupina podle etapy - %s dokončeno %s vytvořeno Nový %s @@ -306,12 +288,9 @@ Tato stránka %s nemůže mít datum v budoucnosti. Změňte ho prosím. Nepodařilo se vložit nový %s Týden %d %s až %s - Skupina %s podle etapy - Zobrazit časovou osu %s Nejsou zaregistrovány žádné stránky %s, kliknutím na tlačítko \"+\" přidáte novou. Nejsou registrovány žádné stránky %s. Na stránkách %s jsou plánovány akce, které nebudou přeplánovány. - znovu otevřít Opakování synchronizace Probíhá synchronizace ... @@ -328,4 +307,8 @@ Import databáze se nezdařil Neplatný soubor Není v režimu registrace TOTP 2FA + + Vymazat vše + + %s stav diff --git a/commons/src/main/res/values-da/strings.xml b/commons/src/main/res/values-da/strings.xml index 4cf9bb987b9..238317f52b3 100644 --- a/commons/src/main/res/values-da/strings.xml +++ b/commons/src/main/res/values-da/strings.xml @@ -6,11 +6,8 @@ No Programs Details - Details Data sets - Status Date - Daily Weekly Månedlige Bi-monthly diff --git a/commons/src/main/res/values-en-rUS/strings.xml b/commons/src/main/res/values-en-rUS/strings.xml index a9cba0beec0..aa28fde5a0a 100644 --- a/commons/src/main/res/values-en-rUS/strings.xml +++ b/commons/src/main/res/values-en-rUS/strings.xml @@ -10,7 +10,6 @@ Password is missing. Please enter password. Username is missing. Please enter username. - This server is running a dhis version different than 2.29. Update your server version or use the testing environment to proceed. This server is running a DHIS2 version different than %s. Update your server version or contact your admin. Something went wrong. Please check that server URL, username and password are correct and try again. Sorry! Something failed on the server side. @@ -83,4 +82,5 @@ Database import failed Invalid file Not in TOTP 2FA enrollment mode - + + diff --git a/commons/src/main/res/values-es-rES/strings.xml b/commons/src/main/res/values-es-rES/strings.xml index 7a24c9faab6..632edc9d7a9 100644 --- a/commons/src/main/res/values-es-rES/strings.xml +++ b/commons/src/main/res/values-es-rES/strings.xml @@ -9,7 +9,6 @@ Cancelar No - Borrar selección Vista de lista Vista de tabla Vista del mapa @@ -20,7 +19,6 @@ Notas Introducción de datos Detalles - Detalles Seleccionar opción Buscar Eventos @@ -34,13 +32,10 @@ Filtros desactivados Periodo Unidad Org. - Orden - Estado Sincronizado Asignado a mí Fecha Seguido %s - Diario Semanal Mensual Anual @@ -48,7 +43,6 @@ Sincronizado Por enviar Para actualizar - Para borrar Error Advertencia Subiendo @@ -138,7 +132,6 @@ La contraseña está vacía. Ingrese la contraseña. El usuario está vacío. Ingrese el usuario. - Este servidor trabaja sobre una versión de dhis distinta a 2.29. Actualice el servidor o utilize el entorno de pruebas para continuar. Este servidor tiene una version de DHIS2 diferente de %s. Actualice su servidor o contacte a su administrador. Algo ha ido mal. Por favor, compruebe que tanto la url del servidor como el usuario y contraseña son correctos y pruebe otra vez. Disculpe, hubo un error del lado del servidor. @@ -196,7 +189,6 @@ No hay suficientes atributos para realizar la búsqueda. Por favor redefina la búsqueda añadiendo más atributos. La búsqueda online solo está disponible para las unidades organizativas de búsqueda. El query contiene caracteres invalidos. Por favor contacte a su administrador para verificar la configuración del servidor (relaxedQueryChars,...). - Este campo no está disponible para la captura por móvil. Añadir imagen Añadir archivo De acuerdo @@ -205,15 +197,11 @@ Ahora no Revisar Completar - Valor Descartar los cambios - Datos Hoy Inscrito en: Eliminar %s Semana %d %s a %s - Reabrir - Sincronizando... Eliminar Diario diff --git a/commons/src/main/res/values-es/strings.xml b/commons/src/main/res/values-es/strings.xml index 608a9933e83..8bf422a50da 100644 --- a/commons/src/main/res/values-es/strings.xml +++ b/commons/src/main/res/values-es/strings.xml @@ -9,7 +9,6 @@ Cancelar No - Borrar selección Vista de lista Vista de tabla Vista del mapa @@ -21,7 +20,6 @@ Notas Introducción de datos Detalles - Detalles Seleccionar opción Buscar Eventos @@ -35,8 +33,6 @@ Filtros desactivados Periodo Unidad Org. - Orden - Estado Sincronizado Asignado a mí Fecha @@ -45,7 +41,6 @@ @string/enrollment_status Seguido %s Seleccionar tipo de periodo - Diario Semanal Quincenal Semanal con inicio el miércoles @@ -58,7 +53,6 @@ Trimestral con inicio en noviembre Semestral Semestral a partir de abril - Semestral con inicio en julio Semestral con inicio en noviembre Anual Año fiscal con inicio en abril @@ -66,13 +60,10 @@ Año fiscal con inicio en octubre Año fiscal con inicio en noviembre - - Sincronizado Por enviar Para actualizar - Para borrar Error Advertencia Subiendo @@ -165,7 +156,6 @@ La contraseña está vacía. Ingrese la contraseña. El usuario está vacío. Ingrese el usuario. - Este servidor trabaja sobre una versión de dhis distinta a 2.29. Actualice el servidor o utilize el entorno de pruebas para continuar. Este servidor tiene una version de DHIS2 diferente de %s. Actualice su servidor o contacte a su administrador. Algo ha ido mal. Por favor, compruebe que tanto la url del servidor como el usuario y contraseña son correctos y pruebe otra vez. Disculpe, hubo un error del lado del servidor. @@ -225,9 +215,7 @@ La consulta contiene caracteres inválidos. Por favor contacte a su administrador para verificar la configuración del servidor (relaxedQueryChars,...). El servidor no está disponible actualmente. Vuelve a intentarlo más tarde. Estas desconectado. Conéctese a Internet e inténtelo nuevamente. - Este campo no está disponible para la captura por móvil. Añadir imagen - Añadir firma Añadir archivo De acuerdo La ubicación de su dispositivo está desconectada. Por favor habilítela para capturar coordenadas. @@ -235,9 +223,7 @@ Ahora no Revisar Completar - Valor Descartar los cambios - Datos Conexión de red no disponible. Se requiere una conexión a Internet para poder utilizar los mapas. @@ -279,7 +265,6 @@ Archivo descargado correctamente - Registro @@ -290,7 +275,6 @@ %s datos %s coordenadas %s fecha - %s estado Debes proporcionar un motivo para consultar %s en este programa protegido. Toda la actividad será registrada. Motivo para consultar %s Estos datos no se pueden editar porque %s está completado o cancelado. @@ -298,14 +282,12 @@ %s se ha cancelado Nuevo %s Inscrito en: - Programa %s Más inscripciones Eliminar de Actualizar este registro Ver como línea de tiempo Ver por etapa - evento @@ -325,12 +307,9 @@ %s no puede tener una fecha en el futuro. Por favor, modifíquela. No fue posible insertar %s Semana %d %s a %s - Agrupar %s por etapa  - Mostrar línea de tiempo de %s No hay %s registrados. Haz clic en “+” para agregar uno nuevo. No hay %s registrados. Hay %s programados que no serán reprogramados - Reabrir Reintentar sincronización Sincronizando... @@ -346,4 +325,7 @@ Año fiscal Importación de la base de datos fallida Archivo no válido - + Limpiar todo + + %s estado + diff --git a/commons/src/main/res/values-fr/strings.xml b/commons/src/main/res/values-fr/strings.xml index 0007a893d5d..a8980102e90 100644 --- a/commons/src/main/res/values-fr/strings.xml +++ b/commons/src/main/res/values-fr/strings.xml @@ -8,7 +8,6 @@ Annuler Oui Non - effacer la sélection Vue de liste Vue de tableau Vue de la carte @@ -19,7 +18,6 @@ Notes Saisie de données Details - Details Sélectionnez une option Chercher événements @@ -33,13 +31,10 @@ Aucun flitre n\'est appliqué Période Unité d\'org. - Trier - Statut Synchronisation Assigné à moi Date Sélectionner type de période - Quotidien Hebdomadaire Bi-hebdomadaire Hebdomadaire débutant un mercredi @@ -58,13 +53,10 @@ Année financière débutant par octobre Année financière débutant par Novembre - - Synchronisation Envoyer Prêts à actualiser - A supprimer Erreur Attention Téléchargement en cours @@ -154,7 +146,6 @@ Le mot de passe est manquant. Veuillez entrer le mot de passe. Le nom d\'utilisateur est manquant. Veuillez entrer le nom d\'utilisateur. - Ce serveur a une version différente de 2.29. Mettre à jour la version ou utiliser un environnement d\'essai pour continuer L\'URL fournie n\'est pas un serveur DHIS2. Nom d\'utilisateur ou le mot de passe sont incorrects Serveur inconnu @@ -181,9 +172,7 @@ Il n\'y a pas assez d\'attributs pour effectuer la recherche. Veuillez affiner votre recherche avec plus d\'attributs. La recherche en ligne n\'est disponible que pour vos unités organisationnelles de recherche d\'utilisateurs. Vous n\'êtes pas connecté. Veuillez-vous connecter et réssayer. - Ce champ n\'est pas disponible pour la capture mobile Ajouter une image - Ajouter une signature Ajouter un fichier Ok L\'emplacement de votre appareil est déconnecté. Veuillez l\'activer pour capturer les coordonnées @@ -191,9 +180,7 @@ Pas maintenant Vérifier Terminer - Valeur Annuler les modifications - Données Connexion réseau indisponible Une connexion Internet est nécessaire pour utiliser les cartes. @@ -209,8 +196,6 @@ Inscrire à: Supprimer 1%s Semaine %d %s à %s - rouvrir - Réessayer la synchronisation Synchronisation... Supprimer @@ -220,4 +205,5 @@ Mois Trimestre Année - + Tout effacer + diff --git a/commons/src/main/res/values-hi-rIN/strings.xml b/commons/src/main/res/values-hi-rIN/strings.xml index 45fa1a37cb9..49ff7d5a414 100644 --- a/commons/src/main/res/values-hi-rIN/strings.xml +++ b/commons/src/main/res/values-hi-rIN/strings.xml @@ -11,8 +11,8 @@ डेटा सेट्स पूरा हुआ + पीरियड तारीख - रोज़ाना साप्ताहिक मासिक वार्षिक @@ -32,9 +32,11 @@ पिछला सप्ताह इस महीने पिछला महीना + दूसरा इस वर्ष पिछली तिमाही पिछला वर्ष + पीरियड रोज़ाना साप्ताहिक मासिक @@ -50,9 +52,7 @@ अभी नहीं रिव्यू पूरा हुआ - वैल्यू बदलावों को छोड़ना है - डेटा आज %d दिन बाकी है @@ -60,7 +60,6 @@ फ़ाइल सफलतापूर्वक डाउनलोड हो गई - %s डेटा सिंक हो रहा है… हटाएं diff --git a/commons/src/main/res/values-id/strings.xml b/commons/src/main/res/values-id/strings.xml index 03e4b6b5122..3520c397002 100644 --- a/commons/src/main/res/values-id/strings.xml +++ b/commons/src/main/res/values-id/strings.xml @@ -8,7 +8,6 @@ Batal Iya Tidak - hapus pilihan Tampilan daftar Tampilan tabel Tampilan peta @@ -19,7 +18,6 @@ Catatan Data entri Rincian - Rincian Pilih opsi Pencarian Even @@ -33,13 +31,10 @@ Tidak ada filter yang diterapkan Periode Organisasi Satuan - Penyortiran - Status Sinkronkan Ditugaskan kepada saya Tanggal Pilih tipe periode - Harian Mingguan Dwiwulanan Bulanan @@ -51,7 +46,6 @@ Sinkronkan Untuk dikirim Untuk diperbarui - Untuk dihapus Kesalahan Peringatan Mengunggah @@ -133,18 +127,13 @@ Grafik Grafik dan indikator - Server ini menjalankan versi dhis yang berbeda dari 2.29. Perbarui versi server Anda atau gunakan pengujian lingkungan untuk melanjutkan. Kesalahan saat penguraian hasil Tambahkan gambar Tambahkan berkas Lengkap - Nilai Hapus perubahan - Data Hari ini Minggu %d %s hingga %s - Buka kembali - Sedang sinkronisasi... Hapus Harian diff --git a/commons/src/main/res/values-km/strings.xml b/commons/src/main/res/values-km/strings.xml index e162244a975..f534228c85d 100644 --- a/commons/src/main/res/values-km/strings.xml +++ b/commons/src/main/res/values-km/strings.xml @@ -10,7 +10,6 @@ ទំនាក់ទំនង ចំណាំ លម្អិត - លម្អិត កំណត់​ជម្រើស ស្វែងរក ព្រឹត្តិការណ៏ @@ -18,10 +17,8 @@ Done កំឡុងពេល - ស្ថានភាព បញ្ចូន កាលបរិច្ឆេទ - រាល់​ថ្ងៃ រាល់​សប្ដាហ៍ រាល់​ខែ រាល់ពីរសប្តាហ៍ @@ -31,7 +28,6 @@ បញ្ចូន ដើម្បីធ្វើបច្ចុប្បន្នភាព - ដើម្បីលុប កំហុសបច្ចេកទេស បើក @@ -73,17 +69,12 @@ មតិ​ឆ្លើយតប Indicators គំនូសតាង(ក្រាប) - ម៉ាស៊ីនមេនេះកំពុងដំណើរការខុសពី DHIS ២.២៩។ សូមធ្វើបច្ចុប្បន្នភាពលើម៉ាស៊ីនមេរបស់អ្នក​ កំហុសខណៈពេលញែកលទ្ធផល បញ្ចូលឯកសារ រក្សាទុកការកែប្រែ បញ្ចប់ - តម្លៃ លុបការកែប្រែ - Data ថ្ងៃនេះ - បើកឡើងវិញ - លុបចេញ រាល់​ថ្ងៃ ឆ្នាំ diff --git a/commons/src/main/res/values-lo/strings.xml b/commons/src/main/res/values-lo/strings.xml index 1c3812c27c4..11cd8ae776d 100644 --- a/commons/src/main/res/values-lo/strings.xml +++ b/commons/src/main/res/values-lo/strings.xml @@ -9,7 +9,6 @@ Cancel ແມ່ນ ບໍ່ມີ - ລຶບການເລືອກ ສະແດງຮູບແບບລາຍການ ເບີ່ງຕາຕະລາງ ເບີ່ງແຜ່ນທີ່ @@ -20,7 +19,6 @@ ຂໍ້ຄວາມ ປ້ອນຂໍ້ມູນ ລາຍລະອຽດ - ລາຍລະອຽດ ເລືອກທາງເລືອກ ຄົນຫາ ເຫດການ @@ -34,14 +32,11 @@ ບໍ່ໄດ້ໃຊ້ຕົວກອງ ຊ່ວງເວລາ ຫົວ​ໜ່ວຍ​ການ​ຈັດ​ຕັ້ງ - ການຈັດຮຽງ - ສະຖານະ ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ ມອບໝາຍໃຫ້ຂ້ອຍ ວັນທີ່ ຕິດຕາມ%s ​ເລືອກ​ໄລ​ຍະ​ເວ​ລາ​ປະ​ເພດ​ຕ່າງໆ - ທຸກໆມື້ ລາຍອາທິດ ສອງອາທິດຕໍ່ຄັ້ງ ລາຍເດືອນ @@ -53,7 +48,6 @@ ອັບເດດຂໍ້ມູນໃຫ້ຄືກັນ ສົ່ງ ຕ້ອງການອັບເດດ - ເພື່ອລືບອອກ ເກີດການຜິດພາດ ແຈ້ງເຕືອນ ກຳລັງອັບໂຫຼດ @@ -143,7 +137,6 @@ ບໍ່ມີລະຫັດຜ່ານ. ກະລຸນາໃສ່ລະຫັດຜ່ານ. ບໍ່ມີຊື່ຜູ້ໃຊ້. ກະລຸນາປ້ອນຊື່ຜູ້ໃຊ້ - ຄອມພີວເຕີໜ່ວຍແມ່ກໍາລັງທໍາງານກັບລະບົບ DHIS2 ລຸ້ນສູງກ່ວາ 2.29. ກະລຸນາ ນໍາໃຊ້ລຸນທີ່ສູງກ່ວາ ຫຼື ນໍາໃຊ້ລະບົບທົດລອງແທນ ຄອມພິວເຕີໜ່ວຍແມ່ເຄື່ອງນີ້ກຳລັງໃຊ້ເວີຊັນ DHIS2 ທີ່ແຕກຕ່າງຈາກ%s . ອັບເດດລຸ່ນຂອງຄອມພິວເຕີເຄື່ອງແມ່ຂອງທ່ານ ຫຼື ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. ມີບາງສິ່ງບາງຢ່າງຜິດພາດ. ກະລຸນາກວດເບິ່ງທີ່ຢູ່ຂອງເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່, ຊື່ຜູ້ໃຊ້ ແລະ ລະຫັດຜ່ານວ່າຖືກຕ້ອງແລ້ວ ແລ້ວລອງໃໝ່ອີກຄັ້ງ. ຂໍອາໄພ! ມີບາງຢ່າງລົ້ມເຫຼວໃນເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່. @@ -199,9 +192,7 @@ ຄຸນລັກສະນະບໍ່ພຽງພໍທີ່ຈະດໍາເນີນການຄົ້ນຫາ. ກະລຸນາປັບປຸງການຄົ້ນຫາຂອງທ່ານດ້ວຍຄຸນລັກສະນະເພີ່ມເຕີມ. ການຄົ້ນຫາອອນລາຍໃຊ້ໄດ້ກັບຫົວຫນ່ວຍການຈັດຕັ້ງທີ່ຄົ້ນຫາຜູ້ໃຊ້ຂອງທ່ານເທົ່ານັ້ນ. ທ່ານອອຟລາຍຢູ່. ເຊື່ອມຕໍ່ອິນເຕີເນັດ ແລະ ລອງໃໝ່ອີກຄັ້ງ - ຫ້ອງນີ້ຂໍ້ມູນບໍ່ສາມາດໃຊ້ໄດ້ສໍາລັບການຈັບພາບມືຖື ເພີ່ມຮູບພາບ - ເພີ່ມລາຍເຊັນ ເອົາຟາຍຂໍ້ມູນເຂົ້າ ຕົກລົງ ສະຖານທີ່ອຸປະກອນຂອງທ່ານຖືກຕັດການເຊື່ອມຕໍ່. ກະລຸນາເປີດໃຊ້ມັນເພື່ອບັນທຶກພິກັດ @@ -209,9 +200,7 @@ ບໍ່​ແມ່ນ​ຕອນ​ນີ້ ທົບທວນຄືນ ສຳເລັດແລ້ວ - Value ລຶບການປ່ຽນແປງ - ຂໍ້ມູນ ການເຊື່ອມຕໍ່ເຄືອຂ່າຍບໍ່ສາມາດໃຊ້ໄດ້ ຈຳເປັນຕ້ອງມີການເຊື່ອມຕໍ່ອິນເຕີເນັດເພື່ອໃຊ້ແຜນທີ່. @@ -219,12 +208,11 @@ ລົງທະບຽນໃນ: ລົບ%s ອາທິດ%d%sຫາ%s - ເປີດຄືນໃໝ່ - ກຳລັງຊິ້ງຂໍ້ມູນ ລົບອອກ ທຸກໆມື້ ອາທິດ ເດືອນ ປີ - + ລຶບທັງໝົດ + diff --git a/commons/src/main/res/values-my/strings.xml b/commons/src/main/res/values-my/strings.xml index f21e2cf4b6c..0d5538640b4 100644 --- a/commons/src/main/res/values-my/strings.xml +++ b/commons/src/main/res/values-my/strings.xml @@ -8,14 +8,11 @@ Programs မှတ်စု အသေးစိတ်များ - အသေးစိတ်များ စီမံချက် Done အချိန်ကာလ - အခြေအနေ ရက်စွဲ - နေ့စဉ် အပတ်စဉ် လစဉ် နှစ်လတစ်ကြိမ် @@ -47,8 +44,6 @@ အညွှန်းများ ဂရပ် ပုံစံများ ပြည့်စုံသည်။ - တန်ဖိုး - အချက်အလက် ဖယ်ရှားခြင်း နေ့စဉ် ခုနှစ် diff --git a/commons/src/main/res/values-nb/strings.xml b/commons/src/main/res/values-nb/strings.xml index ea9b4ffa21a..1b7a926bfb5 100644 --- a/commons/src/main/res/values-nb/strings.xml +++ b/commons/src/main/res/values-nb/strings.xml @@ -8,7 +8,6 @@ Avbryt Ja Nei - tøm valg Listevisning Tabellvisning Kartvisning @@ -19,7 +18,6 @@ Notater Dataregistrering Detaljer - Detaljer Velg et alternativ Søk hendelser @@ -33,13 +31,10 @@ Ingen filter brukt Periode Org. Enhet - Sorterer - Status Synk Tildelt til meg Dato Velg periodetype - Daglig Ukentlig Toukentlig Ukentlig, startes onsdag @@ -58,13 +53,10 @@ Regnskapsår med start oktober Regnskapsår med start november - - Synk Å sende Å oppdatere - Å slette Feil Advarsel Relasjon @@ -132,7 +124,6 @@ Tilbakemelding Indikatorer Diagrammer - Denne serveren kjører en dhis versjon som er forskjellig fra 2.29. Oppdater din serverversjon eller bruk testmiljøet for å fortsette. Feil under analyse av resultater Legg til bilde Legg til fil @@ -140,12 +131,8 @@ Enhetsplasseringen din er koblet fra. Aktiver den for å registrere koordinater Fortsett å rediger Fullfør - Verdi Forkast endringer - Data I dag - Åpne igjen - Synkroniserer... Fjern Daglig @@ -154,4 +141,5 @@ Måned Kvartal År - + Fjern alt + diff --git a/commons/src/main/res/values-ne/strings.xml b/commons/src/main/res/values-ne/strings.xml index 415407a4070..a10a804bf45 100644 --- a/commons/src/main/res/values-ne/strings.xml +++ b/commons/src/main/res/values-ne/strings.xml @@ -4,10 +4,8 @@ रद्द गर्नुहोस् खोज अबधि - स्थिती मलाई निर्दिष्ट गरिएको मिति - दैनिक खोल्नुहोस् खोल्नुहोस् diff --git a/commons/src/main/res/values-nl/strings.xml b/commons/src/main/res/values-nl/strings.xml index 72dc3cbe25c..57d5aa8fc73 100644 --- a/commons/src/main/res/values-nl/strings.xml +++ b/commons/src/main/res/values-nl/strings.xml @@ -9,7 +9,6 @@ Annuleren Ja Nee - duidelijke selectie Lijstweergave Tabel weergave Kaartweergave @@ -20,7 +19,6 @@ Notities Gegevensinvoer Details - Details Selecteer optie Zoek evenementen @@ -34,13 +32,10 @@ Geen filters toegepast Periode Org. Eenheid - Sorteren - status Synchroniseren Toegewezen aan mij Datum Gevolgd %s - Dagelijks Wekelijks Tweewekelijks Maandelijks @@ -52,7 +47,6 @@ Synchroniseren Versturen Updaten - Verwijderen Fout Waarschuwing Uploaden @@ -142,7 +136,6 @@ Wachtwoord ontbreekt. Voer wachtwoord in alstublieft. Gebruikersnaam ontbreekt. Voer gebruikersnaam in. - Deze server draait een andere versie dan 2.29. Werk uw serverversie bij of gebruik de testomgeving om verder te gaan. Deze server draait een andere DHIS2-versie dan %s. Werk uw serverversie bij of neem contact op met uw beheerder. Er is iets fout gegaan. Controleer of de server-URL, gebruikersnaam en wachtwoord juist zijn en probeer het opnieuw. Sorry! Er is iets misgegaan aan de serverzijde. @@ -200,7 +193,6 @@ Er zijn niet genoeg attributen om de zoekopdracht uit te voeren. Verfijn uw zoekopdracht met meer attributen. Online zoeken is alleen beschikbaar voor uw organisatie-eenheden voor het zoeken van gebruikers. Er zijn ongeldige tekens in de vraag. Vraag de beheerder om de serverconfiguratie te verifiëren (relaxedQueryChars,...). - Dit veld is niet beschikbaar voor mobiel vastleggen Voeg afbeelding toe Bestand toevoegen OK @@ -209,9 +201,7 @@ Niet nu Beoordeling Compleet - Waarde Veranderingen ongedaan maken - Gegevens Netwerkverbinding niet beschikbaar Voor het gebruik van maps is een internetverbinding vereist. @@ -221,8 +211,6 @@ Ingeschreven in: Verwijder %s Week %d %s tot %s - heropenen - Synchroniseren... Verwijderen Dagelijks diff --git a/commons/src/main/res/values-or/strings.xml b/commons/src/main/res/values-or/strings.xml index 95b5a47fea6..e34313ae89a 100644 --- a/commons/src/main/res/values-or/strings.xml +++ b/commons/src/main/res/values-or/strings.xml @@ -3,11 +3,9 @@ ହଁ ନାହିଁ ବିବରଣୀ - ବିବରଣୀ ସୂଚନା ହୋଇଗଲା - ସଜାଣି ସାପ୍ତାହିକ ମାସିକ ବାର୍ଷିକ diff --git a/commons/src/main/res/values-prs/strings.xml b/commons/src/main/res/values-prs/strings.xml index ea0ae3f4997..50efaaea1bc 100644 --- a/commons/src/main/res/values-prs/strings.xml +++ b/commons/src/main/res/values-prs/strings.xml @@ -12,18 +12,14 @@ یاداشت ها وارد کردن معلومات جزئیات - جزئیات انتخاب گزینه جستجو دیتا سیت ها انجام شد دوره - تنظیم کردن - وضعیت تاریخ انتخاب نوع دوره - روزنه هفته وار ماهانه دوبار در- ماه @@ -73,8 +69,6 @@ چارت ها او کی\! تکمیل - مقدار - دیتا امروز حذف روزنه diff --git a/commons/src/main/res/values-ps/strings.xml b/commons/src/main/res/values-ps/strings.xml index 7ec567f8d1e..516892c8eca 100644 --- a/commons/src/main/res/values-ps/strings.xml +++ b/commons/src/main/res/values-ps/strings.xml @@ -12,16 +12,13 @@ یادداښتونه د ډاټا یا مالوماتو داخلول جزیات [ ژباړه ـ جزیات ] - جزیات [ ژباړه ـ جزیات ] جستجو د مالوماتو یا ډاټا ټولګې ترسره شو معیاد - وضعیت نېټه د مودې ډول غوره کړئ. - ورځني [ د مودې ډول ] اوونیز میاشتنۍ دوه میاشتنۍ @@ -73,8 +70,6 @@ شاخصونه چارټونه بشپړ یې کړئ - ارزښت یا رقم - مالومات (ډاټا) نن ورځ لرې کول ورځني [ د مودې ډول ] diff --git a/commons/src/main/res/values-pt-rBR/strings.xml b/commons/src/main/res/values-pt-rBR/strings.xml index 8708140ea77..acaa0069821 100644 --- a/commons/src/main/res/values-pt-rBR/strings.xml +++ b/commons/src/main/res/values-pt-rBR/strings.xml @@ -10,16 +10,13 @@ Notas Entrada de dados Detalhes - Detalhes Pesquisar Agregação de Dados Concluído Período - Estado Data Seleccione o tipo de período - Diário Semanal Mensal Bimensal @@ -66,12 +63,12 @@ Indicadores Esta bem Completo - Valor - Dados Hoje Remover Diário Semana Mês Ano + Limpar tudo + diff --git a/commons/src/main/res/values-pt/strings.xml b/commons/src/main/res/values-pt/strings.xml index df8ac8934cb..1d4f58714ce 100644 --- a/commons/src/main/res/values-pt/strings.xml +++ b/commons/src/main/res/values-pt/strings.xml @@ -9,7 +9,6 @@ Cancelar Sim Não - seleção clara Vista de lista Vista de tabela Vista de mapa @@ -21,7 +20,6 @@ Notas Entrada de dados Detalhes - Detalhes Selecionar opção Pesquisar eventos @@ -35,14 +33,11 @@ Nenhum filtro aplicado Período Unidade Org. - Ordem - Status Sincronizar Atribuído a mim Data Acompanhado%s Seleccionar tipo de período - Diário Semanal Quinzenal Semanalmente começando na quarta-feira @@ -61,13 +56,10 @@ Ano financeiro começando em outubro Ano financeiro começando em novembro - - Sincronizar Para enviar Atualizar - Suprimir Erro Advertência A carregar @@ -160,7 +152,6 @@ A palavra-passe está em falta. Introduza a palavra-passe. O nome de utilizador está em falta. Por favor, introduza o nome de utilizador. - Este servidor está executando uma versão dhis diferente de 2.29. Atualize a versão do servidor ou use o ambiente de teste para continuar. Este servidor está a executar uma versão do DHIS2 diferente de %s. Actualize a versão do seu servidor ou contacte o seu administrador. Algo correu mal. Verifique se o URL do servidor, o nome de utilizador e a palavra-passe estão correctos e tente novamente. Desculpe! Algo falhou no lado do servidor. @@ -220,9 +211,7 @@ Existem caracteres inválidos na consulta. Por favor, peça ao administrador para verificar a configuração do servidor (relaxedQueryChars,...). O servidor está indisponível do momento. Tente novamente mais tarde. Está off-line. Ligue-se à Internet e tente novamente. - Este campo não está disponível para captura móvel Adicionar imagem - Adicionar assinatura Adicionar ficheiro Esta bem A localização do seu dispositivo está desconectada. Por favor ative-o para capturar coordenadas @@ -230,9 +219,7 @@ Agora não Revisão Completo - Valor Descartar mudanças - Dados Ligação a rede indisponível É necessária uma ligação à Internet para utilizar mapas. @@ -262,8 +249,6 @@ Novo %s Apagar%s Semana %d %s a%s - reabrir - Repetir a sincronização Sincronizando... Remover @@ -273,4 +258,5 @@ Mês Trimestral Ano - + Limpar tudo + diff --git a/commons/src/main/res/values-ro/strings.xml b/commons/src/main/res/values-ro/strings.xml index 1a7483d1444..86688eb8b18 100644 --- a/commons/src/main/res/values-ro/strings.xml +++ b/commons/src/main/res/values-ro/strings.xml @@ -10,7 +10,6 @@ evenimente Terminat - Stare Data Selectați tipul perioadei Lunar diff --git a/commons/src/main/res/values-ru/strings.xml b/commons/src/main/res/values-ru/strings.xml index 091ced00e8e..29c75004f82 100644 --- a/commons/src/main/res/values-ru/strings.xml +++ b/commons/src/main/res/values-ru/strings.xml @@ -8,7 +8,6 @@ Отмена Да Нет - очистить выбор В виде списка В виде таблицы В виде карты @@ -20,7 +19,6 @@ Заметки Ввод данных Детали - Детали Выберите опцию Поиск события/й @@ -34,8 +32,6 @@ Фильтры не применены Период Орг. единица - Сортировка - Статус Синхронизация Назначен мне Дата @@ -44,7 +40,6 @@ Cтатус регистрации Отмеченные для последующего наблюдения %s Выбрать тип периода - Ежедневно Еженедельно Каждые две недели Еженедельно со среды @@ -56,7 +51,6 @@ Ежеквартально Раз в полугодие По шесть месяцев начиная с апреля - Раз в полгода, начиная с июля Раз в полгода, начиная с ноября Ежегодно Финансовый год начиная с апреля @@ -64,13 +58,10 @@ Финансовый год начиная с октября Финансовый год начиная с ноября - - Синхронизация Отправить Обновить - Удалить Ошибка Предупреждение Загрузка @@ -162,7 +153,6 @@ Пароль отсутствует. Пожалуйста, введите пароль. Имя пользователя отсутствует. Пожалуйста, введите имя пользователя. - На этом сервере установлена версия dhis, отличная от 2.29. Обновите версию своего сервера или используйте тестовую среду для продолжения работы. На этом сервере установлена версия DHIS2, отличная от %s. Обновите версию сервера или обратитесь к администратору. Что-то пошло не так. Пожалуйста, проверьте правильность URL-адреса сервера, имени пользователя и пароля и повторите попытку. Извините! Что-то не получилось на стороне сервера. @@ -209,9 +199,7 @@ Онлайн-поиск доступен только для подразделений вашей организации по поиску пользователей. Сервер в настоящее время недоступен. Повторите попытку позже. Вы находитесь оффлайн. Подключитесь к Интернету и повторите попытку. - Это поле недоступно для мобильного заполнения Добавить изображение - Добавить подпись Добавить файл Ok Местоположение вашего устройства отключено. Пожалуйста, включите его для получения координат @@ -219,9 +207,7 @@ Не сейчас Просмотреть Завершенный - Значение Сбросить изменения - Данные Сетевое подключение недоступно Для использования карт необходимо подключение к Интернету. @@ -269,7 +255,6 @@ Файл успешно загружен - зачисление @@ -279,7 +264,6 @@ %s данных %s дата - %s статус Вы должны указать причину для проверки %s в этой защищенной программе. Все действия будут регистрироваться. Причина для проверки %s Эти данные нельзя редактировать, поскольку %s завершен или отменен. @@ -287,14 +271,12 @@ %s отменено Новый %s Зарегистрирован в: - Программа %s Больше регистраций Удалить из Обновить эту запись Просмотр хронологии Группировать по этапам - мероприятие @@ -315,12 +297,9 @@ У этого %s не может быть даты в будущем. Пожалуйста, измените ее. Не удалось вставить новый %s Неделя %d %s до %s - Группировать %s по этапам - Показать хронологию %s Нет зарегистрированных %s, нажмите \"+\", чтобы добавить новый. Нет зарегистрированных %s. Есть запланированные %s, которые не будут перенесены. - снова открыть Повторить синхронизацию Синхронизация... @@ -331,4 +310,7 @@ Месяц Квартал Год - + Очистить все + + %s статус + diff --git a/commons/src/main/res/values-si/strings.xml b/commons/src/main/res/values-si/strings.xml index 29628b9ac79..9ee4c5153cc 100644 --- a/commons/src/main/res/values-si/strings.xml +++ b/commons/src/main/res/values-si/strings.xml @@ -7,7 +7,6 @@ කළා දිනය - දිනපතා සතිපතා දෙසතියකට වරක් මාස්පතා @@ -38,9 +37,7 @@ ඊයේ ප්‍රස්ථාර - අගය වෙනස් කිරීම් ඉවතලන්න - දත්ත අද ඉවත් කරන්න දිනපතා diff --git a/commons/src/main/res/values-sv/strings.xml b/commons/src/main/res/values-sv/strings.xml index 20f9a44f588..1d90835c3d3 100644 --- a/commons/src/main/res/values-sv/strings.xml +++ b/commons/src/main/res/values-sv/strings.xml @@ -9,17 +9,14 @@ program Relationer detaljer - detaljer Välj alternativ Sök händelser dataset TEI Period - Status Synkronisera Datum - Dagligen Varje vecka En gång i månaden varannan månad @@ -29,7 +26,6 @@ Synkronisera Att uppdatera - Att radera Fel Varning @@ -65,17 +61,14 @@ Återkoppling indikatorer diagram - Den här servern kör en dhis-version som skiljer sig från 2.29. Uppdatera din serverversion eller använd testmiljön för att fortsätta. Fel vid tolkning av resultat Lägg till fil Fortsätt redigera Färdigt - Värde Ignorera ändringar - Data - Öppna igen - Ta bort Dagligen År + Rensa alla + diff --git a/commons/src/main/res/values-tet/strings.xml b/commons/src/main/res/values-tet/strings.xml index f8fe5cc550a..893f89f78ee 100644 --- a/commons/src/main/res/values-tet/strings.xml +++ b/commons/src/main/res/values-tet/strings.xml @@ -6,14 +6,11 @@ Sim Lae Detalles - Detalles Hili opsaun Pakote dadus Períodu - Estadu Data Hili Tipu períodu - Diáriu Semanal Mensal Bimensal @@ -45,8 +42,6 @@ Feedback Indikadores Kompletu - Valor - Dadus Hasai Diáriu Tinan diff --git a/commons/src/main/res/values-tg/strings.xml b/commons/src/main/res/values-tg/strings.xml index ecb8aa6339d..bc5064b3e92 100644 --- a/commons/src/main/res/values-tg/strings.xml +++ b/commons/src/main/res/values-tg/strings.xml @@ -8,16 +8,13 @@ Барномаҳо Ворид кардани иттилоот Маълумоти муфассал - Маълумоти муфассал Ҷустуҷӯ Маҷмӯъҳои иттилоот Иҷро шуд Давра - Ҳолат Сана Интихоби навъи давра - Ҳаррӯза Ҳафтаина Моҳона Ду маротиба дар як моҳ @@ -55,8 +52,6 @@ Изҳори назар Индикаторҳо Анҷом ёфт - Қимат - Иттилоот Имрӯз Тоза кардан Ҳаррӯза diff --git a/commons/src/main/res/values-uk/strings.xml b/commons/src/main/res/values-uk/strings.xml index 9d3be7fc57f..b82b29502ef 100644 --- a/commons/src/main/res/values-uk/strings.xml +++ b/commons/src/main/res/values-uk/strings.xml @@ -9,7 +9,6 @@ Скасувати Так Ні - очистити вибір У вигляді списку У вигляді таблиці У вигляді карті @@ -20,7 +19,6 @@ Примітки Введення даних Деталі - Деталі Обрати опцію Пошук випадки @@ -34,15 +32,12 @@ Фільтрів не застосовано Період Орг. підрозділ - Сортування - Статус Синхронізувати Призначено мені Дата Дата %s Для подальших дій %s Обрати тип періоду - Щодня Щотижня Щодва тижні Щомісяця @@ -54,7 +49,6 @@ Синхронізувати Надіслати Оновити - Видалити Помилка Попередження Завантаження @@ -147,7 +141,6 @@ Пароль відсутній. Будь ласка, введіть пароль. Ім\'я користувача відсутнє. Будь ласка, введіть ім\'я користувача. - На цьому сервері працює інша версія dhis, ніж 2.29. Щоб продовжити, оновіть версію серверу або скористайтеся середовищем тестування. На цьому сервері працює версія DHIS2, що відрізняється від %s. Оновіть версію серверу або сконтактуйте з системним адміністратором. Сталася помилка. Будь ласка, перевірте правильність URL-адреси серверу, імені користувача та пароля і повторіть спробу. Вибачте! На сервері сталася помилка. @@ -205,9 +198,7 @@ Недостатньо атрибутів для виконання пошуку. Уточніть пошук за допомогою додаткових атрибутів. Онлайн-пошук доступний лише для організаційних підрозділів користувача. У запиті є недійсні символи. Попросіть адміністратора перевірити конфігурацію сервера (relaxedQueryChars,...). - Це поле недоступне в мобільній версії Додати зображення - Додати підпис Додати файл OK Розташування Вашого пристрою відключено. Будь ласка, увімкніть його для отримання координат @@ -215,9 +206,7 @@ Не зараз Перегляд Завершити - Значення Відхилити внесені зміни - Дані Сьогодні %d день прострочення @@ -257,11 +246,10 @@ %s успішно видалено Ви впевнені, що хочете видалити %s? Тиждень %d %s до %s - повторно відкрити - Синхронізація... Видалити Щодня Тиждень Рік - + Очистити все + diff --git a/commons/src/main/res/values-ur/strings.xml b/commons/src/main/res/values-ur/strings.xml index a5a12904c62..28b6ff218fe 100644 --- a/commons/src/main/res/values-ur/strings.xml +++ b/commons/src/main/res/values-ur/strings.xml @@ -11,17 +11,13 @@ نوٹس ڈیٹا انٹری تفصیلات - تفصیلات تلاش کریں ڈیٹا سیٹس ہو گیا مدت - ترتیب دیں - حالت تاریخ مدت کی قسم منتخب کریں - روزانہ ہفتہ وار ہفتہ وار شروع ہونے والا ہفتہ جمعرات سے شروع ہونے والا ہفتہ @@ -85,8 +81,6 @@ چارٹس ٹھیک ہے\! مکمل - قدر - ڈیٹا آج حذف روزانہ diff --git a/commons/src/main/res/values-uz-rUZ/strings.xml b/commons/src/main/res/values-uz-rUZ/strings.xml index a119ce757b7..1aa348770d0 100644 --- a/commons/src/main/res/values-uz-rUZ/strings.xml +++ b/commons/src/main/res/values-uz-rUZ/strings.xml @@ -8,7 +8,6 @@ Бекор қилиш Ҳа Йўқ - aniq tanlov Roʼyxat koʼrinishi Jadval koʼrinishi Xarita koʼrinishi @@ -19,7 +18,6 @@ Izohlar Maʼlumot kiritish Тафсилотлари - Тафсилотлари Variant tanlang Izlash Maʼlumotlar toʼplami @@ -30,13 +28,10 @@ Hech qanday filtr qoʼllanilmagan davr Tashkiliy boʼlim - Tartiblash - Status Sinxronlash Menga berilgan vakolat Sana Davr turini tanlash - Кунлик Ҳафталик Juft hafta Ойлик @@ -48,7 +43,6 @@ Sinxronlash Yuborish uchun Yangilash uchun - Oʼchirib tashlash uchun Xatolik Ogohlantirish Oʼzaro aloqa @@ -116,7 +110,6 @@ Fikr-mulohaza Koʼrsatgichlar Diagrammalar - Ushbu server 2.29 dan farqli versiyani ishlatmoqda. Server versiyasini yangilang yoki davom etish uchun sinov muddatidan foydalaning. Nimadir notoʼgʼri bajarildi. Server URL manzili, foydalanuvchi nomi va parol toʼgʼri ekanligini tekshiring va qaytadan urining. Natijalarni tahlil qilishda xatolik yuz berdi Rasm qoʼshish @@ -125,15 +118,12 @@ Tahrirni davom eting Ko‘rib chiqish Bajarildi - Қиймат Oʼzgartirishlar bekor qilinsin - Маълумот бугун - qayta ochish - Sinxronizatsiyalanmoqda... Олиб ташлаш Кунлик Hafta Yil - + Barchasini tozalash + diff --git a/commons/src/main/res/values-uz/strings.xml b/commons/src/main/res/values-uz/strings.xml index 3965897d0ef..971dfe7ab20 100644 --- a/commons/src/main/res/values-uz/strings.xml +++ b/commons/src/main/res/values-uz/strings.xml @@ -8,7 +8,6 @@ Бекор қилиш Ҳа Йўқ - аниқ танлов Рўйхат кўриниши Жадвал кўриниши Харита кўриниши @@ -19,7 +18,6 @@ Изоҳлар Маълумот киритиш Тафсилотлари - Тафсилотлари Вариант танланг Қидириш ҳодиса/тадбирлар @@ -33,13 +31,10 @@ Ҳеч қандай фильтр қўлланилмаган Давр Ташкилий бўлим - Тартиблаш - Статус Синхронлаш Менга берилган ваколат Сана Давр турини танлаш - Кунлик Ҳафталик Жуфт ҳафталик Чоршанба кунидан бошлаб ҳар ҳафта @@ -58,13 +53,10 @@ Молиявий йил Октябрдан бошлаб Молиявий йил Ноябрдан бошлаб - - Синхронлаш Юбориш учун Янгилаш учун - Ўчириб ташлаш учун Хатолик Огоҳлантириш Юкланмоқда @@ -141,11 +133,8 @@ Диаграммалар Диаграммалар ва индикаторлар - Ушбу сервер 2.29 дан фарқли версияни ишлатмоқда. Сервер версиясини янгиланг ёки давом этиш учун синов муддатидан фойдаланинг. Натижаларни таҳлил қилишда хатолик юз берди - Ушбу сатр мобил назорат учун мавжуд эмас Расм қўшиш - Имзо қўшинг Файл қўшиш Яхши Сизнинг қўрилмангиз интернет алоқаси узилди. Илтимос, координаталарни олиш учун уни ёқинг @@ -153,13 +142,9 @@ Hozir emas Кўриб чиқиш Тўлдириш - Қиймат Ўзгартиришлар бекор қилинсин - Маълумот Бугун Қайд этилган: - қайта очиш - Синхронизацияланмоқда... Олиб ташлаш Кунлик @@ -168,4 +153,5 @@ Ой Чорак Йил - + Барчасини тозалаш + diff --git a/commons/src/main/res/values-vi/strings.xml b/commons/src/main/res/values-vi/strings.xml index 5f6402a327a..400d463ffa7 100644 --- a/commons/src/main/res/values-vi/strings.xml +++ b/commons/src/main/res/values-vi/strings.xml @@ -9,7 +9,6 @@ Hủy Không - Bỏ chọn Xem danh sách Xem bảng Xem bản đồ @@ -21,7 +20,6 @@ Ghi Chú Nhập dữ liệu Chi tiết - Chi tiết Chọn Tìm kiếm sự kiện @@ -35,8 +33,6 @@ Không có bộ lọc nào được áp dụng Thời điểm Đơn vị - Sắp xếp thứ tự - Trạng thái Đồng bộ Gán cho tôi Ngày @@ -45,7 +41,6 @@ @string/enrollment_status Theo dõi %s Chọn loại thời điểm - Hàng Ngày Hàng Tuần Định kỳ 2 tuần Hàng tuần bắt đầu từ thứ Tư @@ -63,7 +58,6 @@ Đồng bộ Gửi Cập nhật - Xóa Lỗi Cảnh báo Đang tải lên @@ -156,7 +150,6 @@ Thiếu mật khẩu. Xin vui lòng nhập mật khẩu Thiếu tên đăng nhập. Xin vui lòng nhập tên đăng nhập. - Máy chủ không chạy dhis phiên bản 2.29. Nâng cấp phiên bản máy chủ hoặc sử dụng môi trường thử nghiệm để thực hiện. Máy chủ này đang chạy phiên bản DHIS2 khác với %s. Vui lòng cập nhật phiên bản máy chủ của bạn hoặc liên hệ quản lý. Có gì đó không đúng. Vui lòng kiểm tra đường dẫn liên kết đến máy chủ, tên đăng nhập, mật khẩu và thử lại. Rất tiếc! Có lỗi gì đó ở phía máy chủ @@ -216,9 +209,7 @@ Có các ký tự không hợp lệ trong yêu cầu. Vui lòng liên hệ quản lý để xác nhận cấu hình máy chủ (relaxQueryChars,...) Không thể truy cập máy chủ. Xin vui lòng thử lại sau. Bạn đang ngoại tuyến. Vui lòng kết nối mạng và thử lại - Trường này không hiển thị để nhập dữ liệu bằng điện thoại Thêm hình ảnh - Thêm chữ ký Thêm tập tin Ok Tính năng chia sẻ vị trí thiết bị của bạn bị tắt. Vui lòng mở nó để thu thập tọa độ. @@ -226,9 +217,7 @@ Không phải bây giờ Xem xét Hoàn tất - Giá trị Hủy bỏ các thay đổi - Dữ liệu Không có kết nối mạng Cần có kết nối mạng để sử dụng bản đồ @@ -247,10 +236,8 @@ Tệp đã tải xuống thành công - Dữ liệu %s %s ngày - %s trạng thái %s đã hủy %sMới Đã đăng ký vào: @@ -258,8 +245,6 @@ %s ngày Xóa %s Tuần %d %s đến %s - mở lại - Đồng bộ lại Đang đồng bộ... Xóa @@ -271,4 +256,8 @@ Nhập cơ sở dữ liệu không thành công Tệp không hợp lệ Không ở chế độ đăng ký TOTP 2FA + + Xóa tất cả + + %s trạng thái diff --git a/commons/src/main/res/values-zh-rCN/strings.xml b/commons/src/main/res/values-zh-rCN/strings.xml index f7f33fdbf19..2bdfb30348e 100644 --- a/commons/src/main/res/values-zh-rCN/strings.xml +++ b/commons/src/main/res/values-zh-rCN/strings.xml @@ -9,7 +9,6 @@ 取消 - 清除选择 列表显示 表格检视 地图检视 @@ -21,7 +20,6 @@ 笔记 数据输入 详情 - 详情 选择选项 搜索 事件 @@ -35,8 +33,6 @@ 无过滤器 周期 机构 - 排序 - 状态 同步 指派给我 日期 @@ -45,7 +41,6 @@ @string/enrollment_status 跟进%s 请选择周期类型 - 每日 每周 双周 开始于周三的周报 @@ -58,7 +53,6 @@ 每季度 11 月 六个月 开始于四月的每六个月 - 从 7 月开始,每 6 个月一次 开始于11月的半年 每年 开始于四月的财政年 @@ -66,13 +60,10 @@ 开始于十月的财政年 开始于11月的财政年 - - 同步 发送 更新 - 删除 错误 警告 上传 @@ -165,7 +156,6 @@ 密码丢失。请输入密码。 缺少用户名。请输入用户名。 - 服务器运行版本不是2.29,请更新服务器版本或使用测试环境继续 此服务器正在运行与 %s 不同的 DHIS2 版本。更新您的服务器版本或联系您的管理员。 出了些问题。请检查服务器 URL、用户名和密码是否正确,然后重试。 对不起!服务器端出现问题。 @@ -225,9 +215,7 @@ 查询中有无效字符。请让管理员验证服务器配置(relaxedQueryChars,...)。 服务器当前不可用。稍后再试。 您已离线。连接到互联网并重试。 - 该字段不可用于移动采集 添加图片 - 添加签名 添加文件 Ok! 您的设备位置已断开。请启用它以捕获坐标 @@ -235,9 +223,7 @@ 现在不要 审查 完成 - 忽略修改 - 数据 网络连接不可用 需要互联网连接才能使用地图。 @@ -267,7 +253,6 @@ 文件下载成功 - 注册人数 @@ -276,7 +261,6 @@ %s 数据 %s 坐标 %s 日期 - %s 地位 在这个受保护的程序中,您必须提供检查%s 的理由。所有活动都将记录在案。 检查原因%s 该数据不可编辑,因为%s 已完成或取消 @@ -284,14 +268,12 @@ %s 已取消 新%s 加入: - 计划%s 更多注册人数 刷新此记录 查看时间表 按阶段分组 - 活动 @@ -309,12 +291,9 @@ %s 的日期不能是未来。请更改。 插入新%s 星期%d %s到%s - 按阶段划分的组别%s - 显示%s 时间轴 没有注册%s ,点击 \"+\"可添加新的 。 %s 。 计划中的%s 不会改期 - 重新打开 重试同步 同步 @@ -328,4 +307,7 @@ 每月六次 财政年度 - + 全清除 + + %s 地位 + diff --git a/commons/src/main/res/values-zh/strings.xml b/commons/src/main/res/values-zh/strings.xml index f0a63a42cb1..4ddc1ffd3e2 100644 --- a/commons/src/main/res/values-zh/strings.xml +++ b/commons/src/main/res/values-zh/strings.xml @@ -9,7 +9,6 @@ 取消 是的 - 清除选择 列表显示 表格显示 地图显示 @@ -21,7 +20,6 @@ 笔记 数据输入 详情 - 详情 选择选项 搜索 事件 @@ -35,8 +33,6 @@ 无过滤器 期间 机构 - 排序 - 状态 同步 指派给我 日期 @@ -45,7 +41,6 @@ @string/enrollment_status 跟进%s 请选择周期类型 - 每日 每周 两周 开始于周三的周报 @@ -58,7 +53,6 @@ 每季度 11 月 六个月 开始于四月的每六个月 - 从 7 月开始,每 6 个月一次 开始于11月的半年 每年 开始于四月的财政年 @@ -66,13 +60,10 @@ 开始于十月的财政年 开始于11月的财政年 - - 同步 发送 更新 - 删除 错误 警告 上传 @@ -165,7 +156,6 @@ 密码丢失。请输入密码。 缺少用户名。请输入用户名。 - 服务器运行版本不是2.29,请更新服务器版本或使用测试环境继续 此服务器正在运行与 %s 不同的 DHIS2 版本。更新您的服务器版本或联系您的管理员。 出了些问题。请检查服务器 URL、用户名和密码是否正确,然后重试。 对不起!服务器端出现问题。 @@ -225,9 +215,7 @@ 查询中有无效字符。请让管理员验证服务器配置(relaxedQueryChars,...)。 服务器当前不可用。稍后再试。 您已离线。连接到互联网并重试。 - 该字段不可用于移动采集 添加图片 - 添加签名 添加文件 Ok! 您的设备定位已断开或关闭。请启用它以便捕获地理坐标 @@ -235,9 +223,7 @@ 现在不要 审查 完成 - 忽略修改 - 数据 网络连接不可用 需要互联网连接才能使用地图。 @@ -267,7 +253,6 @@ 文件下载成功 - 注册人数 @@ -276,7 +261,6 @@ %s 数据 %s 坐标 %s 日期 - %s 地位 在这个受保护的程序中,您必须提供检查%s 的理由。所有活动都将记录在案。 检查原因%s 该数据不可编辑,因为%s 已完成或取消 @@ -284,14 +268,12 @@ %s 已取消 新%s 加入: - 计划%s 更多注册人数 刷新此记录 查看时间表 按阶段分组 - 活动 @@ -309,12 +291,9 @@ %s 的日期不能是未来。请更改。 插入新%s 星期%d %s到%s - 按阶段划分的组别%s - 显示%s 时间轴 没有注册%s ,点击 \"+\"可添加新的 。 %s 。 计划中的%s 不会改期 - 重新打开 重试同步 同步 @@ -332,4 +311,8 @@ 数据库导入失败 无效文件 未进入 TOTP 2FA 注册模式 + + 全清除 + + %s 地位 diff --git a/commons/src/main/res/values/attrs.xml b/commons/src/main/res/values/attrs.xml index 33704780956..2f5e07a270d 100644 --- a/commons/src/main/res/values/attrs.xml +++ b/commons/src/main/res/values/attrs.xml @@ -15,7 +15,7 @@ - + diff --git a/commons/src/main/res/values/strings.xml b/commons/src/main/res/values/strings.xml index 0ff4f103756..9d34e6f518f 100644 --- a/commons/src/main/res/values/strings.xml +++ b/commons/src/main/res/values/strings.xml @@ -10,7 +10,6 @@ Cancel Yes No - clear selection List view Table view Map view @@ -22,7 +21,6 @@ Notes Data entry Details - Details Select option Search events @@ -36,8 +34,6 @@ No filters applied Period Org. Unit - Sorting - Status Sync Assigned to me Date @@ -46,11 +42,11 @@ @string/enrollment_status Followed up %s Select period type - Daily Weekly Bi-weekly Weekly starting Wednesday Weekly starting Thursday + Weekly starting Friday Weekly starting Saturday Weekly starting Sunday Monthly @@ -59,21 +55,20 @@ Quarterly Nov Six-monthly Six-monthly starting April - Six-monthly starting July Six-monthly starting November Yearly + Financial year starting February Financial year starting April Financial year starting July + Financial year starting August + Financial year starting September Financial year starting October Financial year starting November - - Sync To send To update - To delete Error Warning Uploading @@ -167,7 +162,6 @@ Password is missing. Please enter password. Username is missing. Please enter username. - This server is running a dhis version different than 2.29. Update your server version or use the testing environment to proceed. This server is running a DHIS2 version different than %s. Update your server version or contact your admin. Something went wrong. Please check that server URL, username and password are correct and try again. Sorry! Something failed on the server side. @@ -227,9 +221,7 @@ There are invalid characters in the query. Please ask the administrator to verify the server configuration (relaxedQueryChars,...). The server is currently unavailable. Try again later. You are offline. Connect to the internet and try again. - This field is not available for mobile capture Add Image - Add signature Add file Ok Your device location is disconnected. Please enable it to capture coordinates @@ -237,9 +229,7 @@ Not now Review Complete - Value Discard changes - Data Network connection unavailable An internet connection is required in order to use maps. @@ -275,7 +265,6 @@ File downloaded successfully - enrollment @@ -285,7 +274,6 @@ %s data %s coordinates %s date - %s status You must provide a reason to check for %s in this protected program. All activity will be logged. Reason to check for %s This data is not editable because the %s is completed or cancelled @@ -293,14 +281,12 @@ %s cancelled New %s Enrolled in: - Program %s More enrollments Remove from Refresh this record View timeline Group by stage - event @@ -321,12 +307,9 @@ %s - %s Week %d %s to %s %d %s - %d %s - Group %s by stage - Show %s timeline There are no %s registered, click \"+\" to add a new one. There are no %s registered. There are planned %s that won\'t be re-scheduled - re-open Retry sync Synchronizing... @@ -344,4 +327,8 @@ Database import failed Invalid file Not in TOTP 2FA enrollment mode + + Clear all + + %s status diff --git a/commons/src/main/res/values/styles.xml b/commons/src/main/res/values/styles.xml index 459deafba55..14aea64adc7 100644 --- a/commons/src/main/res/values/styles.xml +++ b/commons/src/main/res/values/styles.xml @@ -69,6 +69,14 @@ ?colorPrimary + + + + @@ -154,6 +162,8 @@ @color/colorAccent @style/DatePickerThemetrue @color/white + @color/black_de0 + true \ No newline at end of file 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 63e32d5dc14..3ffbccd74d9 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 @@ -42,6 +42,7 @@ class SimprintsD2RepositoryTest { .trackedEntityInstance("tei-uid") .program("program-uid") .organisationUnit("org-unit-uid") + .attributeOptionCombo("attribute-option-combo-uid") .build() val result = repository.getEnrollmentContext("enrollment-uid") @@ -85,6 +86,7 @@ class SimprintsD2RepositoryTest { .uid("enrollment-uid") .program("program-uid") .organisationUnit("org-unit-uid") + .attributeOptionCombo("attribute-option-combo-uid") .build() val result = repository.getEnrollmentContext("enrollment-uid") diff --git a/commonskmm/build.gradle.kts b/commonskmm/build.gradle.kts index 110b39f9280..be788975ab8 100644 --- a/commonskmm/build.gradle.kts +++ b/commonskmm/build.gradle.kts @@ -1,12 +1,9 @@ -@file:OptIn(ExperimentalKotlinGradlePluginApi::class) - -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - kotlin("multiplatform") + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) - id("com.android.library") + alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.atomicfu) alias(libs.plugins.kotlin.serialization) @@ -15,30 +12,30 @@ plugins { kotlin { compilerOptions { freeCompilerArgs.add("-Xcontext-parameters") + freeCompilerArgs.add("-Xexpect-actual-classes") } - androidTarget { - compilations.all { - compileTaskProvider.configure { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - } + + androidLibrary { + namespace = "org.dhis2.mobile.commons" + compileSdk = libs.versions.sdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + androidResources { enable = true } + withHostTestBuilder {}.configure {} + withDeviceTestBuilder {}.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } - jvm("desktop") - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - } + jvm("desktop") sourceSets { commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.ui) - implementation(compose.material3) - implementation(compose.components.resources) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.compose.components.resources) implementation(libs.compose.material3.window) implementation(libs.lifecycle.runtime.compose) @@ -77,13 +74,17 @@ kotlin { implementation(libs.androidx.browser) // Sentry api(libs.analytics.sentry) + implementation(libs.androidx.work) + compileOnly(libs.androidx.compose.preview) + compileOnly(libs.androidx.compose.uitooling) } - androidUnitTest.dependencies { - + getByName("androidHostTest") { + dependencies { + } } - androidInstrumentedTest.dependencies { + getByName("androidDeviceTest") { dependencies { implementation(libs.test.junit.ext) implementation(libs.test.espresso) @@ -93,7 +94,7 @@ kotlin { val desktopMain by getting { dependencies { - implementation(compose.desktop.common) + implementation(libs.compose.desktop.common) } } } @@ -101,36 +102,11 @@ kotlin { } compose.resources { - publicResClass = false + publicResClass = true packageOfResClass = "org.dhis2.mobile.commons.resources" generateResClass = always } -android { - namespace = "org.dhis2.mobile.commons" - compileSdk = libs.versions.sdk.get().toInt() - defaultConfig { - minSdk = libs.versions.minSdk.get().toInt() - val bitriseSentryDSN = System.getenv("SENTRY_DSN") ?: "" - buildConfigField("String", "SENTRY_DSN", "\"${bitriseSentryDSN}\"") - } - buildFeatures { - buildConfig = true - } - compileOptions { - isCoreLibraryDesugaringEnabled = true - - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - dependencies { - coreLibraryDesugaring(libs.desugar) - } -} - dependencies { - debugImplementation(libs.androidx.compose.preview) - debugImplementation(libs.androidx.compose.uitooling) + coreLibraryDesugaring(libs.desugar) } - diff --git a/commonskmm/src/androidUnitTest/kotlin/org/dhis2/commons/validation/validators/FieldMaskValidatorTest.kt b/commonskmm/src/androidHostTest/kotlin/org/dhis2/commons/validation/validators/FieldMaskValidatorTest.kt similarity index 100% rename from commonskmm/src/androidUnitTest/kotlin/org/dhis2/commons/validation/validators/FieldMaskValidatorTest.kt rename to commonskmm/src/androidHostTest/kotlin/org/dhis2/commons/validation/validators/FieldMaskValidatorTest.kt diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/auth/OpenIdControllerImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/auth/OpenIdControllerImpl.kt deleted file mode 100644 index 7fb9d63220c..00000000000 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/auth/OpenIdControllerImpl.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.dhis2.mobile.commons.auth - -import android.app.Activity.RESULT_OK -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch -import org.dhis2.mobile.commons.resources.Res -import org.dhis2.mobile.commons.resources.openid_login_cancelled -import org.hisp.dhis.android.core.user.openid.IntentWithRequestCode -import org.jetbrains.compose.resources.getString - -class OpenIdControllerImpl( - context: FragmentActivity, -) : OpenIdController { - private var openIDRequestCode = -1 - private lateinit var onResultCallback: (suspend (Result) -> Unit) - - private val openIdLauncher = - context.activityResultRegistry.register( - key = "OPEN_ID_LAUNCHER", - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (result.resultCode == RESULT_OK) { - result.data?.let { - val intentResult = - IntentWithRequestCode( - intent = it, - requestCode = openIDRequestCode, - ) - context.lifecycleScope.launch { - onResultCallback(Result.success(intentResult)) - } - } - } else { - context.lifecycleScope.launch { - onResultCallback( - Result.failure(Exception(getString(Res.string.openid_login_cancelled))), - ) - } - } - } - - override fun handleIntent( - intent: Any, - onResult: suspend (Result) -> Unit, - ) { - if (intent !is IntentWithRequestCode) return - onResultCallback = onResult - openIDRequestCode = intent.requestCode - openIdLauncher.launch(intent.intent) - } -} 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 daed91df555..91e426a87b7 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 @@ -108,8 +108,9 @@ class CustomIntentRepositoryImpl( if (customIntent.packageName() != simprintsIdentifyAction) { return this } - val orgUnitName = currentUserOrgUnitName() - ?: return this + val orgUnitName = + currentUserOrgUnitName() + ?: return this return this + (simprintsModuleIdKey to orgUnitName) } diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/di/CommonsModule.android.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/di/CommonsModule.android.kt index 5f84b93b638..fecd0e199fa 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/di/CommonsModule.android.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/di/CommonsModule.android.kt @@ -14,6 +14,8 @@ import org.dhis2.mobile.commons.files.FileHandler import org.dhis2.mobile.commons.files.FileHandlerImpl import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.commons.network.NetworkStatusProviderImpl +import org.dhis2.mobile.commons.notifications.NotificationManager +import org.dhis2.mobile.commons.notifications.NotificationManagerImpl import org.dhis2.mobile.commons.providers.PreferenceProvider import org.dhis2.mobile.commons.providers.PreferenceProviderImpl import org.dhis2.mobile.commons.reporting.CrashReportController @@ -39,7 +41,7 @@ actual val commonsModule: Module FileHandlerImpl() } single { - CrashReportControllerImpl(get()) + CrashReportControllerImpl(get(), getProperty("sentryDsn", "")) } single { @@ -50,6 +52,10 @@ actual val commonsModule: Module PreferenceProviderImpl(get()) } + single { + NotificationManagerImpl(get()) + } + factory { D2ErrorMessageProviderImpl() } diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/error/DomainErrorMapper.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/error/DomainErrorMapper.kt index 2a13641ed6c..3fbe1042a92 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/error/DomainErrorMapper.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/error/DomainErrorMapper.kt @@ -80,6 +80,7 @@ class DomainErrorMapper( D2ErrorCode.NO_DHIS2_SERVER, D2ErrorCode.SERVER_URL_NULL, D2ErrorCode.SERVER_URL_MALFORMED, + D2ErrorCode.INVALID_CONFIGURATION, D2ErrorCode.INVALID_DHIS_VERSION, -> DomainError.ConfigurationError(errorMessage) diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/network/NetworkStatusProviderImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/network/NetworkStatusProviderImpl.kt index ba9ed9bb433..e6d7b133e61 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/network/NetworkStatusProviderImpl.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/network/NetworkStatusProviderImpl.kt @@ -5,7 +5,6 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest -import android.os.Build import androidx.annotation.RequiresPermission import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -24,11 +23,8 @@ class NetworkStatusProviderImpl( NetworkRequest .Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .also { builder -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - builder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - } - }.build() + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() private val availableNetworks = mutableSetOf() @@ -46,7 +42,12 @@ class NetworkStatusProviderImpl( ) { super.onCapabilitiesChanged(network, networkCapabilities) val networkState = networkCapabilities.asNetworkState() - trySend(networkState) + if (networkState) { + availableNetworks.add(network) + } else { + availableNetworks.remove(network) + } + trySend(availableNetworks.isNotEmpty()) } override fun onUnavailable() { @@ -59,13 +60,16 @@ class NetworkStatusProviderImpl( val networkCapabilities = manager.getNetworkCapabilities(network) val networkState = networkCapabilities?.asNetworkState() ?: false - trySend(networkState) + if (networkState) { + availableNetworks.add(network) + } + trySend(availableNetworks.isNotEmpty()) } override fun onLost(network: Network) { super.onLost(network) availableNetworks.remove(network) - trySend(false) + trySend(availableNetworks.isNotEmpty()) } } @@ -77,14 +81,11 @@ class NetworkStatusProviderImpl( @RequiresPermission("android.permission.ACCESS_NETWORK_STATE") private fun ConnectivityManager.getCurrentNetworkState(): Boolean { - @Suppress("DEPRECATION") - return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - activeNetworkInfo?.isConnected == true - } else { - val networkCapabilities = getNetworkCapabilities(activeNetwork) - networkCapabilities?.asNetworkState() ?: false - } + val networkCapabilities = getNetworkCapabilities(activeNetwork) + return networkCapabilities?.asNetworkState() ?: false } - private fun NetworkCapabilities.asNetworkState(): Boolean = hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + private fun NetworkCapabilities.asNetworkState(): Boolean = + hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationManagerImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationManagerImpl.kt new file mode 100644 index 00000000000..483d1a4b107 --- /dev/null +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationManagerImpl.kt @@ -0,0 +1,215 @@ +package org.dhis2.mobile.commons.notifications + +import android.Manifest +import android.app.NotificationChannel +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE +import androidx.work.ForegroundInfo + +private const val SYNC_METADATA_NOTIFICATION_ID = 26061987 +private const val SYNC_METADATA_CHANNEL_ID = "sync_metadata_notification" +private const val SYNC_METADATA_CHANNEL_NAME = "sync_metadata" + +private const val SYNC_DATA_NOTIFICATION_ID = 80071986 +private const val SYNC_DATA_CHANNEL_ID = "sync_data_notification" +private const val SYNC_DATA_CHANNEL_NAME = "sync_data" + +private const val SYNC_SETTINGS_NOTIFICATION_ID = 28042023 +private const val SYNC_SETTINGS_CHANNEL_ID = "sync_settings_notification" +private const val SYNC_SETTINGS_CHANNEL_NAME = "sync_settings" + +class NotificationManagerImpl( + private val context: Context, +) : NotificationManager { + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + + override fun getDataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ) = WorkerNotificationInfo( + createForegroundInfo( + notificationId = SYNC_DATA_NOTIFICATION_ID, + channelId = SYNC_DATA_CHANNEL_ID, + channelName = SYNC_DATA_CHANNEL_NAME, + smallIcon = smallIcon, + contentTitle = contentTitle, + contentText = contentText, + progress = progress, + ), + ) + + override fun displayDataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ) { + val foregroundInfo = + createForegroundInfo( + notificationId = SYNC_DATA_NOTIFICATION_ID, + channelId = SYNC_DATA_CHANNEL_ID, + channelName = SYNC_DATA_CHANNEL_NAME, + smallIcon = smallIcon, + contentTitle = contentTitle, + contentText = contentText, + progress = progress, + ) + + notify(foregroundInfo) + } + + override fun getMetadataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ) = WorkerNotificationInfo( + createForegroundInfo( + notificationId = SYNC_METADATA_NOTIFICATION_ID, + channelId = SYNC_METADATA_CHANNEL_ID, + channelName = SYNC_METADATA_CHANNEL_NAME, + smallIcon = smallIcon, + contentTitle = contentTitle, + contentText = contentText, + progress = progress, + ), + ) + + override fun displayMetadataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ) { + val foregroundInfo = + createForegroundInfo( + notificationId = SYNC_METADATA_NOTIFICATION_ID, + channelId = SYNC_METADATA_CHANNEL_ID, + channelName = SYNC_METADATA_CHANNEL_NAME, + smallIcon = smallIcon, + contentTitle = contentTitle, + contentText = contentText, + progress = progress, + ) + + notify(foregroundInfo) + } + + override fun getSyncSettingsNotificationModel( + smallIcon: Int, + contentTitle: String, + contentText: String, + ) = WorkerNotificationInfo( + createForegroundInfo( + notificationId = SYNC_SETTINGS_NOTIFICATION_ID, + channelId = SYNC_SETTINGS_CHANNEL_ID, + channelName = SYNC_SETTINGS_CHANNEL_NAME, + smallIcon = smallIcon, + contentTitle = contentTitle, + contentText = contentText, + progress = -1, + ), + ) + + override fun displaySyncSettingsNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + ) { + val foregroundInfo = + createForegroundInfo( + notificationId = SYNC_SETTINGS_NOTIFICATION_ID, + channelId = SYNC_SETTINGS_CHANNEL_ID, + channelName = SYNC_SETTINGS_CHANNEL_NAME, + smallIcon = smallIcon, + contentTitle = contentTitle, + contentText = contentText, + progress = -1, + ) + notify(foregroundInfo) + } + + override fun cancelMetadataSyncNotification() { + notificationManager.cancel(SYNC_METADATA_NOTIFICATION_ID) + } + + override fun cancelSyncSettingsNotification() { + notificationManager.cancel(SYNC_SETTINGS_NOTIFICATION_ID) + } + + override fun cancelDataSyncNotification() { + notificationManager.cancel(SYNC_DATA_NOTIFICATION_ID) + } + + private fun createForegroundInfo( + notificationId: Int, + channelId: String, + channelName: String, + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int = 0, + ): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel(channelId, channelName) + } + val notification = + NotificationCompat + .Builder(context, channelId) + .setSmallIcon(smallIcon) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) + .setAutoCancel(false) + .setProgress(100, progress, progress == -1) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + ForegroundInfo( + notificationId, + notification, + ) + } + } + + private fun notify(foregroundInfo: ForegroundInfo) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + ) { + notificationManager.notify(foregroundInfo.notificationId, foregroundInfo.notification) + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel( + channelId: String, + channelName: String, + ) { + notificationManager.createNotificationChannel( + NotificationChannel( + channelId, + channelName, + android.app.NotificationManager.IMPORTANCE_HIGH, + ), + ) + } +} diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/notifications/WorkerNotificationInfo.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/notifications/WorkerNotificationInfo.kt new file mode 100644 index 00000000000..e3e00856e49 --- /dev/null +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/notifications/WorkerNotificationInfo.kt @@ -0,0 +1,7 @@ +package org.dhis2.mobile.commons.notifications + +import androidx.work.ForegroundInfo + +data class WorkerNotificationInfo( + val foregroundInfo: ForegroundInfo, +) : NotificationModel diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/reporting/CrashReportControllerImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/reporting/CrashReportControllerImpl.kt index ac505a6d64b..1979a81096f 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/reporting/CrashReportControllerImpl.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/reporting/CrashReportControllerImpl.kt @@ -1,13 +1,13 @@ package org.dhis2.mobile.commons.reporting import android.content.Context +import android.content.pm.ApplicationInfo import io.sentry.Breadcrumb import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryOptions.BeforeSendCallback import io.sentry.android.core.SentryAndroid import io.sentry.android.core.SentryAndroidOptions -import org.dhis2.mobile.commons.BuildConfig import org.hisp.dhis.android.core.D2Manager import timber.log.Timber @@ -15,20 +15,27 @@ const val DATA_STORE_CRASH_PERMISSION_KEY = "analytics_permission" class CrashReportControllerImpl( private val context: Context, + private val sentryDsn: String, + private val isDebug: Boolean = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0, ) : CrashReportController { override fun init() { + val resolvedDsn = resolveSentryDsn() + if (resolvedDsn.isBlank()) { + Timber.w("Sentry DSN is empty. Skipping Sentry initialization.") + return + } SentryAndroid.init(context) { options: SentryAndroidOptions? -> - options!!.setDsn(BuildConfig.SENTRY_DSN) + options!!.setDsn(resolvedDsn) options.isAnrReportInDebug = true options.beforeSend = BeforeSendCallback { event, _ -> if (SentryLevel.DEBUG == event.level) null else event } - options.environment = if (BuildConfig.DEBUG) "debug" else "production" - options.isDebug = BuildConfig.DEBUG + options.environment = if (isDebug) "debug" else "production" + options.isDebug = isDebug options.isAttachViewHierarchy = true - options.setTracesSampleRate(if (BuildConfig.DEBUG) 1.0 else 0.1) - options.setProfilesSampleRate(if (BuildConfig.DEBUG) 1.0 else 0.1) + options.setTracesSampleRate(if (isDebug) 1.0 else 0.1) + options.setProfilesSampleRate(if (isDebug) 1.0 else 0.1) } } @@ -97,4 +104,23 @@ class CrashReportControllerImpl( Timber.d("Tracking is disabled") } } + + private fun resolveSentryDsn(): String { + if (sentryDsn.isNotBlank()) { + return sentryDsn + } + + return try { + val buildConfigClass = Class.forName("${context.packageName}.BuildConfig") + (buildConfigClass.getField("SENTRY_DSN")[null] as? String).orEmpty() + } catch (_: ClassNotFoundException) { + "" + } catch (_: NoSuchFieldException) { + "" + } catch (_: IllegalAccessException) { + "" + } catch (_: SecurityException) { + "" + } + } } diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/D2ErrorMessageProviderImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/D2ErrorMessageProviderImpl.kt index ec2d3bed3eb..47d10f04c7a 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/D2ErrorMessageProviderImpl.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/D2ErrorMessageProviderImpl.kt @@ -51,8 +51,13 @@ class D2ErrorMessageProviderImpl : D2ErrorMessageProvider { D2ErrorCode.BAD_CREDENTIALS -> getString(Res.string.login_error_bad_credentials) - D2ErrorCode.UNKNOWN_HOST -> - getString(Res.string.login_error_unknown_host) + D2ErrorCode.UNKNOWN_HOST -> { + if (isNetworkAvailable) { + getString(Res.string.login_error_unknown_host) + } else { + getString(Res.string.error_no_internet_connection) + } + } D2ErrorCode.UNEXPECTED -> getString(Res.string.error_unexpected) @@ -203,6 +208,8 @@ class D2ErrorMessageProviderImpl : D2ErrorMessageProvider { D2ErrorCode.ORGUNIT_NOT_IN_SEARCH_SCOPE -> getString(Res.string.error_org_unit_scope) + D2ErrorCode.INVALID_CONFIGURATION -> + getString(Res.string.invalid_configuration) D2ErrorCode.INVALID_CHARACTERS -> getString(Res.string.error_invalid_characters) diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/Resources.android.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/DrawableResourcesProvider.android.kt similarity index 100% rename from commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/Resources.android.kt rename to commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/resources/DrawableResourcesProvider.android.kt diff --git a/commonskmm/src/commonMain/composeResources/values-ar-rEG/strings.xml b/commonskmm/src/commonMain/composeResources/values-ar-rEG/strings.xml index ccf89c85128..d7e0718e1c0 100644 --- a/commonskmm/src/commonMain/composeResources/values-ar-rEG/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ar-rEG/strings.xml @@ -7,8 +7,5 @@ إلغاء لم يتم العثور على نتائج ابحث للعثور على المزيد - - - \ No newline at end of file + إلغاء + \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ar-rIQ/strings.xml b/commonskmm/src/commonMain/composeResources/values-ar-rIQ/strings.xml index 8e50096d019..7e901c6ffbb 100644 --- a/commonskmm/src/commonMain/composeResources/values-ar-rIQ/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ar-rIQ/strings.xml @@ -1,21 +1,13 @@ خطأ - التاريخ - السنوات - الأشهر - ‏‏الأيام تاريخ الاستحقاق مكتمل - حفظ على اية حال مشاركة إغلاق تاريخ الواقعة تنزيل قبول إلغاء - - - \ No newline at end of file + إلغاء + \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ar/strings.xml b/commonskmm/src/commonMain/composeResources/values-ar/strings.xml index 76a47639a73..ac890848627 100644 --- a/commonskmm/src/commonMain/composeResources/values-ar/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ar/strings.xml @@ -22,16 +22,9 @@ لديك بعض الرسائل التحذيرية. لديك بعض الرسائل التحذيرية.\nهل تريد وضع علامة \"مكتمل\" على هذا النموذج؟ مطلوب - التاريخ - السنوات - الأشهر - ‏‏الأيام - لا يمكن تحليل %s كتاريخ مضلع خط العرض خط الطول - قيم الإحداثيات غير صالحة - تم التقاط المضلع أدخل قيمة أدخل رابط url أدخل بريداً إلكترونياً @@ -46,9 +39,6 @@ أدخل نصًا طويلاً أدخل نصًا تاريخ الاستحقاق - حقل الزامي - خط العرض: - خط الطول: لم يتم حفظه المراجعة مكتمل @@ -58,19 +48,13 @@ بعض الحقول بها أخطاء ولم يتم حفظها. \nهل تريد مراجعة النموذج؟ بعض الحقول تحتاج إلى انتباهك.\nهل تريد مراجعة النموذج؟ ليس الان - حفظ على اية حال استمر في التعديل هل تريد وضع علامة على هذا النموذج كمكتمل؟ إذا قمت بالخروج الآن ، فسيتم تجاهل جميع المعلومات الموجودة في النموذج. تحتوي بعض الحقول على أخطاء ولا يتم حفظها. \nإذا قمت بالخروج الآن سيتم تجاهل التغييرات. - بعض الحقول بها أخطاء ولم يتم حفظها. - بعض الحقول الإلزامية غير مدخله ولا يمكن حفظ النموذج. - بعض الحقول الإلزامية مفقودة ولا يمكن حفظ النموذج. \nإذا قمت بالخروج الآن سيتم تجاهل التغييرات. مشاركة إغلاق - تحذير قواعد البرنامج تم النسخ - لا توجد معلومات عن هذا الحقل التقط صورة تحقق من هذا! لا تظهر مرة أخرى @@ -94,7 +78,6 @@ تاريخ الحدوث تنزيل المسح الضوئي - رمز الاستجابة السريعة إضافة موقع الوحدة التنظيمية الإحداثيات @@ -119,12 +102,11 @@ ابحث للعثور على المزيد تم غير مدعوم - كلمة المرور مفقودة. الرجاء إدخال كلمة المرور. اسم المستخدم مفقود. الرجاء إدخال اسم المستخدم. - يقوم هذا الخادم بتشغيل إصدار dhis مختلف عن 2.29. قم بتحديث إصدار الخادم الخاص بك أو استخدم بيئة الاختبار للمتابعة. يقوم هذا الخادم بتشغيل إصدار DHIS2 مختلف عن %s. قم بتحديث إصدار الخادم الخاص بك أو اتصل بالمسؤول. هناك خطأ ما. يرجى التحقق من صحة عنوان URL الخاص بالخادم واسم المستخدم وكلمة المرور وحاول مرة أخرى. آسف! فشل شيء ما من جانب الخادم. @@ -142,7 +124,6 @@ لم يتم تعيين إصدار التطبيق. الغاء الإنشاء. الكائن موجود مسبقًا. احباط عملية الحذف، الكائن غير موجود. - لم نتمكن من الاحتفاظ بقيم لهذه السجلات. يرجى الاتصال بالمسؤول الخاص بك. الرجاء تسجيل الدخول لتصدير قاعدة البيانات. تصدير قاعدة بيانات مشفرة غير مدعوم. أنت تستورد قاعدة بيانات موجودة مسبقًا. @@ -167,7 +148,6 @@ استغرق الخادم وقتًا طويلاً للاستجابة. لقد ألغينا الطلب. لا يمكن تحديث العلاقة. هناك فترات كثيرة جدا. - لم نتمكن من العثور على URL. الرجاء التحقق منه. تم تعطيل حساب المستخدم الخاص بك. إذا كان هذا خطأ ، اتصل بالمسؤول. حساب المستخدم الخاص بك مغلق. إذا كان هذا خطأ ، اتصل بالمسؤول. تعذر تعيين القيمة. @@ -178,8 +158,11 @@ البحث على الإنترنت متاح فقط للوحدات التنظيمية المتاحة للمستخدم للبحث. الخادم غير متوفر حالياً. حاول مرة أخرى في وقت لاحق. أنت غير متصل بالإنترنت. اتصل بالإنترنت وحاول مرة أخرى. - + أو + إلغاء + موافق \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-bn/strings.xml b/commonskmm/src/commonMain/composeResources/values-bn/strings.xml index 926dfcf4f64..6edef692700 100644 --- a/commonskmm/src/commonMain/composeResources/values-bn/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-bn/strings.xml @@ -2,10 +2,5 @@ অবশ্যই সংখ্যা হতে হবে ভুল - তারিখ নির্ধারিত তারিখ - - \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ckb/strings.xml b/commonskmm/src/commonMain/composeResources/values-ckb/strings.xml index 3e62754b89b..3d623906113 100644 --- a/commonskmm/src/commonMain/composeResources/values-ckb/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ckb/strings.xml @@ -2,12 +2,7 @@ هەڵە ثيويست - بەروار - ساڵەکان - مانگەکان - رۆژەکان تەواوبکە - بەهەرحاڵ تۆماری بکە داخستن هیچ نرخێکی دیکەی تەرخانکراو نەما. پەیوەندی بە بەڕێوەبەری سیستەمەکەتەوە بکە بەڵێ @@ -15,8 +10,7 @@ پەسندکردن لابردن تةواو كرا - + لابردن + تةواو \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-cs/strings.xml b/commonskmm/src/commonMain/composeResources/values-cs/strings.xml index 4d76373c0ea..6dad687abd7 100644 --- a/commonskmm/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-cs/strings.xml @@ -25,16 +25,9 @@ Máte několik varovných hlášení. Máte několik varovných hlášení.\nChcete tento formulář označit jako dokončený\? Vyžadováno - datum - Let - Měsíce - Dnů - %s nelze analyzovat jako datum Polygon Zeměpisná šířka Zeměpisná délka - Hodnoty souřadnic nejsou platné - Polygon zaznamenán Zadejte hodnotu Zadejte url Zadejte e-mail @@ -49,9 +42,6 @@ Zadejte dlouhý text Zadejte text Datum vypršení - Povinné pole - Šířka: - Délka: Neuloženo Posouzení Dokončit @@ -61,21 +51,14 @@ Některá pole obsahují chyby a neukládají se. \nChcete formulář zkontrolovat\? Některé oblasti vyžadují vaši pozornost.\nChcete formulář zkontrolovat\? Teď ne - Přesto uložit Pokračovat v úpravách Chcete tento formulář označit jako vyplněný? Pokud nyní odejdete, všechny informace ve formuláři budou zahozeny. Některá pole obsahují chyby a neukládají se. \nPokud je nyní ukončíte, změny budou zrušeny. - Některá pole obsahují chyby a neuloží se. - Některá povinná pole chybí a formulář nelze uložit. - Chybí některá povinná pole a formulář nelze uložit. \nPokud nyní formulář ukončíte, změny budou zrušeny. Sdílet Povolení k použití kamery je zamítnuto.\nChcete-li tuto funkci používat, musíte ji povolit. Zavřít - Upozornění na pravidla programu - Došlo k problému s konfigurací, který způsobuje smyčku v pravidlech. Kontaktujte svého správce. Zkopírováno - Žádné informace pro toto pole Vyfotit Z galerie Zkontrolovat to! @@ -101,13 +84,10 @@ Souřadnice pro Neexistují žádné další rezervované hodnoty. Obraťte se na správce systému Datum incidentu - Pro zachycení souřadnic je potřeba oprávnění k poloze. Povolte je prosím v nastavení aplikace Otevřít s Nenalezena žádná možnost Stáhnout Skenování - QR kód - Čárový kód Přidat umístění Tento formulář nemá nakonfigurovaná žádná pole Podrobnosti %s @@ -138,12 +118,11 @@ Hledat pro nalezení dalších Hotovo Není podporováno - Chybí heslo. Prosím vložte heslo. Chybí uživatelské jméno. Zadejte uživatelské jméno. - Tento server používá jinou verzi DHIS než 2.29. Aktualizujte verzi serveru nebo pokračujte testovacím prostředím. Tento server používá verzi DHIS2 odlišnou od %s. Aktualizujte verzi serveru nebo kontaktujte svého administrátora. Něco se pokazilo. Zkontrolujte prosím správnost adresy URL serveru, uživatelského jména a hesla a zkuste to znovu. Promiňte! Něco selhalo na straně serveru. @@ -164,7 +143,6 @@ Vytváření přerušeno. Objekt již existuje. Mazání přerušeno. Objekt neexistuje. Nelze vytvořit instanci úložiště klíčů. - Pro tyto záznamy jsme nemohli vyhradit hodnoty. Kontaktujte prosím svého administrátora. Pro export databáze se prosím přihlaste. Export šifrované databáze není podporován. Importujete již existující databázi. @@ -191,7 +169,6 @@ Serveru trvalo příliš dlouho, než odpověděl. Žádost jsme zrušili. Vztah nelze aktualizovat. Období je příliš mnoho. - Adresu URL jsme nenašli. Prosím ověřte to. Váš uživatelský účet byl deaktivován. Pokud se jedná o chybu, kontaktujte svého správce. Váš uživatelský účet je uzamčen. Pokud se jedná o chybu, kontaktujte svého správce. Hodnotu nelze nastavit. @@ -207,8 +184,13 @@ Neplatný soubor Není v režimu registrace TOTP 2FA Ověřování zrušeno - + OR + VĚK + DATUM NAROZENÍ + Zrušit + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-da/strings.xml b/commonskmm/src/commonMain/composeResources/values-da/strings.xml index 1bf475e2ded..4204a6da923 100644 --- a/commonskmm/src/commonMain/composeResources/values-da/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-da/strings.xml @@ -2,14 +2,12 @@ Value must be an integer Error - Date Due date Close Incident date Cancel No results found - + Cancel + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-en-rUS/strings.xml b/commonskmm/src/commonMain/composeResources/values-en-rUS/strings.xml index 451502a78b9..30a64044856 100644 --- a/commonskmm/src/commonMain/composeResources/values-en-rUS/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-en-rUS/strings.xml @@ -21,16 +21,9 @@ This field is mandatory You have some warning messages. Required - Date - Years - Months - Days - %s can\'t be parsed as a date Polygon Latitude Longitude - Coordinates values are not valid - Polygon captured Enter value Enter url Enter email @@ -45,27 +38,18 @@ Enter long text Enter text Due date - Mandatory field - Lat: - Long: Not saved Review Complete Discard changes Saved! Not now - Save anyway Keep editing - Do you want to mark this form as complete\? + Do you want to mark this form as complete? If you exit now all the information in the form will be discarded. - Some fields have errors and they are not saved. - Some mandatory fields are missing and the form cannot be saved. Share Close - Program rules warning - There is a configuration issue causing a loop in the rules. Contact you administrator. Copied - No info for this field Take Photo From gallery Check this! @@ -77,13 +61,10 @@ Coordinates for the There are no more reserved values. Contact your system administrator Incident date - The location permission is needed for capturing coordinates. Please, enable them in the application settings Open with No option found Download Scan - QR code - Bar code Add location This form has no fields configured %s details @@ -110,12 +91,11 @@ Search to find more Done Not supported - Password is missing. Please enter password. Username is missing. Please enter username. - This server is running a dhis version different than 2.29. Update your server version or use the testing environment to proceed. This server is running a DHIS2 version different than %s. Update your server version or contact your admin. Something went wrong. Please check that server URL, username and password are correct and try again. Sorry! Something failed on the server side. @@ -136,7 +116,6 @@ Creation aborted. The object already exists. Deletion aborted. The object does not exist. Unable to instantiate keystore. - We couldn´t reserve values for these record. Please contact your admin. Please login to export the database. Exporting an encrypted database is not supported. You are importing an already existing database. @@ -163,7 +142,6 @@ The server took too much time to respond. We canceled the request. The relationship can\'t be updated. There are too many periods. - We couldn´t find the URL. Please verify it. Your user account have been disabled. If this is an error, contact your administrator. Your user account is locked. If this is an error, contact your administrator. The value could not be set. @@ -178,8 +156,5 @@ Database import failed Invalid file Not in TOTP 2FA enrollment mode - - - \ No newline at end of file + Cancel + \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-es-rES/strings.xml b/commonskmm/src/commonMain/composeResources/values-es-rES/strings.xml index 8e63a0b0b46..63291f3482c 100644 --- a/commonskmm/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-es-rES/strings.xml @@ -22,16 +22,9 @@ La regla de programa %s está intentando asignar el valor %s que no pertenece al conjunto de opciones %s El valor del campo %s no pertenece al conjunto de opciones en %s El campo es obligatorio - Fecha - Años - Meses - Días - %s no es una fecha válida Polígono Latitud Longitud - El valor de las coordenadas no es válido - Polígono guardado Introduzca valor Introduzca url Introduzca email @@ -46,27 +39,18 @@ Introducir texto largo Introducir texto Fecha de vencimiento - Campo obligatorio - Lat: - Long: No guardado Revisar Completar Descartar los cambios ¡Guardado! Ahora no - Guardar de todos modos Seguir editando ¿Desea marcar este formulario como completo? Si sale toda la información del formulario será descartada. - Algunos campos tienen errores y no serán guardados. - Algunos campos obligatorios faltan llenarse, el formulario no puede ser guardado Compartir Cerrar - Advertencia de reglas del programa - Hay un problema de configuración que provoca un bucle en las reglas. Póngase en contacto con su administrador. Copiado - No hay información para este campo Tomar foto ¡Importante! No volver a mostrar @@ -90,7 +74,6 @@ Coordenadas para No quedan valores reservados. Contacte con el administrador del sistema. Fecha del incidente - Se requiere el permiso de localización para obtener coordenadas. POR FAVOR, habilítelo desde los ajustes del dispositivo. Unidad Organizativa Cat Combo @@ -103,12 +86,11 @@ Añadir imagen No se han encontrado resultados Terminado - La contraseña está vacía. Ingrese la contraseña. El usuario está vacío. Ingrese el usuario. - Este servidor trabaja sobre una versión de dhis distinta a 2.29. Actualice el servidor o utilice el entorno de pruebas para continuar. Este servidor tiene una version de DHIS2 diferente de %s. Actualice su servidor o contacte a su administrador. Algo ha ido mal. Por favor, compruebe que tanto la url del servidor como el usuario y contraseña son correctos y pruebe otra vez. Disculpe, hubo un error del lado del servidor. @@ -129,7 +111,6 @@ No se puede crear un objeto que ya existe. No se puede eliminar un objeto que no existe. No se puede inicializar el almacén de llaves. - No se pudo reservar los valores. Por favor contacte a su administrador Inicie sesión para exportar la base de datos. Exportar una base de datos encriptada no está soportado. Está intentando importar una base de datos que ya existe. @@ -156,7 +137,6 @@ El servidor ha tardado mucho en responder. Se ha cancelado la petición. La relación no puede actualizarse. Hay demasiados periodos. - No pudimos encontrar la URL. Por favor revísela. Su cuenta de usuario ha sido desactivada. Si esto es un error, contacte con su administrador. Su cuenta de usuario ha sido bloqueada. Si es un error, contacte con su administrador. No se ha podido cambiar el valor. @@ -166,8 +146,7 @@ No hay suficientes atributos para realizar la búsqueda. Por favor redefina la búsqueda añadiendo más atributos. La búsqueda online solo está disponible para las unidades organizativas de búsqueda. El query contiene caracteres invalidos. Por favor contacte a su administrador para verificar la configuración del servidor (relaxedQueryChars,...). - + Cancelar + Correcto \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-es/strings.xml b/commonskmm/src/commonMain/composeResources/values-es/strings.xml index 3b04f677465..1d4def10b1d 100644 --- a/commonskmm/src/commonMain/composeResources/values-es/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-es/strings.xml @@ -25,16 +25,9 @@ Tienes algunos mensajes de aviso. Tiene algunos mensajes de advertencia.\n¿Deseas marcar este formulario como completo\? Necesario - Fecha - Años - Meses - Días - %s no es una fecha válida Polígono Latitud Longitud - El valor de las coordenadas no es válido - Polígono capturado Introduzca valor Introduzca url Introduzca email @@ -49,9 +42,6 @@ Introducir texto largo Introducir texto Fecha de vencimiento - Campo obligatorio - Lat: - Long: No guardado Revisar Completar @@ -61,21 +51,14 @@ Algunos campos tienen errores y no se han guardado.\n¿Deseas revisar el formulario\? Algunos campos necesitan tu atención.\n¿Deseas revisar el formulario\? Ahora no - Guardar de todos modos Seguir editando ¿Desea marcar este formulario como completo? Si sale toda la información del formulario será descartada. Algunos campos presentan errores y no se han guardado.\nSi sales ahora, los cambios se descartarán. - Algunos campos tienen errores y no serán guardados. - Algunos campos obligatorios faltan llenarse, el formulario no puede ser guardado - Faltan algunos campos obligatorios y el formulario no puede guardarse.\nSi sales ahora, los cambios se descartarán. Compartir El permiso de cámara está denegado.\nDebes activarlo para usar esta función. Cerrar - Advertencia de reglas del programa - Hay un problema de configuración que provoca un bucle en las reglas. Póngase en contacto con su administrador. Copiado - No hay información para este campo Tomar foto Desde la galería ¡Importante! @@ -100,13 +83,10 @@ Coordenadas para No quedan valores reservados. Contacte con el administrador del sistema. Fecha del incidente - Se requiere el permiso de localización para obtener coordenadas. Por favor, habilítelo desde los ajustes del dispositivo. Abrir con No se ha encontrado opción Descargar Escanear - Código QR - Código de barras Agregar ubicación Este formulario no tiene campos configurados %s detalles @@ -137,12 +117,11 @@ Busca para ver más Hecho No soportado - La contraseña está vacía. Ingrese la contraseña. El usuario está vacío. Ingrese el usuario. - Este servidor trabaja sobre una versión de dhis distinta a 2.29. Actualice el servidor o utilice el entorno de pruebas para continuar. Este servidor tiene una version de DHIS2 diferente de %s. Actualice su servidor o contacte a su administrador. Algo ha ido mal. Por favor, compruebe que tanto la url del servidor como el usuario y contraseña son correctos y pruebe otra vez. Disculpe, hubo un error del lado del servidor. @@ -163,7 +142,6 @@ No se puede crear un objeto que ya existe. No se puede eliminar un objeto que no existe. No se puede inicializar el almacén de llaves. - No se pudo reservar los valores. Por favor contacte a su administrador Inicie sesión para exportar la base de datos. Exportar una base de datos encriptada no está soportado. Está intentando importar una base de datos que ya existe. @@ -190,7 +168,6 @@ El servidor ha tardado mucho en responder. Se ha cancelado la petición. La relación no puede actualizarse. Hay demasiados periodos. - No pudimos encontrar la URL. Por favor revísela. Su cuenta de usuario ha sido desactivada. Si esto es un error, contacte con su administrador. Su cuenta de usuario ha sido bloqueada. Si es un error, contacte con su administrador. No se ha podido cambiar el valor. @@ -204,8 +181,9 @@ Estas desconectado. Conéctese a Internet e inténtelo nuevamente. Importación de la base de datos fallida Archivo no válido - + EDAD + FECHA DE NACIMIENTO + Cancelar + Aceptar \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-fr/strings.xml b/commonskmm/src/commonMain/composeResources/values-fr/strings.xml index fb712fee77c..42fddb45f7d 100644 --- a/commonskmm/src/commonMain/composeResources/values-fr/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-fr/strings.xml @@ -24,16 +24,9 @@ Ce champ est obligatoire Vous avez des messages d\'avertissement. Requis - Date - Années - Mois - Jours - %s ne peut pas être analysé comme une date Polygone Latitude Longitude - Les valeurs des coordonnées ne sont pas valides - Le polygône a été défini Entrez une valeur Entrez url Entrez e-mail @@ -48,25 +41,18 @@ Saisir texte long Saisir du texte Date d\'échéance - Champ obligatoire Non enregistré! Vérifier Terminer Annuler les modifications Enregistré! Pas maintenant - Enregistrer quand même Continuer l’édition Voulez-vous marquer ce formulaire comme complet? Si vous quittez maintenant, toutes les informations du formulaire seront supprimées. - Certains champs contiennent des erreurs et ils ne sont pas enregistrés. - Certains champs obligatoires manquent et le formulaire ne peut pas être enregistré. Partager Fermer - Avertissement relatif aux règles du programme - Il y a un problème de configuration provoquant une boucle dans les règles. Contactez votre administrateur. Copié - Pas d\'information pour ce champ Prendre une photo Attention. Vérifiez ceci! Ne pas montrer de nouveau @@ -90,13 +76,10 @@ Coordonnées pour Il n\'y a plus de valeurs réservées. Veuillez contacter votre administrateur système. Date de l´incident - L\'autorisation de localisation est nécessaire pour capturer les coordonnées. Veuillez les activer dans les paramètres de l\'application Ouvrir avec Aucune option trouvée Télécharger Scanner - Code QR - Code-barre Ajouter un lieu Ce formulaire n\'a pas de champs configurés %s détails @@ -121,12 +104,11 @@ Aucun résultat trouvé Terminé Non supporté - Le mot de passe est manquant. Veuillez entrer le mot de passe. Le nom d\'utilisateur est manquant. Veuillez entrer le nom d\'utilisateur. - Ce serveur a une version différente de 2.29. Mettre à jour la version ou utiliser un environnement d\'essai pour continuer L\'URL fournie n\'est pas un serveur DHIS2. Nom d\'utilisateur ou le mot de passe sont incorrects Serveur inconnu @@ -136,7 +118,6 @@ Le serveur est un peu occupé en ce moment. Veuillez essayer un peu plus tard. L\'utilisateur est déjà connecté La requête du serveur n\'est pas valide. - Nous ne pouvions pas réserver de valeurs pour ces enregistrements. Veuillez contacter votre administrateur. Veuillez vous déconnecter pour importer une nouvelle base de données. Vous êtes peut-être à court de valeurs disponibles. Il n\'y a pas d\'utilisateur authentifié. @@ -153,8 +134,13 @@ Il n\'y a pas assez d\'attributs pour effectuer la recherche. Veuillez affiner votre recherche avec plus d\'attributs. La recherche en ligne n\'est disponible que pour vos unités organisationnelles de recherche d\'utilisateurs. Vous n\'êtes pas connecté. Veuillez-vous connecter et réssayer. - + OR + ÂGE + DATE DE NAISSANCE + Annuler + Ok \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonskmm/src/commonMain/composeResources/values-hi-rIN/strings.xml index 1f52de81592..629e42f9920 100644 --- a/commonskmm/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -10,19 +10,14 @@ केवल 0 से 1 के मान ही अनुमति हैं यह मान्य URL नहीं है यह फ़ील्ड अपडेट नहीं हो सका। कृपया फिर से प्रयास करें - त्रुटि + एरर्स मान अद्वितीय होना चाहिए। समान मान मिला है। इसे सहेजा नहीं गया। कृपया जाँच करें समय चुनें दिन चुनें यह फ़ील्ड आवश्यक है आपके पास कुछ चेतावनी संदेश हैं - आपके पास कुछ चेतावनी संदेश हैं\nक्या आप इस फ़ॉर्म को कम्प्लीट मार्क करना चाहते हैं\? + आपके पास कुछ चेतावनी संदेश हैं\nक्या आप इस फ़ॉर्म को कम्प्लीट मार्क करना चाहते हैं? आवश्यकता है - तारीख - वर्ष - महीने - दिन - %s डेट के रूप में पढ़ा नहीं जा सकता अक्षांश देशांतर मान दर्ज करें @@ -35,15 +30,12 @@ अक्षर दर्ज करें लंबा अक्षर दर्ज करें पाठ दर्ज करें - अक्षांश - देशांतर सेव नहीं रिव्यू पूरा हुआ बदलावों को छोड़ना है सेव अभी नहीं - सेव करें संपादन करते रहें साझा करें बंद करें @@ -68,8 +60,7 @@ खोजें और अधिक पाएं पूरा हुआ सपोर्टेड नहीं है - + रद्द करें + ठीक है \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-id/strings.xml b/commonskmm/src/commonMain/composeResources/values-id/strings.xml index ac6327ee130..7c91f0384ed 100644 --- a/commonskmm/src/commonMain/composeResources/values-id/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-id/strings.xml @@ -17,15 +17,9 @@ Pilih unit organisasi Bidang ini wajib Wajib - Tanggal - Tahun - Bulan - Hari Poligon Garis lintang Garis bujur - Nilai koordinat tidak valid - Poligon ditangkap Masukkan nilai Masukkan URL Masukan email @@ -43,12 +37,10 @@ Lengkap Hapus perubahan Tersimpan! - Simpan bagaimanapun Bagikan Izin kamera ditolak.\nAnda harus mengaktifkannya untuk menggunakan fitur ini. Tutup Tersalin - Tidak ada info untuk bidang ini Ambil gambar Periksa ini! Jangan perlihatkan lagi @@ -74,10 +66,8 @@ Tidak ada hasil ditemukan Selesai Tidak didukung - Server ini menjalankan versi dhis yang berbeda dari 2.29. Perbarui versi server Anda atau gunakan pengujian lingkungan untuk melanjutkan. Kesalahan saat penguraian hasil - + Batal + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-km/strings.xml b/commonskmm/src/commonMain/composeResources/values-km/strings.xml index 5ba0a42556a..34cbb15a775 100644 --- a/commonskmm/src/commonMain/composeResources/values-km/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-km/strings.xml @@ -10,10 +10,6 @@ កំហុសបច្ចេកទេស ជ្រើសរើសថ្ងៃខែឆ្នាំ ទាមទារ - កាលបរិច្ឆេទ - ឆ្នាំ - ខែ - ថ្ងៃ រយៈទទឹង រយៈបណ្តោយ បញ្ជូលទិន្នន័យជាលេខ (វិជ្ជមាន រឺ លេខ ០) @@ -44,10 +40,8 @@ មិនមានលទ្ធផលបង្ហាញ Done មិនគាំទ្រ​ / មិនអាចធ្វើឡើងបានទេ - ម៉ាស៊ីនមេនេះកំពុងដំណើរការខុសពី DHIS ២.២៩។ សូមធ្វើបច្ចុប្បន្នភាពលើម៉ាស៊ីនមេរបស់អ្នក​ កំហុសខណៈពេលញែកលទ្ធផល - + បោះបង់ + យល់ព្រម \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-lo/strings.xml b/commonskmm/src/commonMain/composeResources/values-lo/strings.xml index 71109ab0dc3..50e290bb0f5 100644 --- a/commonskmm/src/commonMain/composeResources/values-lo/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-lo/strings.xml @@ -23,16 +23,9 @@ ຄ່າຂອງຫ້ອງປ້ອນຂໍ້ມູນ%sບໍ່ໄດ້ຂຶ້ນກັບຕົວເລືອກທີ່ຕັ້ງໄວ້ໃນ%s ຊ່ອງປ້ອນຂໍ້ມູນນີ້ແມ່ນຈໍາເປັນຕ້ອງປ້ອນຂໍ້ມູນໃສ່ ຕ້ອງການ - ວັນທີ່ - ປີ - ເດືອນ - ມື້ - %sບໍ່ສາມາດວິເຄາະເປັນວັນທີໄດ້ Polygon ເສັ້ນຂະໜານ ເສັ້ນແວງ - ປະສານງານບໍ່ຖືກຕ້ອງ - ບັນທຶກຮູບຫຼາຍຫຼ່ຽມ ປ້ອນຄ່າ ປ້ອນ url ປ້ອນອີເມລ @@ -47,23 +40,18 @@ ປ້ອນຂໍ້ມູນເປັນຂໍ້ຄວາມຍາວ ຂຽນຄໍາຕອບ ວັນທີຕ້ອງແລ້ວ - ຊ່ອງປ້ອນຂໍ້ມູນທີ່ບັງຄັບ ບໍ່ໄດ້ບັນທຶກ ທົບທວນຄືນ ສຳເລັດແລ້ວ ລຶບການປ່ຽນແປງ ບັນທຶກແລ້ວ! ບໍ່​ແມ່ນ​ຕອນ​ນີ້ - ບັນທຶກ ສຶບຕໍ່ແກ້ໄຂ ທ່ານຕ້ອງການໝາຍແບບຟອມນີ້ວ່າສຳເລັດແລ້ວຫຼືບໍ? ຖ້າທ່ານອອກດຽວນີ້ ຂໍ້ມູນທັງໝົດໃນແບບຟອມຈະຖືກຍົກເລີກ. ເຜີຍແຜ່ ປິດ - ຂໍ້ຄວນລະວັງກົດຂອງສາຍງານ - ມີບັນຫາການກຳນົດຄ່າທີ່ເຮັດໃຫ້ເກີດມີຊ່ອງຫວ່າງໃນກົດລະບຽບ. ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. ສຳເນົາແລ້ວ - ບໍ່ມີຂໍ້ມູນສຳລັບຫ້ອງຂໍ້ມູນນີ້ ຖ່າຍຮູບ ກວດເບີ່ງອັນນີ້ ບໍ່ຕ້ອງສະແດງອີກ @@ -103,12 +91,11 @@ ບໍ່ພົບຜົນການຊອກຫາ ສຳເລັດ ບໍ່ຮອງຮັບ - ບໍ່ມີລະຫັດຜ່ານ. ກະລຸນາໃສ່ລະຫັດຜ່ານ. ບໍ່ມີຊື່ຜູ້ໃຊ້. ກະລຸນາປ້ອນຊື່ຜູ້ໃຊ້ - ຄອມພີວເຕີໜ່ວຍແມ່ກໍາລັງທໍາງານກັບລະບົບ DHIS2 ລຸ້ນສູງກ່ວາ 2.29. ກະລຸນາ ນໍາໃຊ້ລຸນທີ່ສູງກ່ວາ ຫຼື ນໍາໃຊ້ລະບົບທົດລອງແທນ ຄອມພິວເຕີໜ່ວຍແມ່ເຄື່ອງນີ້ກຳລັງໃຊ້ເວີຊັນ DHIS2 ທີ່ແຕກຕ່າງຈາກ%s . ອັບເດດລຸ່ນຂອງຄອມພິວເຕີເຄື່ອງແມ່ຂອງທ່ານ ຫຼື ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. ມີບາງສິ່ງບາງຢ່າງຜິດພາດ. ກະລຸນາກວດເບິ່ງທີ່ຢູ່ຂອງເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່, ຊື່ຜູ້ໃຊ້ ແລະ ລະຫັດຜ່ານວ່າຖືກຕ້ອງແລ້ວ ແລ້ວລອງໃໝ່ອີກຄັ້ງ. ຂໍອາໄພ! ມີບາງຢ່າງລົ້ມເຫຼວໃນເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່. @@ -128,7 +115,6 @@ ບໍ່ສາມາດເຂົ້າເຖິງທີ່ເກັບຂອງອຸປະກອນໄດ້. ການສ້າງຖືກຍົກເລີກ. ມີວັດຖຸຢູ່ແລ້ວ. ຍົກເລິກການລົບແລ້ວ. ບໍ່ມີວັດຖຸຢູ່ - ພວກເຮົາບໍ່ສາມາດຈອງຄ່າສໍາລັບບັນທຶກເຫຼົ່ານີ້. ກະລຸນາຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. ກະລຸນາເຂົ້າສູ່ລະບົບເພື່ອສົ່ງອອກຖານຂໍ້ມູນ. ບໍ່ຮອງຮັບການສົ່ງອອກຖານຂໍ້ມູນທີ່ຖືກເຂົ້າລະຫັດ. ທ່ານກໍາລັງນໍາເຂົ້າຖານຂໍ້ມູນທີ່ມີຢູ່ແລ້ວ. @@ -154,7 +140,6 @@ ເຄື່ອງຄອມພິວເຕີໜ່ວຍແມ່ໃຊ້ເວລາຕອບສະໜອງດົນເກີນໄປ. ພວກເຮົາຍົກເລິກການຮ້ອງຂໍ ບໍ່ສາມາດອັບເດດຄວາມສຳພັນໄດ້. ຊ່ວງເວລາມີຫຼາຍເກີນໄປ - ພວກເຮົາບໍ່ສາມາດຊອກຫາ URL ໄດ້. ກະລຸນາກວດສອບມັນ. ບັນຊີຂອງທ່ານຖືກປິດການນຳໃຊ້ແລ້ວ. ຖ້ານີ້ແມ່ນຄວາມຜິດພາດ, ກະລຸນາຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. ບັນຊີຜູ້ໃຊ້ຂອງທ່ານຖືກລັອກ. ຖ້າຫາກນີ້ແມ່ນຂໍ້ຜິດພາດ, ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. ບໍ່ສາມາດຕັ້ງຄ່າໄດ້. @@ -164,8 +149,11 @@ ຄຸນລັກສະນະບໍ່ພຽງພໍທີ່ຈະດໍາເນີນການຄົ້ນຫາ. ກະລຸນາປັບປຸງການຄົ້ນຫາຂອງທ່ານດ້ວຍຄຸນລັກສະນະເພີ່ມເຕີມ. ການຄົ້ນຫາອອນລາຍໃຊ້ໄດ້ກັບຫົວຫນ່ວຍການຈັດຕັ້ງທີ່ຄົ້ນຫາຜູ້ໃຊ້ຂອງທ່ານເທົ່ານັ້ນ. ທ່ານອອຟລາຍຢູ່. ເຊື່ອມຕໍ່ອິນເຕີເນັດ ແລະ ລອງໃໝ່ອີກຄັ້ງ - + OR + ຍົກເລີກ + ​ຕົກ​ລົງ \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-my/strings.xml b/commonskmm/src/commonMain/composeResources/values-my/strings.xml index 3f240d322a6..e4d17d0f0bd 100644 --- a/commonskmm/src/commonMain/composeResources/values-my/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-my/strings.xml @@ -4,9 +4,6 @@ အမှား ရက်စွဲ ကိုရွေးချယ်ပါ ဖြည့်သွင်းရန်လိုအပ်သည် - ရက်စွဲ - နှစ်များ - လများ Дата выполнения ပြည့်စုံသည်။ ပိတ်သည် @@ -18,8 +15,7 @@ ပယ်ဖျက်သည် No results found Done - + ပယ်ဖျက်သည် + လက်ခံသည် \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-nb/strings.xml b/commonskmm/src/commonMain/composeResources/values-nb/strings.xml index 368486d6897..fc34c808707 100644 --- a/commonskmm/src/commonMain/composeResources/values-nb/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-nb/strings.xml @@ -18,15 +18,9 @@ Velg organisasjonsenhet Dette feltet er obligatorisk Påkrevd - Dato - År - Måneder - Dager Polygon Breddegrad Lengdegrad - Koordinatverdiene er ikke gyldige - Polygon oppfanget Legg inn verdi Legg inn URL Legg inn e-post @@ -44,12 +38,10 @@ Fullfør Forkast endringer Lagret! - Lagre likevel Fortsett å rediger Del Lukk Kopiert - Ingen info for dette feltet Sjekk dette! Attributter - %s Koordinater for @@ -75,10 +67,12 @@ Ingen resultater funnet Fullfør Ikke støttet - Denne serveren kjører en dhis versjon som er forskjellig fra 2.29. Oppdater din serverversjon eller bruk testmiljøet for å fortsette. Feil under analyse av resultater - + ELLER + Avbryt + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ne/strings.xml b/commonskmm/src/commonMain/composeResources/values-ne/strings.xml index 67d960618f7..2829599ac19 100644 --- a/commonskmm/src/commonMain/composeResources/values-ne/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ne/strings.xml @@ -1,11 +1,7 @@ - मिति स्वीकार रद्द गर्नुहोस् कुनै पनि नतिजा भेटिएन - - - \ No newline at end of file + रद्द गर्नुहोस् + \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-nl/strings.xml b/commonskmm/src/commonMain/composeResources/values-nl/strings.xml index 206c502aa19..ef3fc47b3f1 100644 --- a/commonskmm/src/commonMain/composeResources/values-nl/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-nl/strings.xml @@ -23,16 +23,9 @@ Waarde voor veld %s behoort niet tot de optieset in %s Dit veld is verplicht Vereist - Datum - Jaren - Maanden - Dagen - %s kan niet worden verwerkt als een datum Veelhoek Breedtegraad Lengtegraad - Coördinatenwaarden zijn niet geldig - Polygoon vastgelegd Voer waarde in URL invoeren Voer email in @@ -47,27 +40,18 @@ Voer lange tekst in Tekst invoeren Deadline - Verplicht veld - Breedte: - Lang: Niet opgeslagen Beoordeling Compleet Veranderingen ongedaan maken Opgeslagen! Niet nu - Toch opslaan Blijf bewerken Wilt u dit formulier als voltooid markeren? Als u nu afsluit, wordt alle informatie in het formulier weggegooid. - Sommige velden bevatten fouten en worden niet opgeslagen. - Sommige verplichte velden ontbreken en het formulier kan niet worden opgeslagen. Deel Sluit - Waarschuwing programmaregels - Er is een configuratieprobleem dat een lus in de regels veroorzaakt. Neem contact op met uw beheerder. Gekopieerd - Geen info voor dit veld Neem foto Check dit! Niet meer laten zien @@ -91,7 +75,6 @@ Coördinaten voor de Er zijn geen gereserveerde waarden meer. Neem contact op met uw systeembeheerder Incident datum - De locatietoestemming is nodig voor het vastleggen van coördinaten. Schakel ze alstublieft in de applicatie-instellingen in Openen met Geen optie gevonden Downloaden @@ -109,12 +92,11 @@ Geen resultaten gevonden Klaar Niet ondersteund - Wachtwoord ontbreekt. Voer wachtwoord in alstublieft. Gebruikersnaam ontbreekt. Voer gebruikersnaam in. - Deze server draait een andere versie dan 2.29. Werk uw serverversie bij of gebruik de testomgeving om verder te gaan. Deze server draait een andere DHIS2-versie dan %s. Werk uw serverversie bij of neem contact op met uw beheerder. Er is iets fout gegaan. Controleer of de server-URL, gebruikersnaam en wachtwoord juist zijn en probeer het opnieuw. Sorry! Er is iets misgegaan aan de serverzijde. @@ -135,7 +117,6 @@ Creatie afgebroken. Het object bestaat al. Verwijdering afgebroken. Het voorwerp bestaat niet. Kan sleutelarchief niet instantiëren. - We konden geen waarden voor deze plaat reserveren. Neem contact op met uw beheerder. Log in om de database te exporteren. Het exporteren van een versleutelde database wordt niet ondersteund. U importeert een reeds bestaande database. @@ -162,7 +143,6 @@ De server nam te veel tijd om te reageren. We hebben het verzoek geannuleerd. De relatie kan niet worden bijgewerkt. Er zijn te veel periodes. - We konden de URL niet vinden. Controleer het alstublieft. Uw gebruikersaccount is uitgeschakeld. Neem contact op met uw beheerder als dit een fout is. Uw gebruikersaccount is vergrendeld. Neem contact op met uw beheerder als dit een fout is. De waarde kon niet worden ingesteld. @@ -172,8 +152,7 @@ Er zijn niet genoeg attributen om de zoekopdracht uit te voeren. Verfijn uw zoekopdracht met meer attributen. Online zoeken is alleen beschikbaar voor uw organisatie-eenheden voor het zoeken van gebruikers. Er zijn ongeldige tekens in de vraag. Vraag de beheerder om de serverconfiguratie te verifiëren (relaxedQueryChars,...). - + Annuleren + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-or/strings.xml b/commonskmm/src/commonMain/composeResources/values-or/strings.xml index f6b29d4ee0f..f89310adafb 100644 --- a/commonskmm/src/commonMain/composeResources/values-or/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-or/strings.xml @@ -15,8 +15,4 @@ ଅକ୍ଷାଂଶ ଦ୍ରାଘିମା ହୋଇଗଲା - - - \ No newline at end of file + \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-prs/strings.xml b/commonskmm/src/commonMain/composeResources/values-prs/strings.xml index 385fef3c969..e8ffe608337 100644 --- a/commonskmm/src/commonMain/composeResources/values-prs/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-prs/strings.xml @@ -3,10 +3,6 @@ رقم باید کامل باشد خطا ضروری - تاریخ - سالانه - ماه ها - روزها چند ضلعی عرض البلد طول البلد @@ -27,8 +23,11 @@ طول البلد هیج نتیجه ی یافت نشد انجام شد - + یا + انصراف + تأیید \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ps/strings.xml b/commonskmm/src/commonMain/composeResources/values-ps/strings.xml index 2b86d6ececd..d33f57f12c1 100644 --- a/commonskmm/src/commonMain/composeResources/values-ps/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ps/strings.xml @@ -3,10 +3,6 @@ ارقام باید صحیح عدد وي تېروته اړین دي - نېټه - کلونه - میاشت - ورځې لېټېټيوډ لانګېټيوډ ټاکلې نېټه @@ -24,8 +20,7 @@ لانګېټيوډ هېڅ پایله ترلاسه نه شوه ترسره شو - + رد یې کړئ [ ژباړه ـ ردول ] + سم دی \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonskmm/src/commonMain/composeResources/values-pt-rBR/strings.xml index 86e2dbfc597..95f522f9084 100644 --- a/commonskmm/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -3,14 +3,9 @@ Valor deve ser um número inteiro Erro Exigido - Data - Anos - Meses - Dias Poligono Latitude Longitude - Polígono capturado Data de vencimento Completo Partilhar @@ -29,8 +24,7 @@ Longitude Nenhum resultado encontrado Concluído - + Cancelar + Aprovado \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-pt/strings.xml b/commonskmm/src/commonMain/composeResources/values-pt/strings.xml index b40e2d571b2..c2498ba2970 100644 --- a/commonskmm/src/commonMain/composeResources/values-pt/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-pt/strings.xml @@ -24,16 +24,9 @@ Este campo é obrigatório Tem algumas mensagens de aviso. Obrigatório - Encontro - Anos - Meses - Dias - %s não pode ser analisado como uma data Poligono Latitude Longitude - Os valores das coordenadas não são válidos - Polígono capturado Digite o valor Digite o URL Digite o email @@ -48,27 +41,18 @@ Inserir texto longo Inserir texto Data de Vencimento - Campo obrigatório - Latitude: - Longitude: Não guardado Revisão Concluir Descartar alterações Gravado! Agora não - Salve mesmo assim Continue a editar Deseja marcar este formulário como completo? Se sair agora, todas as informações do formulário serão descartadas. - Alguns campos apresentam erros e não são guardados. - Alguns campos obrigatórios estão em falta e o formulário não pode ser guardado. Partilhar Fechar - Aviso de regras do programa - Existe um problema de configuração que está a provocar um ciclo nas regras. Contacte o seu administrador. Copiado - Nenhuma informação para este campo Tirar fotografia Verifique isso! Não mostrar novamente @@ -92,13 +76,10 @@ Coordenadas para o Não há mais valores reservados. Entre em contato com o administrador do sistema Data do incidente - A permissão de localização é necessária para captar as coordenadas. Por favor, ative-a nas definições do aplicativo Abrir com Nenhuma opção encontrada Baixar Digitalizar - Código QR - Código de barras Adicionar localização Este formulário não tem campos configurados %s detalhes @@ -124,12 +105,11 @@ Nenhum resultado encontrado Feito Não suportado - A palavra-passe está em falta. Introduza a palavra-passe. O nome de utilizador está em falta. Por favor, introduza o nome de utilizador. - Este servidor está executando uma versão dhis diferente de 2.29. Atualize a versão do servidor ou use o ambiente de teste para continuar. Este servidor está a executar uma versão do DHIS2 diferente de %s. Actualize a versão do seu servidor ou contacte o seu administrador. Algo correu mal. Verifique se o URL do servidor, o nome de utilizador e a palavra-passe estão correctos e tente novamente. Desculpe! Algo falhou no lado do servidor. @@ -150,7 +130,6 @@ Criação abortada. O objeto já existe. Eliminação abortada. O objeto não existe. Não foi possível instanciar o keystore. - Não foi possível reservar valores para este registo. Por favor, contacte o seu administrador. Inicie sessão para exportar a base de dados. A exportação de uma base de dados encriptada não é suportada. Está a importar uma base de dados já existente. @@ -177,7 +156,6 @@ O servidor demorou demasiado tempo a responder. Cancelamos a requisição. O relacionamento não pode ser actualizado. Existem muitos períodos. - Não foi possível encontrar a URL. Por favor, verifique-o. A sua conta de utilizador foi desactivada. Se isto for um erro, contacte o seu administrador. A sua conta de utilizador está bloqueada. Se isto for um erro, contacte o seu administrador. O valor não pode ser definido. @@ -189,8 +167,11 @@ Existem caracteres inválidos na consulta. Por favor, peça ao administrador para verificar a configuração do servidor (relaxedQueryChars,...). O servidor está indisponível do momento. Tente novamente mais tarde. Está off-line. Ligue-se à Internet e tente novamente. - + OR + Cancelar + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ro/strings.xml b/commonskmm/src/commonMain/composeResources/values-ro/strings.xml index 8e2f5f540a6..349eb09ecb6 100644 --- a/commonskmm/src/commonMain/composeResources/values-ro/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ro/strings.xml @@ -1,11 +1,6 @@ Eroare - Data - Ani - Luni - Zile - Salvare oricum Partajare Închidere Data incidentului @@ -13,8 +8,5 @@ Anulare Nu au fost găsite rezultate Terminat - - - \ No newline at end of file + Anulare + \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ru/strings.xml b/commonskmm/src/commonMain/composeResources/values-ru/strings.xml index b27343b62c4..5f8be6e2bb1 100644 --- a/commonskmm/src/commonMain/composeResources/values-ru/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ru/strings.xml @@ -23,15 +23,9 @@ У вас есть несколько предупреждающих сообщений. У Вас есть несколько предупреждающих сообщений.\nХотите ли Вы отметить эту форму как завершенную\? Обязательный - Дата - Годы - Месяцы - Дни Полигон Широта Долгота - Значения координат не действительны - Полигон зафиксирован Введите значение Введите url Введите электронную почту @@ -46,7 +40,6 @@ Ввод длинного текста Введите текст Дата выполнения - Обязательное поле Не сохранено Просмотр Завершить @@ -56,21 +49,14 @@ В некоторых полях есть ошибки, и они не сохраняются. \nВы хотите просмотреть форму\? Некоторые поля требуют Вашего внимания.\nХотите просмотреть форму\? Не сейчас - Сохранить в любом случае Продолжить редактирование Хотите ли Вы отметить эту форму как завершенную? Если Вы выйдете сейчас, вся информация в форме будет удалена. Некоторые поля содержат ошибки, и они не сохраняются. \nЕсли Вы выйдете сейчас, изменения будут отменены. - Некоторые поля содержат ошибки, и они не сохраняются. - Некоторые обязательные поля отсутствуют, и форму невозможно сохранить. - Некоторые обязательные поля отсутствуют, и форма не может быть сохранена. \nЕсли вы выйдете сейчас, изменения будут отменены. Поделиться Разрешение камеры запрещено.\nЧтобы использовать эту функцию, ее необходимо разрешить. Закрыть - Предупреждение о правилах программы - Существует проблема конфигурации, вызывающая зацикливание правил. Обратитесь к администратору. Скопировано - Нет информации для этого поля Сделать фото Из галереи Проверьте это! @@ -94,13 +80,10 @@ Координаты для Больше нет зарезервированных значений. Обратитесь к системному администратору Дата происшествия - Для захвата координат необходимо разрешение на определение местоположения. Пожалуйста, включите их в настройках приложения Открыть с помощью Опции не найдены Скачать Сканировать - QR-код - Штрих-код Добавить локацию В этой форме нет настроенных полей %s деталей @@ -128,12 +111,11 @@ Искать, чтобы найти больше Готово Не поддерживается - Пароль отсутствует. Пожалуйста, введите пароль. Имя пользователя отсутствует. Пожалуйста, введите имя пользователя. - На этом сервере установлена версия dhis, отличная от 2.29. Обновите версию своего сервера или используйте тестовую среду для продолжения работы. На этом сервере установлена версия DHIS2, отличная от %s. Обновите версию сервера или обратитесь к администратору. Что-то пошло не так. Пожалуйста, проверьте правильность URL-адреса сервера, имени пользователя и пароля и повторите попытку. Извините! Что-то не получилось на стороне сервера. @@ -152,7 +134,6 @@ Невозможно получить доступ к хранилищу ключей устройства. Создание прервано. Объект уже существует. Удаление прервано. Объект не существует. - Мы не смогли зарезервировать значения для этих записей. Пожалуйста, свяжитесь с вашим администратором. Пожалуйста, войдите в систему, чтобы экспортировать базу данных. Экспорт зашифрованной базы данных не поддерживается. Вы импортируете уже существующую базу данных. @@ -171,7 +152,6 @@ Серверу потребовалось слишком много времени для ответа. Мы отменили запрос. Связь не может быть обновлена. Слишком много периодов. - Мы не смогли найти URL-адрес. Пожалуйста, проверьте его. Ваша учетная запись пользователя была отключена. Если это ошибка, обратитесь к своему администратору. Ваша учетная запись заблокирована. Если это ошибка, обратитесь к своему администратору. Значение не удалось установить. @@ -180,8 +160,13 @@ Онлайн-поиск доступен только для подразделений вашей организации по поиску пользователей. Сервер в настоящее время недоступен. Повторите попытку позже. Вы находитесь оффлайн. Подключитесь к Интернету и повторите попытку. - + ИЛИ + ВОЗРАСТ + ДАТА РОЖДЕНИЯ + Отменить + ОК \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-si/strings.xml b/commonskmm/src/commonMain/composeResources/values-si/strings.xml index fdbec3574f9..21aa1406a9e 100644 --- a/commonskmm/src/commonMain/composeResources/values-si/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-si/strings.xml @@ -1,18 +1,10 @@ දෝෂය - දිනය - අවුරුදු - මාස - දින වෙනස් කිරීම් ඉවතලන්න වසන්න බාගත කරන්න හෝ පිළිගන්න නිම කළා - - \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-sv/strings.xml b/commonskmm/src/commonMain/composeResources/values-sv/strings.xml index 7dfff1d44f2..798704fbceb 100644 --- a/commonskmm/src/commonMain/composeResources/values-sv/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-sv/strings.xml @@ -11,10 +11,6 @@ Fel Välj datum Nödvändig - Datum - År - Månader - Dagar Latitud Longitud Ange ett positivt heltal eller noll. @@ -43,10 +39,8 @@ Longitud Lägg till fil Inga resultat hittades - Den här servern kör en dhis-version som skiljer sig från 2.29. Uppdatera din serverversion eller använd testmiljön för att fortsätta. Fel vid tolkning av resultat - + Avbryt + ok \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-tet/strings.xml b/commonskmm/src/commonMain/composeResources/values-tet/strings.xml index 548c1951621..066de3d02bd 100644 --- a/commonskmm/src/commonMain/composeResources/values-tet/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-tet/strings.xml @@ -2,16 +2,12 @@ Erru Nesesáriu - Data - Tinan - Fulan Kompletu Partilla Taka Hatun Kansela - + Kansela + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-tg/strings.xml b/commonskmm/src/commonMain/composeResources/values-tg/strings.xml index f1ff83d2524..921720cdddd 100644 --- a/commonskmm/src/commonMain/composeResources/values-tg/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-tg/strings.xml @@ -3,9 +3,6 @@ Қимат бояд адади бутун бошад Хатогӣ Ҳатмӣ - Сана - Солҳо - Моҳҳо Бисёркунҷа Тӯл Арз @@ -22,8 +19,11 @@ Тӯл Арз Иҷро шуд - + Ё + Бекор кардан + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-uk/strings.xml b/commonskmm/src/commonMain/composeResources/values-uk/strings.xml index 542f620c9cf..63e2e009d39 100644 --- a/commonskmm/src/commonMain/composeResources/values-uk/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-uk/strings.xml @@ -23,16 +23,9 @@ Значення для поля %s не належить параметру, встановленому в %s Це поле обов\'язкове для заповнення Обов\'язкове поле - Дата - Роки - Місяці - Дні - %s не можна проаналізувати як дату Багатокутник Широта Довгота - Значення координат недійсні - Зафіксовано багатокутник Введіть значення Введіть URL-адресу Введіть адресу електронної пошти @@ -47,9 +40,6 @@ Введіть довгий текст Введіть текст Запланована дата - Обов\'язкове поле - Широта: - Довгота: Не збережено Перегляд Завершити @@ -59,14 +49,9 @@ Продовжити редагування Ви бажаєте позначити цю форму як завершену? Якщо Ви зараз вийдете, уся інформація у формі буде відхилена. - Деякі поля містять помилки і не були збережені. - Деякі обов’язкові поля не заповнено, і форму неможливо зберегти. Поділитися Закрити - Попередження про правила програми - Існує проблема конфігурації, яка спричиняє помилку у правилах. Зверніться до адміністратора. Скопійовано - Немає інформації для цього поля Зробити фото Перевірте це! Не показувати знову @@ -91,7 +76,6 @@ Більше немає зарезервованих значень. Зверніться до свого системного адміністратора Дата інциденту Завантажити - QR-код Додати місцезнаходження %s Деталі Організаційний підрозділ @@ -112,12 +96,11 @@ Результатів не знайдено Зроблено Не підтримується - Пароль відсутній. Будь ласка, введіть пароль. Ім\'я користувача відсутнє. Будь ласка, введіть ім\'я користувача. - На цьому сервері працює інша версія dhis, ніж 2.29. Щоб продовжити, оновіть версію серверу або скористайтеся середовищем тестування. На цьому сервері працює версія DHIS2, що відрізняється від %s. Оновіть версію серверу або сконтактуйте з системним адміністратором. Сталася помилка. Будь ласка, перевірте правильність URL-адреси серверу, імені користувача та пароля і повторіть спробу. Вибачте! На сервері сталася помилка. @@ -138,7 +121,6 @@ Створення перервано. Об\'єкт вже існує. Видалення перервано. Об\'єкт не існує. Не вдалося створити екземпляр сховища ключів. - Ми не змогли зарезервувати значення для цих записів. Будь ласка, зверніться до свого адміністратора. Увійдіть в систему, щоб експортувати базу даних. Експорт зашифрованої бази даних не підтримується. Ви імпортуєте вже існуючу базу даних. @@ -165,7 +147,6 @@ Сервер надто довго відповідав. Ми скасували запит. Зв\'язки не можна оновити. Надто багато періодів. - Не вдалося знайти URL-адресу. Будь ласка, перевірте це. Ваш обліковий запис користувача вимкнено. Якщо це помилка, зверніться до адміністратора. Ваш обліковий запис користувача заблоковано. Якщо це помилка, зверніться до адміністратора. Не вдалося встановити значення. @@ -175,8 +156,9 @@ Недостатньо атрибутів для виконання пошуку. Уточніть пошук за допомогою додаткових атрибутів. Онлайн-пошук доступний лише для організаційних підрозділів користувача. У запиті є недійсні символи. Попросіть адміністратора перевірити конфігурацію сервера (relaxedQueryChars,...). - + ВІК + ДАТА НАРОДЖЕННЯ + Скасувати + ОК \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-ur/strings.xml b/commonskmm/src/commonMain/composeResources/values-ur/strings.xml index 2732a5b0808..8505ea64dce 100644 --- a/commonskmm/src/commonMain/composeResources/values-ur/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-ur/strings.xml @@ -3,13 +3,10 @@ ویلیو انٹیجر ہونی چاہیے غلطی لازمی - تاریخ - سال - مہینے - دن کثیر زبان طول لمبائی + کثیر الاضلاع (حدود) محفوظ کر لی گئیں اخری تاریخ مکمل بانٹیں @@ -26,8 +23,11 @@ لمبائی کوئی نتیجہ نہیں ملا ہو گیا - + یا + منسوخ + ٹھیک \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonskmm/src/commonMain/composeResources/values-uz-rUZ/strings.xml index d72ae5ff712..846df44e6d8 100644 --- a/commonskmm/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -18,15 +18,9 @@ Tashkiliy boʼlimni tanlang Maydon toʼldirilishi majburiy Majburiy - Sana - Yillar - Oylar - Kunlar Poligon Kenglik Kenglik - Koordinata qiymatlari yaroqsiz - Koʼpburchak ushlandi Qiymatni tanlang URL ni tanlang ye-pochta manzilini kiriting @@ -45,12 +39,10 @@ Bajarildi Oʼzgartirishlar bekor qilinsin Saqlandi! - Nima boʼlganda ham saqlash Tahrirni davom eting Улашиш Yopmoq Nusxa olindi - Ushbu maydon uchun maʼlumot yoʼq Buni tekshiring! Аtribatlar - %s Uchun koordinatalar @@ -71,11 +63,9 @@ Fayl qoʼshish Rasm qoʼshish Hech qanday natija topilmadi - Ushbu server 2.29 dan farqli versiyani ishlatmoqda. Server versiyasini yangilang yoki davom etish uchun sinov muddatidan foydalaning. Nimadir notoʼgʼri bajarildi. Server URL manzili, foydalanuvchi nomi va parol toʼgʼri ekanligini tekshiring va qaytadan urining. Natijalarni tahlil qilishda xatolik yuz berdi - + Бекор қилиш + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-uz/strings.xml b/commonskmm/src/commonMain/composeResources/values-uz/strings.xml index 8f26c0404fd..af1e7cadc1c 100644 --- a/commonskmm/src/commonMain/composeResources/values-uz/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-uz/strings.xml @@ -18,15 +18,9 @@ Ташкилий бўлимни танланг Майдон тўлдирилиши мажбурий Мажбурий - Сана - Йиллар - Ойлар - Кунлар Полигон Кенглик Узунлик - Координата қийматлари яроқсиз - Полигон белгиланди Қийматни танланг URL ни танланг е-почта манзилини киритинг @@ -46,12 +40,10 @@ Ўзгартиришлар бекор қилинсин Сақланди! Hozir emas - Нима бўлганда ҳам сақлаш Таҳрирни давом этинг Улашиш Ёпмоқ Нусха олинди - Ушбу майдон учун маълумот йўқ Суратга олиш Буни текширинг! Атрибатлар - %s @@ -59,7 +51,6 @@ Сақланган бошқа қийматлар йўқ. Тизим администраторига мурожаат қилинг. Ҳодиса санаси Юклаш - QR код Локация қўшинг Ташкилий бўлим Координаталар @@ -81,10 +72,14 @@ Натижа йўқ Бўлди Қўллаб қувватланмайди - Ушбу сервер 2.29 дан фарқли версияни ишлатмоқда. Сервер версиясини янгиланг ёки давом этиш учун синов муддатидан фойдаланинг. Натижаларни таҳлил қилишда хатолик юз берди - + ЁКИ + ЁШИ + ТУҒИЛГАН КУНИ + Рад этиш + ЯХШИ \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-vi/strings.xml b/commonskmm/src/commonMain/composeResources/values-vi/strings.xml index 5310653b42c..14e75b53b2a 100644 --- a/commonskmm/src/commonMain/composeResources/values-vi/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-vi/strings.xml @@ -25,16 +25,9 @@ Bạn có một số tin nhắn cảnh báo. Bạn có một số tin nhắn cảnh báo..\nBạn có muốn đánh dấu biểu nhập này là hoàn tất không\? Bắt buộc - Ngày - Năm - Tháng - Ngày - %s không thể chuyển đổi dữ liệu Ngày Đa giác Vĩ độ Kinh độ - Giá trị tọa độ không hợp lệ - Đã thêm bản đồ ranh giới Nhập giá trị Nhập đường dẫn liên kết Nhập email @@ -49,9 +42,6 @@ Nhập văn bản dài Nhập văn bản Ngày đến hạn - Trường bắt buộc - Vĩ độ: - Kinh độ: Chưa lưu Xem lại Hoàn tất @@ -61,21 +51,14 @@ Một số trường dữ liệu có lỗi và chưa được lưu. \nBạn có muốn xem lại biểu nhập không\? Một số trường dữ liệu cần bạn chú ý.\nBạn có muốn xem xét biểu nhập không\? Không phải bây giờ - Tiếp tục Lưu Tiếp tục chỉnh sửa Bạn có muốn đánh dấu biểu nhập này là hoàn tất không? Nếu bạn tắt bây giờ, tất cả thông tin trong biểu nhập sẽ bị hủy bỏ. Một số trường dữ liệu có lỗi và chưa được lưu.. \nNếu bạn tắt bây giờ, tất cả thay đổi sẽ bị hủy bỏ. - Một số trường dữ liệu bị lỗi và chưa được lưu - Thiếu một số trường dữ liệu bắt buộc và Không thể lưu biểu nhập. - hiếu một số trường dữ liệu bắt buộc và không thể lưu biểu nhập. \nNếu bạn tắt bây giờ, tất cả thay đổi sẽ bị hủy bỏ.. Chia sẻ Quyền mở máy ảnh bị từ chối..\nBạn cần bật tính năng máy ảnh để sử dụng chức năng này. Đóng - Cảnh báo quy tắc chương trình - Có một vấn đề cấu hình gây ra một vòng lặp trong các quy tắc chương trình. Hãy liên lạc với quản lý của bạn. Đã sao chép - Không có thông tin cho trường dữ liệu này Chụp ảnh Từ thư viện ảnh Kiểm tra cái này! @@ -100,13 +83,10 @@ Tọa độ của Không có giá trị dành riêng nào nữa. Liên hệ với quản lý của bạn Ngày xảy ra - Cần quyền định vị để thu thập vị trí. Xin vui lòng mở chúng trong cài đặt ứng dụng Mở với Không tìm thấy tùy chọn Tải về Quét - Mã QR - Mã vạch Thêm vị trí Biểu này không có trường dữ liệu được cấu hình Chi tiết %s @@ -137,12 +117,11 @@ Tìm kiếm để tìm thêm Hoàn thành Không được hỗ trợ - Thiếu mật khẩu. Xin vui lòng nhập mật khẩu Thiếu tên đăng nhập. Xin vui lòng nhập tên đăng nhập. - Máy chủ này đang chạy phiên bản dhis khác với phiên bản 2.29. Hãy cập nhật phiên bản máy chủ của bạn hoặc sử dụng môi trường thực nghiệm để tiếp tục. Máy chủ này đang chạy phiên bản DHIS2 khác với %s. Vui lòng cập nhật phiên bản máy chủ của bạn hoặc liên hệ quản lý. Có gì đó không đúng. Vui lòng kiểm tra đường dẫn liên kết đến máy chủ, tên đăng nhập, mật khẩu và thử lại. Rất tiếc! Có lỗi gì đó ở phía máy chủ @@ -163,7 +142,6 @@ Không thể tạo. Đối tượng đang tồn tại. Xóa bị từ chối. Đối tượng không tồn tại Không thể tạo kho khóa - Chúng ta không thể đặt trước giá trị cho các dữ liệu này. Vui lòng liên hệ quản lý của bạn. Vui lòng đăng nhập để xuất cơ sở dữ liệu. Xuất dữ liệu mã hóa không hỗ trợ Bạn đang nhập cơ sở dữ liệu đang tồn tại @@ -190,7 +168,6 @@ Máy chủ mất quá nhiều thời gian để trả lời. Chúng tôi đã hủy yêu cầu. Mối quan hệ không thể cập nhật. Có quá nhiều thời điểm - Chúng tôi không thể tìm thấy đường dẫn liên kết. Vui lòng kiểm tra lại. Tài khoảng của bạn đã bị vô hiệu hóa. Nếu đây là lỗi, vui lòng liên hệ quản lý của bạn. Tài khoảng của bạn đã bị khóa. Nếu đây là lỗi, vui lòng liên hệ quản lý của bạn. Giá trị không thể được thiết lập @@ -205,8 +182,11 @@ Nhập cơ sở dữ liệu không thành công Tệp không hợp lệ Không ở chế độ đăng ký TOTP 2FA - + HOẶC + Hủy + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonskmm/src/commonMain/composeResources/values-zh-rCN/strings.xml index 3013da7eafc..a83b6a4bc30 100644 --- a/commonskmm/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -24,16 +24,9 @@ 此字段是必填字段 您收到了一些警告信息。 必须的 - 日期 - - - - %s 无法解析为日期 多边形 纬度 经度 - 坐标值无效 - 采集的多边形。 输入值 输入url 输入电子邮件 @@ -48,27 +41,18 @@ 输入长文本 输入文本 到期日期 - 强制字段 - 纬度: - 经度: 未保存 审查 完成 忽略修改 已经保存! 现在不要 - 仍然保存 继续编辑 您想将此表单标记为已完成吗? 如果您现在退出,表单中的所有信息都将被丢弃。 - 一些字段有错误并且它们没有被保存。 - 缺少一些必填字段,无法保存表单。 分享 关闭 - 项目规则警告 - 存在导致规则循环的配置问题。联系您的管理员。 副本 - 该字段没有相关信息 拍照 来自画廊 检查 @@ -93,13 +77,10 @@ 坐标 没有更多保留值,请联系管理员 事件日期 - 获取坐标需要位置权限。请在应用程序设置中启用它们 打开用 找不到选项 下载 扫描 - 二维码 - 条码 添加位置 该表单没有配置任何字段 %s详细信息 @@ -125,12 +106,11 @@ 没有结果 完成 不支持 - 密码丢失。请输入密码。 缺少用户名。请输入用户名。 - 服务器运行版本不是2.29,请更新服务器版本或使用测试环境继续 此服务器正在运行与 %s 不同的 DHIS2 版本。更新您的服务器版本或联系您的管理员。 出了些问题。请检查服务器 URL、用户名和密码是否正确,然后重试。 对不起!服务器端出现问题。 @@ -151,7 +131,6 @@ 创作中止。对象已经存在。 删除已中止。对象不存在。 无法实例化密钥库。 - 我们无法为这些记录保留值。请联系您的管理员。 请登录以导出数据库。 不支持导出加密数据库。 您正在导入一个已经存在的数据库。 @@ -178,7 +157,6 @@ 服务器花费了太多时间来响应。我们取消了请求。 关系无法更新。 期间太多了。 - 我们找不到 URL。请验证一下。 您的用户帐户已被禁用。如果这是错误,请联系您的管理员。 您的用户帐户被锁定。如果这是错误,请联系您的管理员。 无法设置该值。 @@ -190,8 +168,11 @@ 查询中有无效字符。请让管理员验证服务器配置(relaxedQueryChars,...)。 服务器当前不可用。稍后再试。 您已离线。连接到互联网并重试。 - + OR + 取消 + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values-zh/strings.xml b/commonskmm/src/commonMain/composeResources/values-zh/strings.xml index e05bfefce3f..86d410771c6 100644 --- a/commonskmm/src/commonMain/composeResources/values-zh/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values-zh/strings.xml @@ -25,16 +25,9 @@ 您收到了一些警告信息。 您有一些警告消息。\n您想将此表单标记为完成吗? 必须的 - 日期 - - - - %s 无法解析为日期 多边形 维度 经度 - 坐标值无效 - 采集的多边形。 输入值 输入url 输入电子邮件 @@ -49,9 +42,6 @@ 输入长文本 输入文本 到期日期 - 强制字段 - 纬度: - 经度: 未保存 审查 完成 @@ -61,21 +51,14 @@ 一些字段有错误并且它们没有被保存。 \n您要查看表单吗? 某些字段需要您注意。\n您要查看表单吗? 现在不要 - 仍然保存 继续编辑 您想将此表单标记为已完成吗? 如果您现在退出,表单中的所有信息都将被丢弃。 某些字段有错误,无法保存。 \n如果您现在退出,更改将被丢弃。 - 一些字段有错误并且它们没有被保存。 - 缺少一些必填字段,无法保存表单。 - 某些必填字段缺失,无法保存表格。 \n如果您现在退出,更改将被丢弃。 分享 相机权限被拒绝。\n您需要启用它才能使用此功能。 关闭 - 项目规则警告 - 存在导致规则循环的配置问题。联系您的管理员。 副本 - 该字段没有相关信息 拍照 来自画廊 检查 @@ -101,13 +84,10 @@ 坐标 没有更多保留值,请联系管理员 事件日期 - 获取坐标需要位置权限。请在应用程序设置中启用它们 打开用 找不到选项 下载 扫描 - 二维码 - 条码 添加位置 该表单没有配置任何字段 %s详细信息 @@ -138,12 +118,11 @@ 搜索查找更多信息 完成 不支持 - 密码丢失。请输入密码。 缺少用户名。请输入用户名。 - 服务器运行版本不是2.29,请更新服务器版本或使用测试环境继续 此服务器正在运行与 %s 不同的 DHIS2 版本。更新您的服务器版本或联系您的管理员。 出了些问题。请检查服务器 URL、用户名和密码是否正确,然后重试。 对不起!服务器端出现问题。 @@ -164,7 +143,6 @@ 创作中止。对象已经存在。 删除已中止。对象不存在。 无法实例化密钥库。 - 我们无法为这些记录保留值。请联系您的管理员。 请登录以导出数据库。 不支持导出加密数据库。 您正在导入一个已经存在的数据库。 @@ -191,7 +169,6 @@ 服务器花费了太多时间来响应。我们取消了请求。 关系无法更新。 期间太多了。 - 我们找不到 URL。请验证一下。 您的用户帐户已被禁用。如果这是错误,请联系您的管理员。 您的用户帐户被锁定。如果这是错误,请联系您的管理员。 无法设置该值。 @@ -207,8 +184,13 @@ 无效文件 未进入 TOTP 2FA 注册模式 认证取消 - + OR + 年龄 + 出生日期 + 取消 + \ No newline at end of file diff --git a/commonskmm/src/commonMain/composeResources/values/strings.xml b/commonskmm/src/commonMain/composeResources/values/strings.xml index d8ce06638d2..a5ecad6faa6 100644 --- a/commonskmm/src/commonMain/composeResources/values/strings.xml +++ b/commonskmm/src/commonMain/composeResources/values/strings.xml @@ -26,16 +26,9 @@ You have some warning messages. You have some warning messages.\nDo you want to mark this form as complete? Required - Date - Years - Months - Days - %s can't be parsed as a date Polygon Latitude Longitude - Coordinates values are not valid - Polygon captured Enter value Enter url Enter email @@ -50,9 +43,6 @@ Enter long text Enter text Due date - Mandatory field - Lat: - Long: Not saved Review Complete @@ -62,21 +52,14 @@ Some fields have errors and they are not saved. \nDo you want to review the form? Some fields need your attention.\nDo you want to review the form? Not now - Save anyway Keep editing Do you want to mark this form as complete? If you exit now all the information in the form will be discarded. Some fields have errors and they are not saved. \nIf you exit now the changes will be discarded. - Some fields have errors and they are not saved. - Some mandatory fields are missing and the form cannot be saved. - Some mandatory fields are missing and the form cannot be saved. \nIf you exit now the changes will be discarded. Share Camera permission is denied.\nYou need to enable it to use this feature. Close - Program rules warning - There is a configuration issue causing a loop in the rules. Contact you administrator. Copied - No info for this field Take Photo From gallery Check this! @@ -95,20 +78,17 @@ Value is neither true or false This field can only be true Invalid character 1 - DataValues can't be saved using these arguments. Use the other one. + DataValues can\'t be saved using these arguments. Use the other one. Leading zero numbers are not allowed - Couldn't retrieve data + Couldn\'t retrieve data Attributes - %s Coordinates for the There are no more reserved values. Contact your system administrator Incident date - The location permission is needed for capturing coordinates. Please, enable them in the application settings Open with No option found Download Scan - QR code - Bar code Add location This form has no fields configured %s details @@ -139,12 +119,11 @@ Search to find more Done Not supported - Password is missing. Please enter password. Username is missing. Please enter username. - This server is running a dhis version different than 2.29. Update your server version or use the testing environment to proceed. This server is running a DHIS2 version different than %s. Update your server version or contact your admin. Something went wrong. Please check that server URL, username and password are correct and try again. Sorry! Something failed on the server side. @@ -165,7 +144,7 @@ Creation aborted. The object already exists. Deletion aborted. The object does not exist. Unable to instantiate keystore. - We couldn´t reserve values for these record. Please contact your admin. + We couldn\'t reserve values for these record. Please contact your admin. Please login to export the database. Exporting an encrypted database is not supported. You are importing an already existing database. @@ -184,15 +163,15 @@ There are no reserved values available. Go to settings and refill values when you have internet connection. The object could not be updated. The object could not be inserted. - You don't have access. + You don\'t have access. The server URL is missing. Please enter URL. The server URL is not well formed. Please check it. This version of the Settings Web App is not supported. The Settings Web App is not installed in the server. The server took too much time to respond. We canceled the request. - The relationship can't be updated. + The relationship can\'t be updated. There are too many periods. - We couldn´t find the URL. Please verify it. + We couldn\'t find the URL. Please verify it. Your user account have been disabled. If this is an error, contact your administrator. Your user account is locked. If this is an error, contact your administrator. The value could not be set. @@ -201,15 +180,20 @@ SMS is not supported. There are not enough attributes to perform the search. Please refine your search with more attributes. Online search is only available for your user search organisation units. + Invalid configuration There are invalid characters in the query. Please ask the administrator to verify the server configuration (relaxedQueryChars,...). The server is currently unavailable. Try again later. You are offline. Connect to the internet and try again. Database import failed Invalid file Not in TOTP 2FA enrollment mode - Authentication cancelled - + OR + AGE + DATE OF BIRTH + Cancel + OK \ No newline at end of file diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/auth/OpenIdController.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/auth/OpenIdController.kt deleted file mode 100644 index 587ab961235..00000000000 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/auth/OpenIdController.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.dhis2.mobile.commons.auth - -fun interface OpenIdController { - fun handleIntent( - intent: Any, - onResult: suspend (Result) -> Unit, - ) -} diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/biometrics/CryptographicActions.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/biometrics/CryptographicActions.kt index 9146451a323..fa04f543535 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/biometrics/CryptographicActions.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/biometrics/CryptographicActions.kt @@ -3,9 +3,9 @@ package org.dhis2.mobile.commons.biometrics import javax.crypto.Cipher interface CryptographicActions { - fun getInitializedCipherForEncryption(): Cipher? + fun getInitializedCipherForEncryption(): Cipher - fun getInitializedCipherForDecryption(initializationVector: ByteArray): Cipher? + fun getInitializedCipherForDecryption(initializationVector: ByteArray): Cipher fun encryptData( plaintext: String, diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/color/ColorMatcher.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/color/ColorMatcher.kt new file mode 100644 index 00000000000..4aafb3ea226 --- /dev/null +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/color/ColorMatcher.kt @@ -0,0 +1,88 @@ +package org.dhis2.mobile.commons.color + +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * CIE L*a*b* color representation for perceptually uniform distance calculations. + */ +private data class Lab( + val l: Double, + val a: Double, + val b: Double, +) + +/** + * Finds the closest color from the palette to the given selected color + * using the CIE76 Delta E in L*a*b* color space, + * which is more perceptually accurate than simple RGB Euclidean distance. + */ +object ColorMatcher { + fun findClosest( + selectedR: Int, + selectedG: Int, + selectedB: Int, + palette: List, + ): PaletteColor? { + if (palette.isEmpty()) return null + val selectedLab = rgbToLab(selectedR, selectedG, selectedB) + return palette.minByOrNull { color -> + val lab = rgbToLab(color.r, color.g, color.b) + deltaE(selectedLab, lab) + } + } + + /** + * CIE76 Delta E — Euclidean distance in L*a*b* space. + */ + private fun deltaE( + c1: Lab, + c2: Lab, + ): Double = + sqrt( + (c1.l - c2.l).pow(2) + + (c1.a - c2.a).pow(2) + + (c1.b - c2.b).pow(2), + ) + + /** + * Convert sRGB to CIE L*a*b* via XYZ intermediate. + */ + private fun rgbToLab( + r: Int, + g: Int, + b: Int, + ): Lab { + // sRGB -> linear RGB + var rr = r / 255.0 + var gg = g / 255.0 + var bb = b / 255.0 + + rr = if (rr > 0.04045) ((rr + 0.055) / 1.055).pow(2.4) else rr / 12.92 + gg = if (gg > 0.04045) ((gg + 0.055) / 1.055).pow(2.4) else gg / 12.92 + bb = if (bb > 0.04045) ((bb + 0.055) / 1.055).pow(2.4) else bb / 12.92 + + // Linear RGB -> XYZ (D65 illuminant) + val x = (rr * 0.4124564 + gg * 0.3575761 + bb * 0.1804375) / 0.95047 + val y = (rr * 0.2126729 + gg * 0.7151522 + bb * 0.0721750) / 1.00000 + val z = (rr * 0.0193339 + gg * 0.1191920 + bb * 0.9503041) / 1.08883 + + // XYZ -> L*a*b* + val fx = labF(x) + val fy = labF(y) + val fz = labF(z) + + val l = 116.0 * fy - 16.0 + val a = 500.0 * (fx - fy) + val bLab = 200.0 * (fy - fz) + + return Lab(l, a, bLab) + } + + private fun labF(t: Double): Double = + if (t > 0.008856) { + t.pow(1.0 / 3.0) + } else { + (7.787 * t) + (16.0 / 116.0) + } +} diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/color/PaletteColor.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/color/PaletteColor.kt new file mode 100644 index 00000000000..cbdea388636 --- /dev/null +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/color/PaletteColor.kt @@ -0,0 +1,21 @@ +package org.dhis2.mobile.commons.color + +/** + * Represents a color in RGB space, parsed from a hex string. + */ +data class PaletteColor( + val hex: String, + val r: Int, + val g: Int, + val b: Int, +) { + companion object { + fun fromHex(hex: String): PaletteColor { + val clean = hex.removePrefix("#") + val r = clean.substring(0, 2).toInt(16) + val g = clean.substring(2, 4).toInt(16) + val b = clean.substring(4, 6).toInt(16) + return PaletteColor(hex = "#$clean", r = r, g = g, b = b) + } + } +} diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt index 0d05bcfa71e..e51ac96bca3 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/coroutine/CoroutineTracker.kt @@ -1,24 +1,21 @@ package org.dhis2.mobile.commons.coroutine import kotlinx.atomicfu.atomic -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow object CoroutineTracker { private val activeTasks = atomic(0) - private val _isIdle = MutableStateFlow(true) - - val isIdle: StateFlow get() = _isIdle fun increment() { - val count = activeTasks.incrementAndGet() - _isIdle.value = false IdlingResourceProvider.idlingResource.increment() } fun decrement() { - val count = activeTasks.decrementAndGet() - _isIdle.value = (count == 0) + var current: Int + var next: Int + do { + current = activeTasks.value + next = (current - 1).coerceAtLeast(0) + } while (!activeTasks.compareAndSet(current, next)) IdlingResourceProvider.idlingResource.decrement() } } diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/dates/DateTimeInputsUtils.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/dates/DateTimeInputsUtils.kt new file mode 100644 index 00000000000..402504b738e --- /dev/null +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/dates/DateTimeInputsUtils.kt @@ -0,0 +1,94 @@ +package org.dhis2.mobile.commons.dates + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.periodUntil +import kotlinx.datetime.todayIn +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Calculates a date from an age value and time unit + * Uses kotlinx.datetime for date arithmetic + * @param ageValue The age value as an integer + * @param timeUnit The time unit (years, months, or days) - expects string values: "YEARS", "MONTHS", "DAYS" + * @return The calculated date in yyyy-MM-dd format, or null if calculation fails + */ +@OptIn(ExperimentalTime::class) +fun calculateDateFromAge( + ageValue: String, + timeUnit: String, +): String? { + return try { + val today = + Clock.System + .todayIn(TimeZone.currentSystemDefault()) + + val calculatedDate = + when (timeUnit) { + "YEARS" -> today.minus(ageValue.toInt(), DateTimeUnit.YEAR) + "MONTHS" -> today.minus(ageValue.toInt(), DateTimeUnit.MONTH) + "DAYS" -> today.minus(ageValue.toInt(), DateTimeUnit.DAY) + else -> return null + } + + calculatedDate.toString() + } catch (_: Exception) { + null + } +} + +/** + * Calculates age from a date string based on the specified time unit + * Uses kotlinx.datetime for period calculations + * @param dateString The birth date in yyyy-MM-dd format + * @param timeUnit The time unit to calculate age in - expects string values: "YEARS", "MONTHS", "DAYS" + * @return The calculated age as a string, or null if calculation fails + */ +@OptIn(ExperimentalTime::class) +fun calculateAgeFromDate( + dateString: String, + timeUnit: String, +): String? = + try { + val birthDate = LocalDate.parse(dateString) + val today = + Clock.System + .todayIn(TimeZone.currentSystemDefault()) + + when (timeUnit) { + "YEARS" -> { + val period = birthDate.periodUntil(today) + period.years.toString() + } + "MONTHS" -> { + monthsBetween(birthDate, today).toString() + } + "DAYS" -> { + birthDate.daysUntil(today).toString() + } + else -> null + } + } catch (_: Exception) { + null + } + +/** + * Calculates the number of months between two dates + * Uses kotlinx.datetime for accurate month calculations + * @param startDate The start date + * @param endDate The end date + * @return The number of months between the two dates + */ +fun monthsBetween( + startDate: LocalDate, + endDate: LocalDate, +): Int { + val startDateTotalMonths = 12 * startDate.year + startDate.month.number + val endDateTotalMonths = 12 * endDate.year + endDate.month.number + return endDateTotalMonths - startDateTotalMonths +} diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/DateExtensions.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/DateExtensions.kt new file mode 100644 index 00000000000..9fa686dd94b --- /dev/null +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/DateExtensions.kt @@ -0,0 +1,14 @@ +package org.dhis2.mobile.commons.extensions + +import java.util.Date +import kotlin.time.Instant + +fun Date.toKtxInstant(): Instant = Instant.fromEpochMilliseconds(this.time) + +fun Instant.toJavaDate(): Date = Date(this.toEpochMilliseconds()) + +fun getTodayAsInstant(): Instant { + val nowMillis = System.currentTimeMillis() + + return Instant.fromEpochMilliseconds(nowMillis) +} diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt index ca4334259e2..27f2c919e50 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/ViewModelExtensions.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.dhis2.mobile.commons.coroutine.CoroutineTracker +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext /** * Launches a coroutine in the ViewModel's scope for executing a use case. @@ -13,12 +15,14 @@ import org.dhis2.mobile.commons.coroutine.CoroutineTracker * * @param block The suspend block of code to be executed within the coroutine. This is typically the use case logic. */ -fun ViewModel.launchUseCase(block: suspend CoroutineScope.() -> Unit) = - viewModelScope.launch { - CoroutineTracker.increment() - try { - block() - } finally { - CoroutineTracker.decrement() - } +fun ViewModel.launchUseCase( + dispatcher: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> Unit, +) = viewModelScope.launch(dispatcher) { + CoroutineTracker.increment() + try { + block() + } finally { + CoroutineTracker.decrement() } +} diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationManager.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationManager.kt new file mode 100644 index 00000000000..7d6c4951ab7 --- /dev/null +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationManager.kt @@ -0,0 +1,49 @@ +package org.dhis2.mobile.commons.notifications + +interface NotificationManager { + fun getDataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ): NotificationModel + + fun displayDataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ) + + fun getMetadataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ): NotificationModel + + fun displayMetadataSyncNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + progress: Int, + ) + + fun getSyncSettingsNotificationModel( + smallIcon: Int, + contentTitle: String, + contentText: String, + ): NotificationModel + + fun displaySyncSettingsNotification( + smallIcon: Int, + contentTitle: String, + contentText: String, + ) + + fun cancelMetadataSyncNotification() + + fun cancelSyncSettingsNotification() + + fun cancelDataSyncNotification() +} diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationModel.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationModel.kt new file mode 100644 index 00000000000..831c2446f68 --- /dev/null +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/notifications/NotificationModel.kt @@ -0,0 +1,3 @@ +package org.dhis2.mobile.commons.notifications + +interface NotificationModel diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceConstants.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceConstants.kt index 03a9fd3f090..4b3694afcb3 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceConstants.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceConstants.kt @@ -6,5 +6,17 @@ const val SECURE_USER_NAME = "SECURE_USER_NAME" const val SECURE_PASS = "SECURE_PASS" const val SHARE_PREFS = "org.dhis2" const val LAST_META_SYNC = "last_meta_sync" +const val TIME_META = "timeMeta" +const val LAST_META_SYNC_STATUS = "last_meta_sync_status" const val LAST_DATA_SYNC = "last_data_sync" +const val TIME_DATA = "timeData" +const val LAST_DATA_SYNC_STATUS = "last_data_sync_status" +const val SYNC_RESULT = "sync_result" const val BIOMETRIC_CREDENTIALS = "biometric_credentials" +const val EVENT_MAX = "EVENT_MAX" +const val EVENT_MAX_DEFAULT = 1000 +const val TEI_MAX = "TEI_MAX" +const val TEI_MAX_DEFAULT = 500 +const val LIMIT_BY_ORG_UNIT = "LIMIT_BY_ORG_UNIT" +const val LIMIT_BY_PROGRAM = "LIMIT_BY_PROGRAM" +const val MAX_RESERVED_VALUES = 100 diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceProvider.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceProvider.kt index 3c5d1d07892..5fb3b0eebb9 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceProvider.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/providers/PreferenceProvider.kt @@ -1,6 +1,7 @@ package org.dhis2.mobile.commons.providers import org.dhis2.mobile.commons.biometrics.CiphertextWrapper +import kotlin.time.Duration.Companion.days interface PreferenceProvider { fun saveUserCredentials( @@ -91,4 +92,8 @@ interface PreferenceProvider { ): List fun updateLoginServers(serverUrl: String) + + fun getMetadataSyncPeriod(): Int = getInt(TIME_META, 7.days.inWholeSeconds.toInt()) + + fun getDataSyncPeriod(): Int = getInt(TIME_DATA, 1.days.inWholeSeconds.toInt()) } diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/reporting/AnalyticActions.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/reporting/AnalyticActions.kt index 92da4053031..3877335781e 100644 --- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/reporting/AnalyticActions.kt +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/reporting/AnalyticActions.kt @@ -16,7 +16,6 @@ interface AnalyticActions { fun updateMatomoSecondaryTracker( matomoUrl: String, matomoID: Int, - trackerName: String, ) fun clearMatomoSecondaryTracker() diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/resources/Resources.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/resources/DrawableResourcesProvider.kt similarity index 100% rename from commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/resources/Resources.kt rename to commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/resources/DrawableResourcesProvider.kt diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/resources/StringResourceProvider.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/resources/StringResourceProvider.kt new file mode 100644 index 00000000000..6003c0e8ae4 --- /dev/null +++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/resources/StringResourceProvider.kt @@ -0,0 +1,11 @@ +package org.dhis2.mobile.commons.resources + +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString + +class StringResourceProvider { + suspend fun provideString( + resource: StringResource, + number: Int, + ): String = getString(resource, number) +} diff --git a/commonskmm/src/commonTest/kotlin/org/dhis2/mobile/commons/color/ColorMatcherTest.kt b/commonskmm/src/commonTest/kotlin/org/dhis2/mobile/commons/color/ColorMatcherTest.kt new file mode 100644 index 00000000000..2b8d91aa0ac --- /dev/null +++ b/commonskmm/src/commonTest/kotlin/org/dhis2/mobile/commons/color/ColorMatcherTest.kt @@ -0,0 +1,70 @@ +package org.dhis2.mobile.commons.color + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ColorMatcherTest { + // Palette sampled from PaletteThemes covering distinct hue regions + private val palette = + listOf( + PaletteColor.fromHex("#d32f2f"), // Red + PaletteColor.fromHex("#1976d2"), // Blue + PaletteColor.fromHex("#2e7d32"), // Green + PaletteColor.fromHex("#fbc02d"), // Amber/Yellow + PaletteColor.fromHex("#424242"), // Dark Grey + ) + + @Test + fun returnsNullWhenPaletteIsEmpty() { + assertNull(ColorMatcher.findClosest(128, 128, 128, emptyList())) + } + + @Test + fun returnsOnlyColorWhenPaletteHasSingleEntry() { + val single = PaletteColor.fromHex("#1976d2") + assertEquals(single, ColorMatcher.findClosest(255, 0, 0, listOf(single))) + } + + @Test + fun exactColorMatchReturnsItself() { + // #d32f2f = rgb(211, 47, 47) + val result = ColorMatcher.findClosest(211, 47, 47, palette) + assertEquals(PaletteColor.fromHex("#d32f2f"), result) + } + + @Test + fun pureRedIsMatchedToRedPaletteEntry() { + // rgb(255, 0, 0) should be perceptually closest to #d32f2f among the palette + val result = ColorMatcher.findClosest(255, 0, 0, palette) + assertEquals(PaletteColor.fromHex("#d32f2f"), result) + } + + @Test + fun pureBlueIsMatchedToBluePaletteEntry() { + // rgb(0, 0, 255) should be perceptually closest to #1976d2 among the palette + val result = ColorMatcher.findClosest(0, 0, 255, palette) + assertEquals(PaletteColor.fromHex("#1976d2"), result) + } + + @Test + fun pureGreenIsMatchedToGreenPaletteEntry() { + // rgb(0, 128, 0) should be perceptually closest to #2e7d32 among the palette + val result = ColorMatcher.findClosest(0, 128, 0, palette) + assertEquals(PaletteColor.fromHex("#2e7d32"), result) + } + + @Test + fun yellowIsMatchedToAmberPaletteEntry() { + // rgb(255, 200, 0) should be perceptually closest to #fbc02d among the palette + val result = ColorMatcher.findClosest(255, 200, 0, palette) + assertEquals(PaletteColor.fromHex("#fbc02d"), result) + } + + @Test + fun darkGreyIsMatchedToGreyPaletteEntry() { + // rgb(64, 64, 64) should be perceptually closest to #424242 among the palette + val result = ColorMatcher.findClosest(64, 64, 64, palette) + assertEquals(PaletteColor.fromHex("#424242"), result) + } +} diff --git a/commonskmm/src/desktopMain/java/org/dhis2/mobile/commons/resources/Resources.desktop.kt b/commonskmm/src/desktopMain/java/org/dhis2/mobile/commons/resources/DrawableResourcesProvider.desktop.kt similarity index 100% rename from commonskmm/src/desktopMain/java/org/dhis2/mobile/commons/resources/Resources.desktop.kt rename to commonskmm/src/desktopMain/java/org/dhis2/mobile/commons/resources/DrawableResourcesProvider.desktop.kt diff --git a/compose-table/build.gradle.kts b/compose-table/build.gradle.kts index 7e05c3b3414..215c0241340 100644 --- a/compose-table/build.gradle.kts +++ b/compose-table/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose.compiler) } diff --git a/compose-table/src/main/java/org/dhis2/composetable/ui/TextInput.kt b/compose-table/src/main/java/org/dhis2/composetable/ui/TextInput.kt index 749d73e0856..c9d8d98657c 100644 --- a/compose-table/src/main/java/org/dhis2/composetable/ui/TextInput.kt +++ b/compose-table/src/main/java/org/dhis2/composetable/ui/TextInput.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight @@ -76,8 +75,7 @@ fun TextInput( .background( color = Color.White, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - ).padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 4.dp) - .imePadding(), + ).padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 4.dp), verticalArrangement = spacedBy(8.dp), ) { InputTitle(textInputModel.mainLabel, textInputModel.secondaryLabels) diff --git a/dhis2-mobile-program-rules/build.gradle.kts b/dhis2-mobile-program-rules/build.gradle.kts index 470b75ce8f0..7f2f277ed4c 100644 --- a/dhis2-mobile-program-rules/build.gradle.kts +++ b/dhis2-mobile-program-rules/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") } apply(from = "${project.rootDir}/jacoco/jacoco.gradle.kts") diff --git a/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleConstants.kt b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleConstants.kt new file mode 100644 index 00000000000..43e5c34f347 --- /dev/null +++ b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleConstants.kt @@ -0,0 +1,5 @@ +package org.dhis2.mobileProgramRules + +object RuleConstants { + const val LEGENDSET_LABEL = "legendSet" +} diff --git a/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineExtensions.kt b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineExtensions.kt index 76d31368433..8b03888f94e 100644 --- a/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineExtensions.kt +++ b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineExtensions.kt @@ -1,12 +1,9 @@ package org.dhis2.mobileProgramRules -import kotlinx.datetime.Instant -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone -import kotlinx.datetime.toDeprecatedInstant -import kotlinx.datetime.toInstant +import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime +import org.dhis2.mobileProgramRules.RuleConstants.LEGENDSET_LABEL import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.dataelement.DataElementCollectionRepository @@ -25,6 +22,8 @@ import org.hisp.dhis.rules.models.Rule import org.hisp.dhis.rules.models.RuleAction import org.hisp.dhis.rules.models.RuleAttributeValue import org.hisp.dhis.rules.models.RuleDataValue +import org.hisp.dhis.rules.models.RuleInstant +import org.hisp.dhis.rules.models.RuleLocalDate import org.hisp.dhis.rules.models.RuleValueType import org.hisp.dhis.rules.models.RuleVariable import org.hisp.dhis.rules.models.RuleVariableAttribute @@ -36,22 +35,28 @@ import org.hisp.dhis.rules.models.RuleVariablePreviousEvent import timber.log.Timber import java.util.Date import kotlin.time.ExperimentalTime +import kotlin.time.Instant -fun Date.toRuleEngineInstant() = Instant.fromEpochMilliseconds(this.time) - -fun Date.toRuleEngineLocalDate() = toRuleEngineInstant().toLocalDateTime(TimeZone.currentSystemDefault()).date +@OptIn(ExperimentalTime::class) +private fun Date.toKtInstant() = Instant.fromEpochMilliseconds(this.time) @OptIn(ExperimentalTime::class) -fun Date.toRuleEngineInstantWithNoTime() = - LocalDateTime(toRuleEngineLocalDate(), LocalTime(0, 0, 0, 0)) - .toInstant(TimeZone.currentSystemDefault()) - .toDeprecatedInstant() +private fun Date.toKtLocalDateTime() = toKtInstant().toLocalDateTime(TimeZone.currentSystemDefault()) + +fun Date.toRuleEngineInstant() = RuleInstant(this.time) + +fun Date?.toRuleEngineInstantOrNow() = this?.toRuleEngineInstant() ?: RuleInstant.now() + +fun Date.toRuleEngineLocalDate() = + this.toKtLocalDateTime().let { + RuleLocalDate(it.year, it.month.number, it.day) + } fun List.sortForRuleEngine(): List = sortedWith( compareBy( - { it.eventDate()?.toRuleEngineInstantWithNoTime() }, - { it.created() }, + { it.eventDate()?.toRuleEngineLocalDate() }, + { it.createdAtClient() ?: it.created() }, ).reversed(), ) @@ -133,6 +138,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) ProgramRuleActionType.DISPLAYTEXT -> @@ -144,7 +150,9 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { Pair("location", location() ?: "indicators"), ).also { map -> contentToDisplay?.let { map["content"] = it } + legendSet()?.let { map[LEGENDSET_LABEL] = it.uid() } }, + priority = priority(), ) ProgramRuleActionType.DISPLAYKEYVALUEPAIR -> @@ -156,7 +164,9 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { Pair("location", location()!!), ).also { map -> contentToDisplay?.let { map["content"] = it } + legendSet()?.let { map[LEGENDSET_LABEL] = it.uid() } }, + priority = priority(), ) ProgramRuleActionType.HIDESECTION -> @@ -168,6 +178,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { mutableMapOf( Pair("programStageSection", it.uid()), ), + priority = priority(), ) } ?: RuleAction( "HIDE SECTION RULE IS MISSING PROGRAM STAGE SECTION", @@ -183,6 +194,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { mutableMapOf( Pair("programStage", it.uid()), ), + priority = priority(), ) } ?: RuleAction( "HIDE STAGE RULE IS MISSING PROGRAM STAGE", @@ -213,6 +225,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ) } ?: emptyMap() }, + priority = priority(), ) } } @@ -227,6 +240,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) ProgramRuleActionType.WARNINGONCOMPLETE -> @@ -239,6 +253,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) ProgramRuleActionType.SHOWERROR -> @@ -251,6 +266,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) ProgramRuleActionType.ERRORONCOMPLETE -> @@ -263,6 +279,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) ProgramRuleActionType.CREATEEVENT -> @@ -276,6 +293,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) } ?: RuleAction( "CREATE EVENT RULE IS MISSING PROGRAM STAGE SECTION", @@ -292,6 +310,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) ProgramRuleActionType.HIDEOPTION -> @@ -306,6 +325,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) } ?: RuleAction( "HIDE OPTION RULE IS MISSING OPTION", @@ -324,6 +344,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) } ?: RuleAction( "SHOW OPTION GROUP RULE IS MISSING OPTION GROUP", @@ -342,6 +363,7 @@ fun ProgramRuleAction.toRuleEngineObject(): RuleAction { ).also { map -> contentToDisplay?.let { map["content"] = it } }, + priority = priority(), ) } ?: RuleAction( "HIDE OPTION GROUP RULE IS MISSING OPTION GROUP", diff --git a/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineHelper.kt b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineHelper.kt index f992d0a131d..6777eedd337 100644 --- a/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineHelper.kt +++ b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RuleEngineHelper.kt @@ -84,7 +84,7 @@ class RuleEngineHelper( RuleEngineContext( rules = rules.await(), ruleVariables = ruleVariables.await(), - supplementaryData = supplData.await(), + ruleSupplementaryData = supplData.await(), constantsValues = constants.await(), ), ruleEnrollment = ruleEnrollment.await(), diff --git a/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RulesRepository.kt b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RulesRepository.kt index 8971a8591a4..2274b85a44f 100644 --- a/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RulesRepository.kt +++ b/dhis2-mobile-program-rules/src/main/java/org/dhis2/mobileProgramRules/RulesRepository.kt @@ -1,22 +1,17 @@ package org.dhis2.mobileProgramRules -import android.os.Build import android.text.TextUtils.isEmpty -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import org.dhis2.commons.bindings.enrollment import org.dhis2.commons.bindings.event import org.dhis2.commons.bindings.organisationUnit import org.dhis2.commons.bindings.program import org.dhis2.commons.bindings.programStage import org.hisp.dhis.android.core.D2 -import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.event.EventStatus import org.hisp.dhis.android.core.program.ProgramRule +import org.hisp.dhis.rules.api.RuleSupplementaryData import org.hisp.dhis.rules.models.Rule import org.hisp.dhis.rules.models.RuleAttributeValue import org.hisp.dhis.rules.models.RuleDataValue @@ -33,8 +28,8 @@ class RulesRepository( ) { // ORG UNIT GROUPS // USER ROLES - suspend fun supplementaryData(orgUnitUid: String): Map> { - val supData = HashMap>() + suspend fun supplementaryData(orgUnitUid: String): RuleSupplementaryData { + val ouGroupMap = HashMap>() d2 .organisationUnitModule() @@ -44,18 +39,19 @@ class RulesRepository( .blockingGet() .let { orgUnit -> orgUnit?.organisationUnitGroups()?.map { - it.code()?.let { code -> supData[code] = arrayListOf(orgUnit.uid()) } - supData[it.uid()] = arrayListOf(orgUnit.uid()) + it.code()?.let { code -> ouGroupMap[code] = arrayListOf(orgUnit.uid()) } + ouGroupMap[it.uid()] = arrayListOf(orgUnit.uid()) } } val userRoleUids = d2.userModule().userRoles().blockingGetUids() val userGroupUids = d2.userModule().userGroups().blockingGetUids() - supData["USER_ROLES"] = userRoleUids - supData["USER_GROUPS"] = userGroupUids - supData["android_version"] = listOf(Build.VERSION.SDK_INT.toString()) - return supData + return RuleSupplementaryData( + userGroups = userGroupUids, + userRoles = userRoleUids, + orgUnitGroups = ouGroupMap, + ) } suspend fun rules( @@ -72,9 +68,9 @@ class RulesRepository( ?.programStage() } - return queryRules(programUid).toRuleList().filter { - it.programStage == null || it.programStage == programStage - } + return queryRules(programUid) + .filter { it.programStage() == null || it.programStage()?.uid() == programStage } + .toRuleList() } suspend fun ruleVariables(programUid: String): List = @@ -133,21 +129,9 @@ class RulesRepository( } else { RuleEventStatus.valueOf(event.status()!!.name) }, - eventDate = event.eventDate()!!.toRuleEngineInstant(), - dueDate = - event.dueDate()?.let { - Instant - .fromEpochMilliseconds(it.time) - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - }, - completedDate = - event.completedDate()?.let { - Instant - .fromEpochMilliseconds(it.time) - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - }, + eventDate = event.eventDate()!!.toRuleEngineLocalDate(), + dueDate = event.dueDate()?.toRuleEngineLocalDate(), + completedDate = event.completedDate()?.toRuleEngineLocalDate(), organisationUnit = event.organisationUnit()!!, organisationUnitCode = d2 @@ -157,11 +141,8 @@ class RulesRepository( event.organisationUnit(), ).blockingGet() ?.code(), - createdDate = - event - .created() - ?.let { Instant.fromEpochMilliseconds(it.time) } - ?: Clock.System.now(), + createdDate = event.created().toRuleEngineInstantOrNow(), + createdAtClientDate = event.createdAtClient()?.toRuleEngineInstant(), dataValues = event.trackedEntityDataValues()?.toRuleDataValue() ?: emptyList(), ) @@ -186,8 +167,8 @@ class RulesRepository( .byDeleted() .isFalse .withTrackedEntityDataValues() - .orderByEventDate(RepositoryScope.OrderByDirection.DESC) .blockingGet() + .sortForRuleEngine() } else { d2 .eventModule() @@ -205,8 +186,8 @@ class RulesRepository( .byDeleted() .isFalse .withTrackedEntityDataValues() - .orderByEventDate(RepositoryScope.OrderByDirection.DESC) .blockingGet() + .sortForRuleEngine() .let { list -> val currentEventIndex = list.indexOfFirst { it.uid() == eventToEvaluate.uid() } @@ -269,7 +250,7 @@ class RulesRepository( } else { RuleEventStatus.valueOf(event.status()!!.name) }, - eventDate = event.eventDate()!!.toRuleEngineInstant(), + eventDate = event.eventDate()!!.toRuleEngineLocalDate(), dueDate = event.dueDate()?.toRuleEngineLocalDate(), completedDate = event.completedDate()?.toRuleEngineLocalDate(), organisationUnit = event.organisationUnit()!!, @@ -280,11 +261,8 @@ class RulesRepository( .uid(event.organisationUnit()) .blockingGet() ?.code(), - createdDate = - event - .created() - ?.let { Instant.fromEpochMilliseconds(it.time) } - ?: Clock.System.now(), + createdDate = event.created().toRuleEngineInstantOrNow(), + createdAtClientDate = event.createdAtClient()?.toRuleEngineInstant(), dataValues = event.trackedEntityDataValues()?.toRuleDataValue() ?: emptyList(), ) @@ -415,16 +393,13 @@ class RulesRepository( programStage = event.programStage()!!, programStageName = d2.programStage(event.programStage()!!)?.name()!!, status = RuleEventStatus.valueOf(event.status()!!.name), - eventDate = event.eventDate()!!.toRuleEngineInstant(), + eventDate = event.eventDate()!!.toRuleEngineLocalDate(), dueDate = event.dueDate()?.toRuleEngineLocalDate(), completedDate = event.completedDate()?.toRuleEngineLocalDate(), organisationUnit = event.organisationUnit()!!, organisationUnitCode = d2.organisationUnit(event.organisationUnit()!!)?.code(), - createdDate = - event - .created() - ?.let { Instant.fromEpochMilliseconds(it.time) } - ?: Clock.System.now(), + createdDate = event.created().toRuleEngineInstantOrNow(), + createdAtClientDate = event.createdAtClient()?.toRuleEngineInstant(), dataValues = emptyList(), ) } diff --git a/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineExtensionsTest.kt b/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineExtensionsTest.kt index 05ec4abc3d2..b34420f0829 100644 --- a/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineExtensionsTest.kt +++ b/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineExtensionsTest.kt @@ -52,14 +52,14 @@ class RuleEngineExtensionsTest { val date1 = DateUtils.DATE_FORMAT.parse("2025-09-25T11:43:32.431") val date2 = DateUtils.DATE_FORMAT.parse("2025-09-25T00:00:00.000") - assertEquals(date1.toRuleEngineInstantWithNoTime(), date2.toRuleEngineInstantWithNoTime()) + assertEquals(date1.toRuleEngineLocalDate(), date2.toRuleEngineLocalDate()) val date3 = DateUtils.DATE_FORMAT.parse("2025-09-25T11:43:32.431") val date4 = DateUtils.DATE_FORMAT.parse("2025-09-26T00:00:00.000") assertNotEquals( - date3.toRuleEngineInstantWithNoTime(), - date4.toRuleEngineInstantWithNoTime(), + date3.toRuleEngineLocalDate(), + date4.toRuleEngineLocalDate(), ) } diff --git a/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineHelperTest.kt b/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineHelperTest.kt index 8693a982723..840b8f886bf 100644 --- a/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineHelperTest.kt +++ b/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RuleEngineHelperTest.kt @@ -1,7 +1,9 @@ package org.dhis2.mobileProgramRules import kotlinx.coroutines.test.runTest +import org.dhis2.mobileProgramRules.RuleConstants.LEGENDSET_LABEL import org.hisp.dhis.android.core.program.ProgramRuleActionType +import org.hisp.dhis.rules.api.RuleSupplementaryData import org.hisp.dhis.rules.models.Rule import org.hisp.dhis.rules.models.RuleAction import org.hisp.dhis.rules.models.RuleEnrollment @@ -22,12 +24,13 @@ class RuleEngineHelperTest { data: String, actionType: String, priority: Int?, + values: Map = emptyMap(), ): Rule = Rule( condition = "true", actions = listOf( - RuleAction(data = "'$data'", type = actionType), + RuleAction(data = "'$data'", type = actionType, values = values), ), uid = data, name = "Rule 1", @@ -76,7 +79,7 @@ class RuleEngineHelperTest { whenever(rulesRepository.rules(any(), anyOrNull())) doReturn rules whenever(rulesRepository.ruleVariables(any())) doReturn emptyList() - whenever(rulesRepository.supplementaryData(any())) doReturn emptyMap() + whenever(rulesRepository.supplementaryData(any())) doReturn RuleSupplementaryData() whenever(rulesRepository.constants()) doReturn emptyMap() whenever(rulesRepository.enrollmentEvents(any())) doReturn emptyList() whenever(rulesRepository.getRuleEnrollment(any())) doReturn createEnrollment() @@ -93,4 +96,35 @@ class RuleEngineHelperTest { listOf("Rule1", "Rule4", "Rule2", "Rule3"), ) } + + @Test + fun `Should return values passed to ruleActions`() = + runTest { + val rules = + listOf( + createFeedbackRule( + data = "Rule1", + actionType = ProgramRuleActionType.DISPLAYKEYVALUEPAIR.name, + priority = 1, + values = mapOf(LEGENDSET_LABEL to "test_legend_set"), + ), + ) + + whenever(rulesRepository.rules(any(), anyOrNull())) doReturn rules + whenever(rulesRepository.ruleVariables(any())) doReturn emptyList() + whenever(rulesRepository.supplementaryData(any())) doReturn RuleSupplementaryData() + whenever(rulesRepository.constants()) doReturn emptyMap() + whenever(rulesRepository.enrollmentEvents(any())) doReturn emptyList() + whenever(rulesRepository.getRuleEnrollment(any())) doReturn createEnrollment() + whenever(rulesRepository.queryAttributeValues(any())) doReturn emptyList() + whenever(rulesRepository.enrollmentProgram(any())) doReturn Pair("program", "orgunit") + + val engineHelper = + RuleEngineHelper(EvaluationType.Enrollment("enrollment"), rulesRepository) + + val ruleEffects = engineHelper.evaluate() + + assertEquals(ruleEffects.size, 1) + assertEquals(ruleEffects.first().ruleAction.values, mapOf(LEGENDSET_LABEL to "test_legend_set")) + } } diff --git a/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RulesRepositoryTest.kt b/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RulesRepositoryTest.kt index 6bb1e5c5f4e..295016ffaf0 100644 --- a/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RulesRepositoryTest.kt +++ b/dhis2-mobile-program-rules/src/test/java/org/dhis2/mobileProgramRules/RulesRepositoryTest.kt @@ -38,22 +38,21 @@ class RulesRepositoryTest { runBlocking { repository.supplementaryData("org_unit_test") } - assertTrue(supplData.isNotEmpty()) - assertTrue(supplData.containsKey("USER_ROLES")) - assertTrue(supplData.containsKey("USER_GROUPS")) - assertTrue(supplData.containsKey("org_unit_group_test_code")) - assertTrue(supplData.containsKey("org_unit_group_test")) - assertTrue(supplData["USER_ROLES"]?.contains("UtXToHNI0Cb") ?: false) - assertTrue(supplData["USER_ROLES"]?.contains("oPNOIj7zJ1m") ?: false) - assertTrue(supplData["USER_GROUPS"]?.contains("gVC8vCfNAx8") ?: false) - assertTrue(supplData["USER_GROUPS"]?.contains("Kk12LkEWtXp") ?: false) + assertTrue(supplData.userRoles.isNotEmpty()) + assertTrue(supplData.userGroups.isNotEmpty()) + assertTrue(supplData.orgUnitGroups.containsKey("org_unit_group_test_code")) + assertTrue(supplData.orgUnitGroups.containsKey("org_unit_group_test")) + assertTrue(supplData.userRoles.contains("UtXToHNI0Cb")) + assertTrue(supplData.userRoles.contains("oPNOIj7zJ1m")) + assertTrue(supplData.userGroups.contains("gVC8vCfNAx8")) + assertTrue(supplData.userGroups.contains("Kk12LkEWtXp")) assertTrue( - supplData + supplData.orgUnitGroups .getOrElse("org_unit_group_test") { arrayListOf() } .contains("org_unit_test"), ) assertTrue( - supplData + supplData.orgUnitGroups .getOrElse("org_unit_group_test_code") { arrayListOf() } .contains("org_unit_test"), ) @@ -77,19 +76,21 @@ class RulesRepositoryTest { repository.supplementaryData("org_unit_test") } - assertTrue(supplData.isNotEmpty()) - - assertTrue(supplData.containsKey("USER_ROLES")) - assertTrue(!supplData.containsKey("org_unit_group_test_code")) - assertTrue(supplData.containsKey("org_unit_group_test")) - assertTrue(supplData["USER_ROLES"]?.contains("UtXToHNI0Cb") ?: false) - assertTrue(supplData["USER_ROLES"]?.contains("oPNOIj7zJ1m") ?: false) + assertTrue(supplData.userRoles.isNotEmpty()) + assertTrue(!supplData.orgUnitGroups.containsKey("org_unit_group_test_code")) + assertTrue(supplData.orgUnitGroups.containsKey("org_unit_group_test")) + assertTrue(supplData.userRoles.contains("UtXToHNI0Cb")) + assertTrue(supplData.userRoles.contains("oPNOIj7zJ1m")) assertTrue( - supplData + supplData.orgUnitGroups .getOrElse("org_unit_group_test") { arrayListOf() } .contains("org_unit_test"), ) - assertTrue(supplData.getOrElse("org_unit_group_test_code") { arrayListOf() }.isEmpty()) + assertTrue( + supplData.orgUnitGroups + .getOrElse("org_unit_group_test_code") { arrayListOf() } + .isEmpty(), + ) } private fun getTestUserRoles(): List = arrayListOf("UtXToHNI0Cb", "oPNOIj7zJ1m") diff --git a/dhis2_android_maps/build.gradle.kts b/dhis2_android_maps/build.gradle.kts index c5dc169b4bb..c39c2c4290a 100644 --- a/dhis2_android_maps/build.gradle.kts +++ b/dhis2_android_maps/build.gradle.kts @@ -2,7 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.legacy.kapt) id("com.google.devtools.ksp") alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.serialization) @@ -52,6 +52,10 @@ kotlin { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation(project(":commons")) implementation(project(":commonskmm")) diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/attribution/AttributionManager.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/attribution/AttributionManager.kt index 0caf0c55c9c..26101f2933e 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/attribution/AttributionManager.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/attribution/AttributionManager.kt @@ -22,7 +22,7 @@ class AttributionManager( ) : AttributionDialogManager(context, maplibreMap) { override fun showAttributionDialog(attributionTitles: Array) { val attributions: Array = - currentBaseMapStyle.sources.attribution + currentBaseMapStyle.attribution .split(", ") .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() @@ -34,7 +34,7 @@ class AttributionManager( dialog: DialogInterface?, which: Int, ) { - val selectedAttribution = currentBaseMapStyle.sources.attribution.split(", ")[which] + val selectedAttribution = currentBaseMapStyle.attribution.split(", ")[which] val url = HtmlCompat .fromHtml(selectedAttribution, HtmlCompat.FROM_HTML_MODE_LEGACY) diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/layer/basemaps/BaseMapStyle.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/layer/basemaps/BaseMapStyle.kt index 08fd2af2170..c89a1ed5192 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/layer/basemaps/BaseMapStyle.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/layer/basemaps/BaseMapStyle.kt @@ -1,25 +1,31 @@ package org.dhis2.maps.layer.basemaps -import com.google.gson.annotations.SerializedName - object BaseMapStyleBuilder { fun build( id: String, tileUrls: List, attribution: String, + overlays: List, isDefault: Boolean, ) = BaseMapStyle( version = 8, sources = - StyleSources( - rasterTiles = + mapOf( + "raster-tiles" to RasterTiles( type = "raster", tiles = tileUrls, tileSize = 256, ), - attribution = attribution, - ), + ) + + overlays.associate { overlay -> + overlay.id to + RasterTiles( + type = "raster", + tiles = overlay.tiles, + tileSize = 256, + ) + }, layers = listOf( StyleLayers( @@ -29,10 +35,23 @@ object BaseMapStyleBuilder { minZoom = 0, maxZoom = 22, ), - ), + ) + + overlays.map { overlay -> + StyleLayers( + id = overlay.id, + type = "raster", + source = overlay.id, + minZoom = 0, + maxZoom = 22, + ) + }, id = id, glyphs = DEFAULT_GLYPH_URL, isDefault = isDefault, + attribution = + (listOf(attribution) + overlays.map { overlay -> overlay.attribution }).joinToString( + separator = ", ", + ), ) fun internalBaseMap(): BaseMapStyle = @@ -47,20 +66,23 @@ object BaseMapStyleBuilder { ), attribution = DEFAULT_ATTRIBUTION, isDefault = true, + overlays = emptyList(), ) } data class BaseMapStyle( val version: Int, - val sources: StyleSources, + val sources: Map, val layers: List, val id: String, var glyphs: String, val isDefault: Boolean, + val attribution: String, ) -data class StyleSources( - @SerializedName("raster-tiles") val rasterTiles: RasterTiles, +data class Overlay( + val id: String, + val tiles: List, val attribution: String, ) diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/MapManager.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/MapManager.kt index 16e88425ca3..4ed02238894 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/MapManager.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/MapManager.kt @@ -3,6 +3,7 @@ package org.dhis2.maps.managers import android.annotation.SuppressLint import android.app.Activity import android.os.Bundle +import android.view.Gravity import androidx.core.content.ContextCompat import androidx.core.location.LocationListenerCompat import androidx.lifecycle.Lifecycle @@ -143,6 +144,7 @@ abstract class MapManager( ), ) uiSettings.isLogoEnabled = false + uiSettings.attributionGravity = Gravity.TOP or Gravity.START uiSettings.setAttributionMargins( defaultUiIconLeftMargin, uiSettings.attributionMarginTop, @@ -265,7 +267,9 @@ abstract class MapManager( permissionsManager = PermissionsManager( object : PermissionsListener { - override fun onExplanationNeeded(permissionsToExplain: MutableList?) {} + override fun onExplanationNeeded(permissionsToExplain: MutableList?) { + // no-op + } override fun onPermissionResult(granted: Boolean) { if (granted) { @@ -320,7 +324,9 @@ abstract class MapManager( permissionsManager = PermissionsManager( object : PermissionsListener { - override fun onExplanationNeeded(permissionsToExplain: MutableList?) {} + override fun onExplanationNeeded(permissionsToExplain: MutableList?) { + // no-op + } override fun onPermissionResult(granted: Boolean) { if (granted) { diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/TeiMapManager.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/TeiMapManager.kt index ce49e1481de..838c66bc95f 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/TeiMapManager.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/managers/TeiMapManager.kt @@ -39,6 +39,7 @@ class TeiMapManager( private var fieldFeatureCollections: Map = emptyMap() private var teiFeatureCollections: HashMap? = null private var eventsFeatureCollection: Map? = null + private var hasPerformedInitialAutoSelection = false var mapStyle: MapStyle? = null private var teiImages: HashMap = hashMapOf() var teiFeatureType: FeatureType? = FeatureType.POINT @@ -300,6 +301,47 @@ class TeiMapManager( return wrappedDrawable } + fun autoSelectFirstLayerWithData(): Boolean { + if (hasPerformedInitialAutoSelection) return false + hasPerformedInitialAutoSelection = true + + val firstSource = findFirstSourceWithData() ?: return false + + mapLayerManager.mapLayers.keys.forEach { sourceId -> + mapLayerManager.handleLayer(sourceId, sourceId == firstSource) + } + return true + } + + private fun findFirstSourceWithData(): String? { + listOf(TEIS_SOURCE_ID, ENROLLMENT_SOURCE_ID).forEach { sourceId -> + if (teiFeatureCollections?.get(sourceId)?.features()?.isNotEmpty() == true && + mapLayerManager.mapLayers.containsKey(sourceId) + ) return sourceId + } + eventsFeatureCollection?.forEach { (sourceId, fc) -> + if (fc.features()?.isNotEmpty() == true && mapLayerManager.mapLayers.containsKey(sourceId)) { + return sourceId + } + } + fieldFeatureCollections.forEach { (sourceId, fc) -> + if (fc.features()?.isNotEmpty() == true && mapLayerManager.mapLayers.containsKey(sourceId)) { + return sourceId + } + } + teiFeatureCollections + ?.filterKeys { + it != TEIS_SOURCE_ID && + it != ENROLLMENT_SOURCE_ID && + eventsFeatureCollection?.containsKey(it) == false + }?.forEach { (sourceId, fc) -> + if (fc.features()?.isNotEmpty() == true && mapLayerManager.mapLayers.containsKey(sourceId)) { + return sourceId + } + } + return null + } + override fun findFeature( source: String, propertyName: String, diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/model/RelationshipUiComponentModel.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/model/RelationshipUiComponentModel.kt index fa0a95f7286..ac597eb1f94 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/model/RelationshipUiComponentModel.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/model/RelationshipUiComponentModel.kt @@ -1,7 +1,5 @@ package org.dhis2.maps.model -import org.dhis2.commons.data.CarouselItemModel -import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.common.Geometry data class RelationshipUiComponentModel( @@ -13,11 +11,7 @@ data class RelationshipUiComponentModel( val from: TeiMap, val to: TeiMap, val canBeDeleted: Boolean? = true, -) : CarouselItemModel { - override fun uid(): String = relationshipUid - - fun shouldShowNavigationButton(): Boolean = to.geometry?.type() == FeatureType.POINT -} +) data class TeiMap( val teiUid: String?, diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/usecases/MapStyleConfiguration.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/usecases/MapStyleConfiguration.kt index 344fa8d6dab..24cdbad8a33 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/usecases/MapStyleConfiguration.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/usecases/MapStyleConfiguration.kt @@ -3,9 +3,11 @@ package org.dhis2.maps.usecases import org.dhis2.commons.data.ProgramConfigurationRepository import org.dhis2.maps.layer.basemaps.BaseMapStyle import org.dhis2.maps.layer.basemaps.BaseMapStyleBuilder.build +import org.dhis2.maps.layer.basemaps.Overlay import org.dhis2.maps.model.MapScope import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.map.layer.MapLayerImageryProvider +import org.hisp.dhis.android.core.map.layer.MapLayerPosition const val DEFAULT_FORCED_LOCATION_ACCURACY = -1 @@ -61,6 +63,8 @@ class MapStyleConfiguration( .mapsModule() .mapLayers() .withImageryProviders() + .byMapLayerPosition() + .eq(MapLayerPosition.BASEMAP) .blockingGet() .map { mapLayer -> val id = mapLayer.displayName() @@ -71,7 +75,35 @@ class MapStyleConfiguration( mapLayer.subdomains(), ) val attribution = mapLayer.imageryProviders().mapAttribution() - build(id, tileUrls, attribution, defaultMap == mapLayer.uid()) + + val overlays = + d2 + .mapsModule() + .mapLayers() + .withImageryProviders() + .byLinkedLayerUid() + .eq(mapLayer.uid()) + .byMapLayerPosition() + .eq(MapLayerPosition.OVERLAY) + .blockingGet() + + val basemapOverlays = + overlays.map { overlay -> + val id = overlay.displayName() + val tileUrls = + overlay.imageUrl().mapTileUrls( + overlay.subdomainPlaceholder(), + overlay.subdomains(), + ) + val attribution = overlay.imageryProviders().mapAttribution() + Overlay( + id = id, + tiles = tileUrls, + attribution = attribution, + ) + } + + build(id, tileUrls, attribution, basemapOverlays, defaultMap == mapLayer.uid()) } } diff --git a/dhis2_android_maps/src/main/java/org/dhis2/maps/utils/CoordinateUtils.kt b/dhis2_android_maps/src/main/java/org/dhis2/maps/utils/CoordinateUtils.kt index 97c301ac38f..4d0769d1268 100644 --- a/dhis2_android_maps/src/main/java/org/dhis2/maps/utils/CoordinateUtils.kt +++ b/dhis2_android_maps/src/main/java/org/dhis2/maps/utils/CoordinateUtils.kt @@ -42,9 +42,13 @@ object CoordinateUtils { is Polygon -> { val value = geometry.coordinates().map { polygon -> - polygon.map { point -> + val polygonCoordinates = polygon.map { point -> point.coordinates().map { coordinate -> coordinate.truncate() } + }.toMutableList() + if (polygonCoordinates.first() != polygonCoordinates.last()) { + polygonCoordinates.add(polygonCoordinates.first()) } + polygonCoordinates } Gson().toJson(value) } diff --git a/dhis2_android_maps/src/main/res/values-ar/strings.xml b/dhis2_android_maps/src/main/res/values-ar/strings.xml index ccae4686fd0..46f22b2e4a1 100644 --- a/dhis2_android_maps/src/main/res/values-ar/strings.xml +++ b/dhis2_android_maps/src/main/res/values-ar/strings.xml @@ -8,7 +8,6 @@ Bing داكن Bing جوي تطبيق - إظهار عرض القمر الصناعي إظهار الأحداث إظهار إحداثيات tei إظهار طبقة مخطط الحرارة diff --git a/dhis2_android_maps/src/main/res/values-cs/strings.xml b/dhis2_android_maps/src/main/res/values-cs/strings.xml index d3f4de029f2..28c0d6f34db 100644 --- a/dhis2_android_maps/src/main/res/values-cs/strings.xml +++ b/dhis2_android_maps/src/main/res/values-cs/strings.xml @@ -13,7 +13,6 @@ Azure Aerial Azure Aerial Labels Použít - Zobrazit satelitní pohled Zobrazit události Zobrazit TEI souřadnice Zobrazit vrstvu teplotní mapy diff --git a/dhis2_android_maps/src/main/res/values-es-rES/strings.xml b/dhis2_android_maps/src/main/res/values-es-rES/strings.xml index b28858e97a5..4cdeb71a5ca 100644 --- a/dhis2_android_maps/src/main/res/values-es-rES/strings.xml +++ b/dhis2_android_maps/src/main/res/values-es-rES/strings.xml @@ -3,7 +3,6 @@ Seleccione ubicación Capas del Mapa Aplicar - Mostrar vista satélite Mostrar eventos Mostrar coordenadas de la entidad Mostrar mapa de calor diff --git a/dhis2_android_maps/src/main/res/values-es/strings.xml b/dhis2_android_maps/src/main/res/values-es/strings.xml index 2f5e8d549f8..653d67fcd5b 100644 --- a/dhis2_android_maps/src/main/res/values-es/strings.xml +++ b/dhis2_android_maps/src/main/res/values-es/strings.xml @@ -13,7 +13,6 @@ Azure Vista Aérea Azure Vista Aérea con Etiquetas Aplicar - Mostrar vista satélite Mostrar eventos Mostrar coordenadas de la entidad Mostrar mapa de calor diff --git a/dhis2_android_maps/src/main/res/values-fr/strings.xml b/dhis2_android_maps/src/main/res/values-fr/strings.xml index 6f9b664bd07..43fb4f3467e 100644 --- a/dhis2_android_maps/src/main/res/values-fr/strings.xml +++ b/dhis2_android_maps/src/main/res/values-fr/strings.xml @@ -13,7 +13,6 @@ Azure Vue Aérienne Azure Vue Aérienne avec Étiquettes Appliquer - Afficher la vue satellite Afficher les événements Afficher les coordonnées tei Afficher la couche de carte thermique diff --git a/dhis2_android_maps/src/main/res/values-id/strings.xml b/dhis2_android_maps/src/main/res/values-id/strings.xml index 8c0622a1bf2..858c95d691e 100644 --- a/dhis2_android_maps/src/main/res/values-id/strings.xml +++ b/dhis2_android_maps/src/main/res/values-id/strings.xml @@ -3,7 +3,6 @@ Pilih lokasi Bing Dark Terapkan - Tampilkan citra satelit Tampilkan even Tampilkan koordinat tei Rendah diff --git a/dhis2_android_maps/src/main/res/values-nb/strings.xml b/dhis2_android_maps/src/main/res/values-nb/strings.xml index dcfacaa3265..15209820af7 100644 --- a/dhis2_android_maps/src/main/res/values-nb/strings.xml +++ b/dhis2_android_maps/src/main/res/values-nb/strings.xml @@ -9,7 +9,6 @@ Bing Flyfoto Bing Flyfoto Etiketter Bruk - Vis satelittvisning Vis hendelser Vis tei koordinater Vis varmekartlag diff --git a/dhis2_android_maps/src/main/res/values-nl/strings.xml b/dhis2_android_maps/src/main/res/values-nl/strings.xml index 354543d1e7a..125f4efbee9 100644 --- a/dhis2_android_maps/src/main/res/values-nl/strings.xml +++ b/dhis2_android_maps/src/main/res/values-nl/strings.xml @@ -9,7 +9,6 @@ Bing Aerial Bing Aerial Labels Toepassen - Satellietweergave tonen Toon evenementen Toon tei-coördinaten Warmtebeeld weergeven diff --git a/dhis2_android_maps/src/main/res/values-pt/strings.xml b/dhis2_android_maps/src/main/res/values-pt/strings.xml index 2be6fdd4180..2c0b8f2a127 100644 --- a/dhis2_android_maps/src/main/res/values-pt/strings.xml +++ b/dhis2_android_maps/src/main/res/values-pt/strings.xml @@ -9,7 +9,6 @@ Bing Aerial Etiquetas aéreas do Bing Aplicar - Mostrar vista de satélite Mostrar eventos Mostrar coordenadas tei Mostrar camada do mapa de calor diff --git a/dhis2_android_maps/src/main/res/values-uk/strings.xml b/dhis2_android_maps/src/main/res/values-uk/strings.xml index d4e032c7d59..c54dced58a5 100644 --- a/dhis2_android_maps/src/main/res/values-uk/strings.xml +++ b/dhis2_android_maps/src/main/res/values-uk/strings.xml @@ -8,7 +8,6 @@ Bing - супутник Bing - супутник з мітками Застосувати - Показати як вид із супутника Показати випадки Показати координати об\'єкту, який відстежується Показати шар теплової карти diff --git a/dhis2_android_maps/src/main/res/values-uz-rUZ/strings.xml b/dhis2_android_maps/src/main/res/values-uz-rUZ/strings.xml index a8e4050f2d2..547d1374d8f 100644 --- a/dhis2_android_maps/src/main/res/values-uz-rUZ/strings.xml +++ b/dhis2_android_maps/src/main/res/values-uz-rUZ/strings.xml @@ -9,7 +9,6 @@ Gibrid koʼrinish Belgilar Murojaat qilish - Suniy yoʼldosh koʼrinishini koʼrsatish Hodisa/tadbirlarni koʼrsatish TEI koordinatalarini koʼrsatish Issiqlik xaritasi qatlamini koʼrsatish diff --git a/dhis2_android_maps/src/main/res/values-uz/strings.xml b/dhis2_android_maps/src/main/res/values-uz/strings.xml index 08f61f883ae..af6486c4288 100644 --- a/dhis2_android_maps/src/main/res/values-uz/strings.xml +++ b/dhis2_android_maps/src/main/res/values-uz/strings.xml @@ -9,7 +9,6 @@ Гибрид кўриниш Қатлам белгилари Мурожаат қилиш - Суньий йўлдош кўринишини кўрсатиш Ҳодиса/тадбирларни кўрсатиш TEI координаталарини кўрсатиш Иссиқлик харитаси қатламини кўрсатиш diff --git a/dhis2_android_maps/src/main/res/values-vi/strings.xml b/dhis2_android_maps/src/main/res/values-vi/strings.xml index f87647d0c88..c073b974087 100644 --- a/dhis2_android_maps/src/main/res/values-vi/strings.xml +++ b/dhis2_android_maps/src/main/res/values-vi/strings.xml @@ -13,7 +13,6 @@ Azure Aerial Azure Aerial Labels Áp dụng - Xem Vệ Tinh Hiển thị sự kiện Hiển thị tọa độ đối tượng theo dõi Hiển thị lớp bản đồ nhiệt độ diff --git a/dhis2_android_maps/src/main/res/values-zh-rCN/strings.xml b/dhis2_android_maps/src/main/res/values-zh-rCN/strings.xml index 8cb78453585..f88f85ddd6e 100644 --- a/dhis2_android_maps/src/main/res/values-zh-rCN/strings.xml +++ b/dhis2_android_maps/src/main/res/values-zh-rCN/strings.xml @@ -9,7 +9,6 @@ Bing空间图 Bing空间标示图 应用 - 显示卫星视图 显示事件 显示跟踪实体坐标 显示热力图 diff --git a/dhis2_android_maps/src/main/res/values-zh/strings.xml b/dhis2_android_maps/src/main/res/values-zh/strings.xml index 22b721b4b4a..ca7e398e6b0 100644 --- a/dhis2_android_maps/src/main/res/values-zh/strings.xml +++ b/dhis2_android_maps/src/main/res/values-zh/strings.xml @@ -13,7 +13,6 @@ Azure 卫星图 Azure 卫星带标签图 应用 - 显示卫星视图 显示事件 显示跟踪实体坐标 显示热力图 diff --git a/dhis2_android_maps/src/main/res/values/strings.xml b/dhis2_android_maps/src/main/res/values/strings.xml index 55fa70bc72f..61b2027b56a 100644 --- a/dhis2_android_maps/src/main/res/values/strings.xml +++ b/dhis2_android_maps/src/main/res/values/strings.xml @@ -13,7 +13,6 @@ Azure Aerial Azure Aerial Labels Apply - Show satellite view Show events Show tei coordinates Show heatmap layer diff --git a/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapMocks.kt b/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapMocks.kt new file mode 100644 index 00000000000..3982f0da4db --- /dev/null +++ b/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapMocks.kt @@ -0,0 +1,60 @@ +package org.dhis2.maps.usecases + +import org.hisp.dhis.android.core.D2 +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.map.layer.MapLayer +import org.hisp.dhis.android.core.map.layer.MapLayerCollectionRepository +import org.hisp.dhis.android.core.map.layer.MapLayerPosition +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +private val stringFilterConnector: StringFilterConnector = mock() +private val enumFilterConnector: EnumFilterConnector = + mock() + +fun mockBasemaps( + d2: D2, + mockedBasemap: List, +) { + val mockedMapLayerCollectionRepository: MapLayerCollectionRepository = mock() + + whenever(d2.mapsModule().mapLayers()) doReturn mockedMapLayerCollectionRepository + whenever(mockedMapLayerCollectionRepository.withImageryProviders()) doReturn mockedMapLayerCollectionRepository + whenever( + mockedMapLayerCollectionRepository.byMapLayerPosition(), + ) doReturn enumFilterConnector + whenever( + enumFilterConnector.eq(MapLayerPosition.BASEMAP), + ) doReturn mockedMapLayerCollectionRepository + whenever( + mockedMapLayerCollectionRepository.blockingGet(), + ) doReturn mockedBasemap +} + +fun mockOverlays( + d2: D2, + mockedOverlays: List, +) { + val mockedMapLayerCollectionRepository: MapLayerCollectionRepository = mock() + + whenever(d2.mapsModule().mapLayers()) doReturn mockedMapLayerCollectionRepository + whenever(mockedMapLayerCollectionRepository.withImageryProviders()) doReturn mockedMapLayerCollectionRepository + whenever( + mockedMapLayerCollectionRepository.byLinkedLayerUid(), + ) doReturn stringFilterConnector + whenever( + stringFilterConnector.eq(any()), + ) doReturn mockedMapLayerCollectionRepository + whenever( + mockedMapLayerCollectionRepository.byMapLayerPosition(), + ) doReturn enumFilterConnector + whenever( + enumFilterConnector.eq(MapLayerPosition.OVERLAY), + ) doReturn mockedMapLayerCollectionRepository + whenever( + mockedMapLayerCollectionRepository.blockingGet(), + ) doReturn mockedOverlays +} diff --git a/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapSelectorViewModelTest.kt b/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapSelectorViewModelTest.kt index 43c3d378d43..79385c218f8 100644 --- a/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapSelectorViewModelTest.kt +++ b/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapSelectorViewModelTest.kt @@ -25,7 +25,6 @@ import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.FeatureType import org.hisp.dhis.android.core.map.layer.MapLayer import org.hisp.dhis.android.core.map.layer.MapLayerPosition -import org.hisp.dhis.android.core.settings.SystemSetting import org.hisp.dhis.mobile.ui.designsystem.component.model.LocationItemModel import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -133,20 +132,8 @@ class MapSelectorViewModelTest { @Test fun shouldFetchBaseMaps() = runTest { - whenever( - d2 - .settingModule() - .systemSetting() - .defaultBaseMap() - .blockingGet(), - ) doReturn SystemSetting.builder().value("defaultBaseMapUid").build() - whenever( - d2 - .mapsModule() - .mapLayers() - .withImageryProviders() - .blockingGet(), - ) doReturn + mockBasemaps( + d2, listOf( MapLayer .builder() @@ -160,7 +147,9 @@ class MapSelectorViewModelTest { .external(false) .mapLayerPosition(MapLayerPosition.BASEMAP) .build(), - ) + ), + ) + mockOverlays(d2, emptyList()) val baseMaps = mapSelectorViewModel.fetchMapStyles() assertTrue(baseMaps.isNotEmpty()) } diff --git a/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapStyleConfigurationTest.kt b/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapStyleConfigurationTest.kt index e83d7e29b02..2dd2378f71c 100644 --- a/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapStyleConfigurationTest.kt +++ b/dhis2_android_maps/src/test/java/org/dhis2/maps/usecases/MapStyleConfigurationTest.kt @@ -3,7 +3,10 @@ package org.dhis2.maps.usecases import org.dhis2.commons.data.ProgramConfigurationRepository import org.dhis2.maps.model.MapScope import org.hisp.dhis.android.core.D2 +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.map.layer.MapLayer +import org.hisp.dhis.android.core.map.layer.MapLayerCollectionRepository import org.hisp.dhis.android.core.map.layer.MapLayerImageryProvider import org.hisp.dhis.android.core.map.layer.MapLayerPosition import org.hisp.dhis.android.core.settings.DataSetConfigurationSetting @@ -18,7 +21,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever class MapStyleConfigurationTest { - private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val d2: D2 = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) private val uid = "uid" private val programConfigurationSetting: ProgramConfigurationSetting = mock() private val dataSetConfigurationSetting: DataSetConfigurationSetting = mock() @@ -35,17 +38,14 @@ class MapStyleConfigurationTest { programConfigurationRepository, ) + private val stringFilterConnector: StringFilterConnector = mock() + private val enumFilterConnector: EnumFilterConnector = + mock() + @Test fun shouldFetchMapStyles() { - whenever(d2.mapsModule().mapLayers()) doReturn mock() - whenever(d2.mapsModule().mapLayers().withImageryProviders()) doReturn mock() - whenever( - d2 - .mapsModule() - .mapLayers() - .withImageryProviders() - .blockingGet(), - ) doReturn + mockBasemaps( + d2, listOf( mockMapLayer( displayName = "basemap 1", @@ -68,36 +68,46 @@ class MapStyleConfigurationTest { mockImaginaryProvider("© Carto"), ), ), - ) + ), + ) + + mockOverlays(d2, emptyList()) mapStyleConfiguration.fetchMapStyles().let { result -> assertTrue(result.size == 2) assertTrue( result[0] - .sources.rasterTiles.tiles.size == 2, + .sources.entries + .first() + .value.tiles.size == 2, + ) + assertTrue( + result[0] + .sources.entries + .first() + .value.tiles[0] == "http://a.test.test/x/y/z", + ) + assertTrue(result[0].attribution == "© Maplibre") + assertTrue( + result[0] + .sources.entries + .first() + .value.tiles[1] == "http://b.test.test/x/y/z", ) - assertTrue(result[0].sources.rasterTiles.tiles[0] == "http://a.test.test/x/y/z") - assertTrue(result[0].sources.attribution == "© Maplibre") - assertTrue(result[0].sources.rasterTiles.tiles[1] == "http://b.test.test/x/y/z") assertTrue( result[1] - .sources.rasterTiles.tiles.size == 1, + .sources.entries + .first() + .value.tiles.size == 1, ) - assertTrue(result[1].sources.attribution == "© Maplibre, © Carto") + assertTrue(result[1].attribution == "© Maplibre, © Carto") } } @Test fun shouldFetchCustomMaps() { - whenever(d2.mapsModule().mapLayers()) doReturn mock() - whenever(d2.mapsModule().mapLayers().withImageryProviders()) doReturn mock() - whenever( - d2 - .mapsModule() - .mapLayers() - .withImageryProviders() - .blockingGet(), - ) doReturn + mockBasemaps( + d2, listOf( mockMapLayer( displayName = "basemap 1", @@ -120,22 +130,85 @@ class MapStyleConfigurationTest { mockImaginaryProvider("© Carto"), ), ), - ) + ), + ) + + mockOverlays(d2, emptyList()) mapStyleConfiguration.fetchMapStyles().let { result -> assertTrue(result.size == 2) assertTrue( result[0] - .sources.rasterTiles.tiles.size == 4, + .sources.entries + .first() + .value.tiles.size == 4, + ) + assertTrue( + result[0] + .sources.entries + .first() + .value.tiles[0] == "http://a.test.test/x/y/z", + ) + assertTrue(result[0].attribution == "© Maplibre") + assertTrue( + result[0] + .sources.entries + .first() + .value.tiles[1] == "http://b.test.test/x/y/z", ) - assertTrue(result[0].sources.rasterTiles.tiles[0] == "http://a.test.test/x/y/z") - assertTrue(result[0].sources.attribution == "© Maplibre") - assertTrue(result[0].sources.rasterTiles.tiles[1] == "http://b.test.test/x/y/z") assertTrue( result[1] - .sources.rasterTiles.tiles.size == 4, + .sources.entries + .first() + .value.tiles.size == 4, + ) + assertTrue(result[1].attribution == "© Maplibre, © Carto") + } + } + + @Test + fun shouldFetchBasemapWithOverlay() { + mockBasemaps( + d2, + listOf( + mockMapLayer( + displayName = "basemap 1", + imageUrl = "http://{s}.test.test/x/y/z", + subDomainPlaceHolder = null, + subdomains = null, + imaginaryProviders = + listOf( + mockImaginaryProvider("© Maplibre"), + ), + ), + ), + ) + + mockOverlays( + d2, + listOf( + mockMapLayer( + displayName = "overlay 1", + imageUrl = "http://{s}.overlay.test/x/y/z", + subdomains = null, + subDomainPlaceHolder = null, + imaginaryProviders = + listOf( + mockImaginaryProvider("© Overlay"), + ), + ), + ), + ) + + mapStyleConfiguration.fetchMapStyles().let { result -> + assertTrue(result[0].sources.size == 2) + assertTrue( + result[0] + .sources.keys + .toList()[1] == "overlay 1", ) - assertTrue(result[1].sources.attribution == "© Maplibre, © Carto") + assertTrue(result[0].sources["overlay 1"]?.tiles[0] == "http://a.overlay.test/x/y/z") + assertEquals("© Maplibre, © Overlay", result[0].attribution) } } diff --git a/dhis2_android_maps/src/test/java/org/dhis2/maps/utils/CoordinateUtilsTest.kt b/dhis2_android_maps/src/test/java/org/dhis2/maps/utils/CoordinateUtilsTest.kt new file mode 100644 index 00000000000..20d50923108 --- /dev/null +++ b/dhis2_android_maps/src/test/java/org/dhis2/maps/utils/CoordinateUtilsTest.kt @@ -0,0 +1,71 @@ +package org.dhis2.maps.utils + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.maplibre.geojson.Point +import org.maplibre.geojson.Polygon + +class CoordinateUtilsTest { + + private val gson = Gson() + private val ringType = object : TypeToken>>>() {}.type + + @Test + fun `geometryCoordinates returns null when geometry is null`() { + assertNull(CoordinateUtils.geometryCoordinates(null)) + } + + @Test + fun `geometryCoordinates returns truncated JSON coordinates for Point`() { + val point = Point.fromLngLat(10.123456789, 20.987654321) + + val result = CoordinateUtils.geometryCoordinates(point) + + val coordinates: List = gson.fromJson( + result, + object : TypeToken>() {}.type, + ) + assertEquals(10.12345, coordinates[0], 0.0) + assertEquals(20.98765, coordinates[1], 0.0) + } + + @Test + fun `geometryCoordinates closes open polygon ring when first and last coordinates differ`() { + val pointA = Point.fromLngLat(0.0, 0.0) + val pointB = Point.fromLngLat(1.0, 0.0) + val pointC = Point.fromLngLat(0.0, 1.0) + val polygon = Polygon.fromLngLats(listOf(listOf(pointA, pointB, pointC))) + + val result = CoordinateUtils.geometryCoordinates(polygon) + + val rings: List>> = gson.fromJson(result, ringType) + val ring = rings[0] + assertEquals( + "Ring should start and end with the same coordinates", + ring.first(), + ring.last() + ) + } + + @Test + fun `geometryCoordinates does not duplicate closing coordinate when polygon ring is already closed`() { + val pointA = Point.fromLngLat(0.0, 0.0) + val pointB = Point.fromLngLat(1.0, 0.0) + val pointC = Point.fromLngLat(0.0, 1.0) + val polygon = Polygon.fromLngLats(listOf(listOf(pointA, pointB, pointC, pointA))) + + val result = CoordinateUtils.geometryCoordinates(polygon) + + val rings: List>> = gson.fromJson(result, ringType) + val ring = rings[0] + assertEquals( + "Ring should start and end with the same coordinates", + ring.first(), + ring.last() + ) + assertEquals("Closing coordinate must not be duplicated", 4, ring.size) + } +} diff --git a/dhis_android_analytics/build.gradle.kts b/dhis_android_analytics/build.gradle.kts index 6ebb32d5980..2c659157427 100644 --- a/dhis_android_analytics/build.gradle.kts +++ b/dhis_android_analytics/build.gradle.kts @@ -2,8 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") - kotlin("kapt") + alias(libs.plugins.legacy.kapt) id("com.google.devtools.ksp") alias(libs.plugins.kotlin.compose.compiler) } @@ -53,6 +52,10 @@ kotlin { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) implementation(project(":commons")) diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/Charts.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/Charts.kt index a579db69625..4d77861c022 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/Charts.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/Charts.kt @@ -16,7 +16,7 @@ interface Charts { fun getVisualizationGroups(uid: String?): List - fun geEnrollmentCharts(enrollmentUid: String): List + suspend fun geEnrollmentCharts(enrollmentUid: String): List fun getProgramVisualizations( groupUid: String?, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepository.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepository.kt index fce0f75751f..0aa6577e4b7 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepository.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepository.kt @@ -7,7 +7,7 @@ import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.android.core.settings.AnalyticsDhisVisualizationsGroup interface ChartsRepository { - fun getAnalyticsForEnrollment(enrollmentUid: String): List + suspend fun getAnalyticsForEnrollment(enrollmentUid: String): List fun getProgramVisualization( groupUid: String?, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt index 596c9b30b91..e1ad5bc8641 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ChartsRepositoryImpl.kt @@ -40,7 +40,7 @@ class ChartsRepositoryImpl( ) : ChartsRepository { private val lineListHeaderCache: MutableMap> = mutableMapOf() - override fun getAnalyticsForEnrollment(enrollmentUid: String): List { + override suspend fun getAnalyticsForEnrollment(enrollmentUid: String): List { val enrollment = getEnrollment(enrollmentUid) if (enrollment?.trackedEntityInstance() == null) return emptyList() @@ -403,51 +403,51 @@ class ChartsRepositoryImpl( lineListHeaderCache.putIfAbsent(visualisationUid, trackerLineListResponse.headers) } - private fun getSettingsAnalytics(enrollment: Enrollment): List = - d2 + private suspend fun getSettingsAnalytics(enrollment: Enrollment): List { + val analyticsSettings = d2 .settingModule() .analyticsSetting() .teis() .byProgram() .eq(enrollment.program()) .blockingGet() - .let { analyticsSettings -> - analyticsTeiSettingsToGraph.map( - enrollment.trackedEntityInstance()!!, - analyticsSettings, - analyticsFilterProvider::visualizationPeriod, - analyticsFilterProvider::visualizationOrgUnits, - { dataElementUid -> - d2 - .dataElementModule() - .dataElements() - .uid(dataElementUid) - .blockingGet() - ?.displayFormName() ?: dataElementUid - }, - { indicatorUid -> - d2 - .programModule() - .programIndicators() - .uid(indicatorUid) - .blockingGet() - ?.displayName() ?: indicatorUid - }, - { nutritionGenderData -> - val genderValue = - d2 - .trackedEntityModule() - .trackedEntityAttributeValues() - .value( - nutritionGenderData.attributeUid, - enrollment.trackedEntityInstance()!!, - ).blockingGet() - nutritionGenderData.isFemale(genderValue?.value()) - }, - ) - } ?: emptyList() + if (analyticsSettings.isEmpty()) return emptyList() + return analyticsTeiSettingsToGraph.map( + enrollment.trackedEntityInstance()!!, + analyticsSettings, + analyticsFilterProvider::visualizationPeriod, + analyticsFilterProvider::visualizationOrgUnits, + { dataElementUid -> + d2 + .dataElementModule() + .dataElements() + .uid(dataElementUid) + .blockingGet() + ?.displayFormName() ?: dataElementUid + }, + { indicatorUid -> + d2 + .programModule() + .programIndicators() + .uid(indicatorUid) + .blockingGet() + ?.displayName() ?: indicatorUid + }, + { nutritionGenderData -> + val genderValue = + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .value( + nutritionGenderData.attributeUid, + enrollment.trackedEntityInstance()!!, + ).blockingGet() + nutritionGenderData.isFemale(genderValue?.value()) + }, + ) + } - private fun getDefaultAnalytics(enrollment: Enrollment): List = + private suspend fun getDefaultAnalytics(enrollment: Enrollment): List = getRepeatableProgramStages(enrollment.program()) .map { programStage -> @@ -458,14 +458,14 @@ class ChartsRepositoryImpl( val selectedRelativePeriod = analyticsFilterProvider.visualizationPeriod( enrollment.trackedEntityInstance()!! + - programStage.uid() + - dataElement.uid(), + programStage.uid() + + dataElement.uid(), ) val selectedOrgUnits = analyticsFilterProvider.visualizationOrgUnits( enrollment.trackedEntityInstance()!! + - programStage.uid() + - dataElement.uid(), + programStage.uid() + + dataElement.uid(), ) dataElementToGraph.map( dataElement, @@ -481,14 +481,14 @@ class ChartsRepositoryImpl( val selectedRelativePeriod = analyticsFilterProvider.visualizationPeriod( enrollment.trackedEntityInstance()!! + - programStage.uid() + - programIndicator.uid(), + programStage.uid() + + programIndicator.uid(), ) val selectedOrgUnits = analyticsFilterProvider.visualizationOrgUnits( enrollment.trackedEntityInstance()!! + - programStage.uid() + - programIndicator.uid(), + programStage.uid() + + programIndicator.uid(), ) programIndicatorToGraph.map( programIndicator, @@ -619,7 +619,7 @@ class ChartsRepositoryImpl( analyticsFilterProvider.addColumnFilter( trackerVisualizationUid, columnIndex, - filterValue!!, + filterValue, ) } else { analyticsFilterProvider.removeColumnFilter(trackerVisualizationUid, columnIndex) diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/DhisAnalyticCharts.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/DhisAnalyticCharts.kt index d6c561f5e00..76c7a8d0f1a 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/DhisAnalyticCharts.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/DhisAnalyticCharts.kt @@ -18,7 +18,7 @@ class DhisAnalyticCharts override fun getVisualizationGroups(uid: String?): List = chartsRepository.getVisualizationGroups(uid) - override fun geEnrollmentCharts(enrollmentUid: String): List = chartsRepository.getAnalyticsForEnrollment(enrollmentUid) + override suspend fun geEnrollmentCharts(enrollmentUid: String): List = chartsRepository.getAnalyticsForEnrollment(enrollmentUid) override fun getProgramVisualizations( groupUid: String?, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/AnalyticsBindings.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/AnalyticsBindings.kt index 60bece0c4b5..d2616d6f567 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/AnalyticsBindings.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/AnalyticsBindings.kt @@ -35,10 +35,11 @@ fun PeriodType.datePattern(): String = when (this) { PeriodType.Daily, PeriodType.Weekly, + PeriodType.WeeklyWednesday, + PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, - PeriodType.WeeklyThursday, - PeriodType.WeeklyWednesday, PeriodType.BiWeekly, PeriodType.Monthly, PeriodType.BiMonthly, @@ -52,8 +53,11 @@ fun PeriodType.datePattern(): String = } PeriodType.Yearly, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, PeriodType.FinancialNov, -> { diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/DateToPosition.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/DateToPosition.kt index 60f5089c8f5..301a3c84bcc 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/DateToPosition.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/bindings/DateToPosition.kt @@ -26,10 +26,11 @@ class DateToPosition { when (eventPeriodType) { PeriodType.Daily, PeriodType.Weekly, + PeriodType.WeeklyWednesday, + PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, - PeriodType.WeeklyThursday, - PeriodType.WeeklyWednesday, PeriodType.BiWeekly, PeriodType.Monthly, PeriodType.BiMonthly, @@ -48,8 +49,11 @@ class DateToPosition { monthDiff.toFloat() + (dayInMonth.toFloat() - 1f) / daysInMonth.toFloat() } PeriodType.Yearly, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, PeriodType.FinancialNov, -> { diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/data/Graph.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/data/Graph.kt index cfa972b8430..d069766af30 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/data/Graph.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/data/Graph.kt @@ -48,10 +48,11 @@ data class Graph( return when (eventPeriodType) { PeriodType.Daily, PeriodType.Weekly, + PeriodType.WeeklyWednesday, + PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, - PeriodType.WeeklyThursday, - PeriodType.WeeklyWednesday, PeriodType.BiWeekly, PeriodType.Monthly, PeriodType.BiMonthly, @@ -65,8 +66,11 @@ data class Graph( } PeriodType.Yearly, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, PeriodType.FinancialNov, -> { @@ -126,10 +130,11 @@ data class Graph( when (eventPeriodType) { PeriodType.Daily, PeriodType.Weekly, + PeriodType.WeeklyWednesday, + PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, - PeriodType.WeeklyThursday, - PeriodType.WeeklyWednesday, PeriodType.BiWeekly, PeriodType.Monthly, PeriodType.BiMonthly, @@ -144,8 +149,11 @@ data class Graph( } PeriodType.Yearly, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, PeriodType.FinancialNov, -> { @@ -206,25 +214,31 @@ data class GraphPoint( ) { fun numericValue() = when (fieldValue) { - is GraphFieldValue.Numeric -> fieldValue.value is GraphFieldValue.Text -> 0f + is GraphFieldValue.Decimal -> fieldValue.value + is GraphFieldValue.Integer -> fieldValue.value.toFloat() } fun textValue() = when (fieldValue) { - is GraphFieldValue.Numeric -> fieldValue.value.toString() is GraphFieldValue.Text -> fieldValue.value + is GraphFieldValue.Decimal -> fieldValue.value.toString() + is GraphFieldValue.Integer -> fieldValue.value.toString() } } sealed class GraphFieldValue { - data class Numeric( + data class Decimal( val value: Float, ) : GraphFieldValue() data class Text( val value: String, ) : GraphFieldValue() + + data class Integer( + val value: Int, + ) : GraphFieldValue() } fun Graph.toChartBuilder(): Chart.ChartBuilder = Chart.ChartBuilder() diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt index 3fc36dd9474..9b365ed6e9b 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsInjector.kt @@ -8,6 +8,7 @@ import dhis2.org.analytics.charts.Charts import dhis2.org.analytics.charts.ChartsRepository import dhis2.org.analytics.charts.ChartsRepositoryImpl import dhis2.org.analytics.charts.DhisAnalyticCharts +import dhis2.org.analytics.charts.domain.GetEnrollmentAnalyticsUseCase import dhis2.org.analytics.charts.data.AnalyticResources import dhis2.org.analytics.charts.mappers.AnalyticDataElementToDataElementData import dhis2.org.analytics.charts.mappers.AnalyticIndicatorToIndicatorData @@ -36,6 +37,7 @@ import javax.inject.Singleton ) interface ChartsComponent { fun charts(): Charts + fun getEnrollmentAnalyticsUseCase(): GetEnrollmentAnalyticsUseCase } @Module @@ -138,4 +140,10 @@ class ChartsModule { @Provides internal fun bindStorageFeatureImpl(analyticsCharts: DhisAnalyticCharts): Charts = analyticsCharts + + @Provides + internal fun provideGetEnrollmentAnalyticsUseCase( + chartsRepository: ChartsRepository, + dispatcherProvider: DispatcherProvider, + ): GetEnrollmentAnalyticsUseCase = GetEnrollmentAnalyticsUseCase(chartsRepository, dispatcherProvider) } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsModule.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsModule.kt new file mode 100644 index 00000000000..d57cbeb7018 --- /dev/null +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/di/ChartsModule.kt @@ -0,0 +1,114 @@ +package dhis2.org.analytics.charts.di + +import dhis2.org.analytics.charts.Charts +import dhis2.org.analytics.charts.ChartsRepository +import dhis2.org.analytics.charts.ChartsRepositoryImpl +import dhis2.org.analytics.charts.DhisAnalyticCharts +import dhis2.org.analytics.charts.data.AnalyticResources +import dhis2.org.analytics.charts.mappers.AnalyticDataElementToDataElementData +import dhis2.org.analytics.charts.mappers.AnalyticIndicatorToIndicatorData +import dhis2.org.analytics.charts.mappers.AnalyticTeiSettingsToSettingsAnalyticsModel +import dhis2.org.analytics.charts.mappers.AnalyticsTeiSettingsToGraph +import dhis2.org.analytics.charts.mappers.DataElementToGraph +import dhis2.org.analytics.charts.mappers.ProgramIndicatorToGraph +import dhis2.org.analytics.charts.mappers.VisualizationToGraph +import dhis2.org.analytics.charts.providers.AnalyticsFilterProvider +import dhis2.org.analytics.charts.providers.ChartCoordinatesProvider +import dhis2.org.analytics.charts.providers.ChartCoordinatesProviderImpl +import dhis2.org.analytics.charts.providers.NutritionDataProvider +import dhis2.org.analytics.charts.providers.PeriodStepProvider +import dhis2.org.analytics.charts.providers.PeriodStepProviderImpl +import dhis2.org.analytics.charts.providers.RuleEngineNutritionDataProviderImpl +import kotlinx.coroutines.Dispatchers +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +val chartsModule = module { + + factory { + PeriodStepProviderImpl( + d2 = get(), + dispatcherProvider = object : DispatcherProvider { + override fun io() = Dispatchers.IO + + override fun computation() = Dispatchers.Default + + override fun ui() = Dispatchers.Main + + } + ) + } + + factory { + ChartCoordinatesProviderImpl( + d2 = get(), + periodStepProvider = get(), + resourceManager = get() + ) + } + + factory { + VisualizationToGraph( + periodStepProvider = get(), + chartCoordinatesProvider = get() + ) + } + + factoryOf(::AnalyticDataElementToDataElementData) + factoryOf(::AnalyticIndicatorToIndicatorData) + factory { + AnalyticTeiSettingsToSettingsAnalyticsModel( + analyticDataElementMapper = get(), + analyticIndicatorMapper = get() + ) + } + + factory { + RuleEngineNutritionDataProviderImpl() + } + + factory { + AnalyticsTeiSettingsToGraph( + analyticsSettingsMapper = get(), + nutritionDataProvider = get(), + periodStepProvider = get(), + chartCoordinatesProvider = get() + ) + } + + factory { + DataElementToGraph( + periodStepProvider = get(), + chartCoordinatesProvider = get() + ) + } + + factory { + ProgramIndicatorToGraph( + periodStepProvider = get(), + chartCoordinatesProvider = get() + ) + } + + factoryOf(::AnalyticResources) + + factoryOf(::AnalyticsFilterProvider) + + factory { + ChartsRepositoryImpl( + d2 = get(), + visualizationToGraph = get(), + analyticsTeiSettingsToGraph = get(), + dataElementToGraph = get(), + programIndicatorToGraph = get(), + analyticsResources = get(), + analyticsFilterProvider = get() + ) + } + factory { + DhisAnalyticCharts( + chartsRepository = get() + ) + } +} \ No newline at end of file diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/domain/GetEnrollmentAnalyticsUseCase.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/domain/GetEnrollmentAnalyticsUseCase.kt new file mode 100644 index 00000000000..bff08fe2811 --- /dev/null +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/domain/GetEnrollmentAnalyticsUseCase.kt @@ -0,0 +1,20 @@ +package dhis2.org.analytics.charts.domain + +import dhis2.org.analytics.charts.ChartsRepository +import dhis2.org.analytics.charts.data.Graph +import kotlinx.coroutines.withContext +import org.dhis2.commons.viewmodel.DispatcherProvider + +class GetEnrollmentAnalyticsUseCase( + private val chartsRepository: ChartsRepository, + private val dispatchers: DispatcherProvider, +) { + suspend operator fun invoke(enrollmentUid: String): Result> = + withContext(dispatchers.io()) { + try { + Result.success(chartsRepository.getAnalyticsForEnrollment(enrollmentUid)) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/AnalyticsTeiSettingsToGraph.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/AnalyticsTeiSettingsToGraph.kt index 3b25e7a3fcd..cdd8ffd7285 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/AnalyticsTeiSettingsToGraph.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/AnalyticsTeiSettingsToGraph.kt @@ -20,16 +20,16 @@ class AnalyticsTeiSettingsToGraph( private val periodStepProvider: PeriodStepProvider, private val chartCoordinatesProvider: ChartCoordinatesProvider, ) { - fun map( + suspend fun map( teiUid: String, - analytycsTeiSettings: List, + analyticsTeiSettings: List, selectedRelativePeriodProvider: (String) -> List?, selectedOrgUnitProvider: (String) -> List?, dataElementNameProvider: (String) -> String, indicatorNameProvider: (String) -> String, teiGenderProvider: (NutritionGenderData) -> Boolean, ): List = - analytycsTeiSettings.map { analyticsTeiSettings -> + analyticsTeiSettings.map { analyticsTeiSettings -> val analyticsSetting = analyticsSettingsMapper.map(analyticsTeiSettings) val selectedRelativePeriod = selectedRelativePeriodProvider(analyticsTeiSettings.uid()) val selectedOrgUnits = selectedOrgUnitProvider(analyticsTeiSettings.uid()) diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt index 14f5f6ae28a..ecd0098997e 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DataElementToGraph.kt @@ -13,7 +13,7 @@ class DataElementToGraph( private val periodStepProvider: PeriodStepProvider, private val chartCoordinatesProvider: ChartCoordinatesProvider, ) { - fun map( + suspend fun map( dataElement: DataElement, stageUid: String, teiUid: String, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DimensionalResponseToPieData.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DimensionalResponseToPieData.kt index e7275532cb9..ec789bc577b 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DimensionalResponseToPieData.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/DimensionalResponseToPieData.kt @@ -19,7 +19,9 @@ class DimensionalResponseToPieData { .map { entry -> GraphPoint( eventDate = GregorianCalendar(2021, 0, 1).time, - fieldValue = GraphFieldValue.Numeric(entry.value.sumByDouble { it.value?.toDouble() ?: 0.0 }.toFloat()), + fieldValue = GraphFieldValue.Decimal(entry.value.sumOf { + it.value?.toDouble() ?: 0.0 + }.toFloat()), legend = dimensionalResponse.metadata[entry.key]?.displayName, ) } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToBarData.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToBarData.kt index 60cedf8f36e..2c49a231409 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToBarData.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToBarData.kt @@ -57,10 +57,11 @@ class GraphToBarData { PeriodType.Daily -> DAILY_BAR_WIDTH PeriodType.Weekly, + PeriodType.WeeklyWednesday, + PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, - PeriodType.WeeklyThursday, - PeriodType.WeeklyWednesday, -> WEEKLY_BAR_WIDTH PeriodType.BiWeekly, @@ -77,8 +78,11 @@ class GraphToBarData { SIX_MONTHLY_BAR_WIDTH } PeriodType.Yearly, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, PeriodType.FinancialNov, -> { diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToTable.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToTable.kt index 72abeccb105..2beb24c5974 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToTable.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/GraphToTable.kt @@ -199,7 +199,7 @@ class GraphToTable { color = TextColor.OnSurfaceLight, fontSize = 10.sp, fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(org.dhis2.ui.R.font.roboto_regular)), + fontFamily = FontFamily(Font(R.font.roboto_regular)), lineHeight = 16.sp, letterSpacing = (0.4).sp, textAlign = TextAlign.End, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt index 7078afdd9f4..fef93ad9439 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/mappers/ProgramIndicatorToGraph.kt @@ -13,7 +13,7 @@ class ProgramIndicatorToGraph( private val periodStepProvider: PeriodStepProvider, private val chartCoordinatesProvider: ChartCoordinatesProvider, ) { - fun map( + suspend fun map( programIndicator: ProgramIndicator, stageUid: String, teiUid: String, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProvider.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProvider.kt index 798f54f3c3c..d90cfb42159 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProvider.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProvider.kt @@ -6,7 +6,7 @@ import org.hisp.dhis.android.core.analytics.aggregated.MetadataItem import org.hisp.dhis.android.core.common.RelativePeriod interface ChartCoordinatesProvider { - fun dataElementCoordinates( + suspend fun dataElementCoordinates( stageUid: String, teiUid: String, dataElementUid: String, @@ -15,7 +15,7 @@ interface ChartCoordinatesProvider { isDefault: Boolean = false, ): List - fun indicatorCoordinates( + suspend fun indicatorCoordinates( stageUid: String, teiUid: String, indicatorUid: String, @@ -24,7 +24,7 @@ interface ChartCoordinatesProvider { isDefault: Boolean = false, ): List - fun nutritionCoordinates( + suspend fun nutritionCoordinates( stageUid: String, teiUid: String, zScoreValueContainerUid: String, @@ -35,7 +35,7 @@ interface ChartCoordinatesProvider { selectedOrgUnits: List?, ): List - fun pieChartCoordinates( + suspend fun pieChartCoordinates( stageUid: String, teiUid: String, dataElementUid: String, diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImpl.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImpl.kt index 6862fb6c474..3c59db6dcec 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImpl.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImpl.kt @@ -21,7 +21,7 @@ class ChartCoordinatesProviderImpl( val periodStepProvider: PeriodStepProvider, val resourceManager: ResourceManager, ) : ChartCoordinatesProvider { - override fun dataElementCoordinates( + override suspend fun dataElementCoordinates( stageUid: String, teiUid: String, dataElementUid: String, @@ -65,18 +65,18 @@ class ChartCoordinatesProviderImpl( } else { periodStepProvider .getPeriodDiff( - initialPeriod!!, + initialPeriod, lineListResponse.period, ).toFloat() }, - fieldValue = GraphFieldValue.Numeric(value.toFloatOrNull() ?: 0f), + fieldValue = formatToNumericFieldValue(value), legendValue = createLegendValue(legend), ) } } } - override fun indicatorCoordinates( + override suspend fun indicatorCoordinates( stageUid: String, teiUid: String, indicatorUid: String, @@ -112,7 +112,7 @@ class ChartCoordinatesProviderImpl( .value ?.toFloat() ?: Float.NaN ).isNaN() - } catch (e: java.lang.Exception) { + } catch (_: java.lang.Exception) { false } }.mapNotNull { lineListResponse -> @@ -122,7 +122,6 @@ class ChartCoordinatesProviderImpl( if (initialPeriod == null) initialPeriod = lineListResponse.period val legend = getLegend(lineListResponseValue.legend) - GraphPoint( eventDate = formattedDate(lineListResponse.date), position = @@ -131,18 +130,18 @@ class ChartCoordinatesProviderImpl( } else { periodStepProvider .getPeriodDiff( - initialPeriod!!, + initialPeriod, lineListResponse.period, ).toFloat() }, - fieldValue = GraphFieldValue.Numeric(value.toFloat()), + fieldValue = formatToNumericFieldValue(value), legendValue = createLegendValue(legend), ) } } } - override fun nutritionCoordinates( + override suspend fun nutritionCoordinates( stageUid: String, teiUid: String, zScoreValueContainerUid: String, @@ -199,13 +198,13 @@ class ChartCoordinatesProviderImpl( GraphPoint( eventDate = formattedDate(lineListResponse.date), position = xAxisValue.toFloat(), - fieldValue = GraphFieldValue.Numeric(zScoreValue.toFloat()), + fieldValue = formatToNumericFieldValue(zScoreValue), ) } } } - override fun pieChartCoordinates( + override suspend fun pieChartCoordinates( stageUid: String, teiUid: String, dataElementUid: String, @@ -235,7 +234,7 @@ class ChartCoordinatesProviderImpl( return eventList.groupBy { it.values.first().value }.mapNotNull { GraphPoint( eventDate = formattedDate(it.value.first().date), - fieldValue = GraphFieldValue.Numeric(it.value.size.toFloat()), + fieldValue = GraphFieldValue.Integer(it.value.size), legend = it.key, ) } @@ -283,7 +282,7 @@ class ChartCoordinatesProviderImpl( GraphPoint( eventDate = GregorianCalendar(2021, 0, 1).time, position = position.toFloat(), - fieldValue = GraphFieldValue.Numeric(gridResponseValue.value!!.toFloat()), + fieldValue = formatToNumericFieldValue(gridResponseValue.value), legend = columnLegend, legendValue = createLegendValue(legend?.item), ) @@ -294,11 +293,11 @@ class ChartCoordinatesProviderImpl( val formattedDateString = SimpleDateFormat("yyyy-MM-dd").format(date) val formattedDate = SimpleDateFormat("yyyy-MM-dd").parse(formattedDateString) formattedDate ?: date - } catch (e: Exception) { + } catch (_: Exception) { date } - private fun getLegend(legendUid: String?): Legend? = + private suspend fun getLegend(legendUid: String?): Legend? = legendUid?.let { d2 .legendSetModule() @@ -316,4 +315,11 @@ class ChartCoordinatesProviderImpl( it.displayName(), ) } + + private fun formatToNumericFieldValue(value: String?): GraphFieldValue = + if (value?.contains(".") == true || value?.contains(",") == true) { + GraphFieldValue.Decimal(value.toFloatOrNull() ?: 0f) + } else { + GraphFieldValue.Integer(value?.toIntOrNull() ?: 0) + } } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/PeriodStepProviderImpl.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/PeriodStepProviderImpl.kt index a67a8637eb3..8a603334dd4 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/PeriodStepProviderImpl.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/PeriodStepProviderImpl.kt @@ -77,6 +77,7 @@ class PeriodStepProviderImpl( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, -> { @@ -111,9 +112,13 @@ class PeriodStepProviderImpl( PeriodType.QuarterlyNov, PeriodType.SixMonthly, PeriodType.SixMonthlyApril, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, + PeriodType.FinancialNov, -> formattedDate = periodString.format( @@ -173,6 +178,7 @@ class PeriodStepProviderImpl( PeriodType.Weekly, PeriodType.WeeklyWednesday, PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, -> @@ -223,8 +229,11 @@ class PeriodStepProviderImpl( ).months / 6 PeriodType.Yearly, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, PeriodType.FinancialNov, -> diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/RuleEngineNutritionDataProviderImpl.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/RuleEngineNutritionDataProviderImpl.kt index 67fe13d7975..b9a61f05911 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/RuleEngineNutritionDataProviderImpl.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/providers/RuleEngineNutritionDataProviderImpl.kt @@ -40,7 +40,7 @@ class RuleEngineNutritionDataProviderImpl : NutritionDataProvider { GraphPoint( eventDate = GregorianCalendar(2021, 0, 1).time, position = parameter, - fieldValue = GraphFieldValue.Numeric(values[dataIndex]), + fieldValue = GraphFieldValue.Decimal(values[dataIndex]), ), ) } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsFragment.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsFragment.kt index a1cf66320a1..a15accfc803 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsFragment.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsFragment.kt @@ -8,6 +8,9 @@ import android.view.ViewGroup import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.github.mikephil.charting.utils.Utils import dhis2.org.R import dhis2.org.analytics.charts.data.AnalyticGroup @@ -18,6 +21,7 @@ import dhis2.org.analytics.charts.ui.di.AnalyticsFragmentModule import dhis2.org.analytics.charts.ui.dialog.SearchColumnDialog import dhis2.org.databinding.AnalyticsGroupBinding import dhis2.org.databinding.AnalyticsItemBinding +import kotlinx.coroutines.launch import org.dhis2.commons.bindings.clipWithRoundedCorners import org.dhis2.commons.bindings.scrollToPosition import org.dhis2.commons.dialogs.AlertBottomDialog @@ -220,47 +224,57 @@ class GroupAnalyticsFragment : Fragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - groupViewModel.chipItems.observe(viewLifecycleOwner) { chipResult -> - when { - chipResult.isSuccess -> { - val chips = chipResult.getOrDefault(emptyList()) - if (chips.isEmpty() || chips.size < MIN_SIZE_TO_SHOW) { - binding?.analyticChipGroup?.visibility = View.GONE - } else { - binding?.analyticChipGroup?.visibility = View.VISIBLE - disableToolbarElevation?.invoke() - addChips(chips) - } - } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + groupViewModel.chipItems.collect { chipResult -> + chipResult ?: return@collect + when { + chipResult.isSuccess -> { + val chips = chipResult.getOrDefault(emptyList()) + if (chips.isEmpty() || chips.size < MIN_SIZE_TO_SHOW) { + binding?.analyticChipGroup?.visibility = View.GONE + } else { + binding?.analyticChipGroup?.visibility = View.VISIBLE + disableToolbarElevation?.invoke() + addChips(chips) + } + } - chipResult.isFailure -> { - binding?.progressLayout?.visibility = View.GONE - binding?.emptyAnalytics?.apply { - visibility = View.VISIBLE - text = getString(R.string.visualization_groups_failure) + chipResult.isFailure -> { + binding?.progressLayout?.visibility = View.GONE + binding?.emptyAnalytics?.apply { + visibility = View.VISIBLE + text = getString(R.string.visualization_groups_failure) + } + } + } + startPostponedEnterTransition() } } - } - startPostponedEnterTransition() - } - groupViewModel.analytics.observe(viewLifecycleOwner) { analytics -> - try { - when { - analytics.isSuccess -> - adapter.submitList(analytics.getOrDefault(emptyList())) { - binding?.progressLayout?.visibility = View.GONE - } + launch { + groupViewModel.analytics.collect { analytics -> + analytics ?: return@collect + try { + when { + analytics.isSuccess -> + adapter.submitList(analytics.getOrDefault(emptyList())) { + binding?.progressLayout?.visibility = View.GONE + } - analytics.isFailure -> { - binding?.progressLayout?.visibility = View.GONE - binding?.emptyAnalytics?.apply { - visibility = View.VISIBLE - text = getString(R.string.visualization_failure) + analytics.isFailure -> { + binding?.progressLayout?.visibility = View.GONE + binding?.emptyAnalytics?.apply { + visibility = View.VISIBLE + text = getString(R.string.visualization_failure) + } + } + } + } finally { + AnalyticsCountingIdlingResource.decrement() } } } - } finally { - AnalyticsCountingIdlingResource.decrement() } } } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModel.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModel.kt index 0fa595dcd15..fe1609a3b88 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModel.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModel.kt @@ -1,19 +1,20 @@ package dhis2.org.analytics.charts.ui -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dhis2.org.analytics.charts.Charts import dhis2.org.analytics.charts.data.AnalyticGroup +import dhis2.org.analytics.charts.domain.GetEnrollmentAnalyticsUseCase import dhis2.org.analytics.charts.idling.AnalyticsCountingIdlingResource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.dhis2.commons.matomo.Actions import org.dhis2.commons.matomo.Categories import org.dhis2.commons.matomo.Labels import org.dhis2.commons.matomo.MatomoAnalyticsController +import org.dhis2.commons.viewmodel.DispatcherProvider import org.hisp.dhis.android.core.common.RelativePeriod import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import timber.log.Timber @@ -25,11 +26,13 @@ class GroupAnalyticsViewModel( private val uid: String?, private val charts: Charts, private val matomoAnalyticsController: MatomoAnalyticsController, + private val getEnrollmentAnalyticsUseCase: GetEnrollmentAnalyticsUseCase, + private val dispatchers: DispatcherProvider, ) : ViewModel() { - private val _chipItems = MutableLiveData>>() - val chipItems: LiveData>> = _chipItems - private val _analytics = MutableLiveData>>() - val analytics: LiveData>> = _analytics + private val _chipItems = MutableStateFlow>?>(null) + val chipItems: StateFlow>?> = _chipItems.asStateFlow() + private val _analytics = MutableStateFlow>?>(null) + val analytics: StateFlow>?> = _analytics.asStateFlow() private var currentGroup: String? = null init { @@ -44,19 +47,20 @@ class GroupAnalyticsViewModel( } private fun fetchAnalyticsGroup(onSuccess: () -> Unit) { - viewModelScope.launch { - val result = - async(context = Dispatchers.IO) { - charts.getVisualizationGroups(uid).map { - AnalyticGroup(it.id(), it.name()) - } - } + AnalyticsCountingIdlingResource.increment() + viewModelScope.launch(dispatchers.io()) { try { - _chipItems.value = Result.success(result.await()) + val groups = charts.getVisualizationGroups(uid).map { + AnalyticGroup(it.id(), it.name()) + } + _chipItems.value = Result.success(groups) onSuccess() } catch (e: Exception) { _chipItems.value = Result.failure(e) } + finally { + AnalyticsCountingIdlingResource.decrement() + } } } @@ -138,50 +142,55 @@ class GroupAnalyticsViewModel( fun fetchAnalytics(groupUid: String?) { AnalyticsCountingIdlingResource.increment() currentGroup = groupUid - viewModelScope.launch { - val result = - async(context = Dispatchers.IO) { - when (mode) { - AnalyticMode.ENROLLMENT -> - uid?.let { - charts - .geEnrollmentCharts(uid) - .map { ChartModel(it) } - } ?: emptyList() - - AnalyticMode.TRACKER_PROGRAM -> - uid?.let { - charts - .getProgramVisualizations(groupUid, uid) - .map { ChartModel(it) } - } ?: emptyList() - - AnalyticMode.EVENT_PROGRAM -> - uid?.let { - charts - .getProgramVisualizations(groupUid, uid) - .map { ChartModel(it) } - } ?: emptyList() - - AnalyticMode.HOME -> - charts - .getHomeVisualizations(groupUid) - .map { ChartModel(it) } - - AnalyticMode.DATASET -> - uid?.let { - charts - .getDataSetVisualizations(groupUid, uid) - .map { ChartModel(it) } - } ?: emptyList() + when (mode) { + AnalyticMode.ENROLLMENT -> + viewModelScope.launch { + uid?.let { + getEnrollmentAnalyticsUseCase(uid) + .onSuccess { graphs -> + _analytics.value = Result.success(graphs.map { ChartModel(it) }) + }.onFailure { e -> + Timber.e(e) + _analytics.value = Result.failure(e) + } + } ?: run { + _analytics.value = Result.success(emptyList()) + } + } + + else -> + viewModelScope.launch(dispatchers.io()) { + try { + val result = when (mode) { + AnalyticMode.TRACKER_PROGRAM -> + uid?.let { + charts.getProgramVisualizations(groupUid, uid) + .map { ChartModel(it) } + } ?: emptyList() + + AnalyticMode.EVENT_PROGRAM -> + uid?.let { + charts.getProgramVisualizations(groupUid, uid) + .map { ChartModel(it) } + } ?: emptyList() + + AnalyticMode.HOME -> + charts.getHomeVisualizations(groupUid).map { ChartModel(it) } + + AnalyticMode.DATASET -> + uid?.let { + charts.getDataSetVisualizations(groupUid, uid) + .map { ChartModel(it) } + } ?: emptyList() + + AnalyticMode.ENROLLMENT -> emptyList() + } + _analytics.value = Result.success(result) + } catch (e: Exception) { + Timber.e(e) + _analytics.value = Result.failure(e) } } - try { - _analytics.value = Result.success(result.await()) - } catch (e: Exception) { - Timber.e(e) - _analytics.value = Result.failure(e) - } } } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModelFactory.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModelFactory.kt index 8ff1cd01259..11f9157b0e6 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModelFactory.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/GroupAnalyticsViewModelFactory.kt @@ -3,13 +3,17 @@ package dhis2.org.analytics.charts.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import dhis2.org.analytics.charts.Charts +import dhis2.org.analytics.charts.domain.GetEnrollmentAnalyticsUseCase import org.dhis2.commons.matomo.MatomoAnalyticsController +import org.dhis2.commons.viewmodel.DispatcherProvider class GroupAnalyticsViewModelFactory( private val mode: AnalyticMode, private val uid: String?, private val charts: Charts, private val matomoAnalyticsController: MatomoAnalyticsController, + private val getEnrollmentAnalyticsUseCase: GetEnrollmentAnalyticsUseCase, + private val dispatchers: DispatcherProvider, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = GroupAnalyticsViewModel( @@ -17,5 +21,7 @@ class GroupAnalyticsViewModelFactory( uid, charts, matomoAnalyticsController, + getEnrollmentAnalyticsUseCase, + dispatchers, ) as T } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/IndicatorViewHolder.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/IndicatorViewHolder.kt index 1d21a5552be..13b12ac6e41 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/IndicatorViewHolder.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/IndicatorViewHolder.kt @@ -54,7 +54,7 @@ class IndicatorViewHolder( if (!programIndicatorModel.color.isNullOrEmpty()) { Color(programIndicatorModel.color()) } else { - SurfaceColor.Container + SurfaceColor.ContainerHigh }, content = programIndicatorModel.value ?: "", modifier = @@ -69,6 +69,7 @@ class IndicatorViewHolder( Modifier }, ), + useMarkdown = true, ) Spacer(modifier = Modifier.size(16.dp)) } diff --git a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/di/AnalyticsFragmentInjector.kt b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/di/AnalyticsFragmentInjector.kt index 94b65a9a769..d581d40b8a7 100644 --- a/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/di/AnalyticsFragmentInjector.kt +++ b/dhis_android_analytics/src/main/java/dhis2/org/analytics/charts/ui/di/AnalyticsFragmentInjector.kt @@ -4,10 +4,12 @@ import dagger.Module import dagger.Provides import dagger.Subcomponent import dhis2.org.analytics.charts.Charts +import dhis2.org.analytics.charts.domain.GetEnrollmentAnalyticsUseCase import dhis2.org.analytics.charts.ui.AnalyticMode import dhis2.org.analytics.charts.ui.GroupAnalyticsFragment import dhis2.org.analytics.charts.ui.GroupAnalyticsViewModelFactory import org.dhis2.commons.matomo.MatomoAnalyticsController +import org.dhis2.commons.viewmodel.DispatcherProvider @Subcomponent(modules = [AnalyticsFragmentModule::class]) interface AnalyticsFragmentComponent { @@ -23,5 +25,15 @@ class AnalyticsFragmentModule( fun provideViewModelFactory( charts: Charts, matomoAnalyticsController: MatomoAnalyticsController, - ): GroupAnalyticsViewModelFactory = GroupAnalyticsViewModelFactory(mode, uid, charts, matomoAnalyticsController) + getEnrollmentAnalyticsUseCase: GetEnrollmentAnalyticsUseCase, + dispatcherProvider: DispatcherProvider, + ): GroupAnalyticsViewModelFactory = + GroupAnalyticsViewModelFactory( + mode, + uid, + charts, + matomoAnalyticsController, + getEnrollmentAnalyticsUseCase, + dispatcherProvider, + ) } diff --git a/dhis_android_analytics/src/main/res/values-ar-rIQ/strings.xml b/dhis_android_analytics/src/main/res/values-ar-rIQ/strings.xml index 38de9dcfe4a..39f8914a67b 100644 --- a/dhis_android_analytics/src/main/res/values-ar-rIQ/strings.xml +++ b/dhis_android_analytics/src/main/res/values-ar-rIQ/strings.xml @@ -2,7 +2,6 @@ عرض كجدول قبول - لا شيء يومي اليوم الأمس diff --git a/dhis_android_analytics/src/main/res/values-ar/strings.xml b/dhis_android_analytics/src/main/res/values-ar/strings.xml index bd90b0a6276..72fc5ea5af7 100644 --- a/dhis_android_analytics/src/main/res/values-ar/strings.xml +++ b/dhis_android_analytics/src/main/res/values-ar/strings.xml @@ -6,7 +6,6 @@ عرض كقيمة قبول الفترة الزمنية - لا شيء يومي اليوم أخرى @@ -42,7 +41,6 @@ سيعرض الفترة الحالية في الرسم البياني. لا تُرجع عوامل التصفية التي قمت بتطبيقها أية بيانات ليس لديك أي بيانات ليتم عرضها - هناك خطأ ما في هذا التصور هناك خطأ ما في التصورات كل البيانات صفر لم يتم تكوين هذا العرض البياني بشكل جيد ، يرجى الاتصال بالمسؤول. diff --git a/dhis_android_analytics/src/main/res/values-ckb/strings.xml b/dhis_android_analytics/src/main/res/values-ckb/strings.xml index 5f9661cf0a1..c4d7dcac0d1 100644 --- a/dhis_android_analytics/src/main/res/values-ckb/strings.xml +++ b/dhis_android_analytics/src/main/res/values-ckb/strings.xml @@ -2,7 +2,6 @@ پەسندکردن ماوة - هیچیان ئەمڕۆ ئەم ساڵە \ No newline at end of file diff --git a/dhis_android_analytics/src/main/res/values-cs/strings.xml b/dhis_android_analytics/src/main/res/values-cs/strings.xml index f754a77c112..d7efadff225 100644 --- a/dhis_android_analytics/src/main/res/values-cs/strings.xml +++ b/dhis_android_analytics/src/main/res/values-cs/strings.xml @@ -9,7 +9,6 @@ Přijmout Období Org. Jednotka - Žádný Denně Dnes jiný @@ -45,7 +44,6 @@ V grafu se zobrazí aktuální období. Použité filtry nevracejí žádná data K zobrazení nemáte žádná data - S touto vizualizací není něco v pořádku S vizualizacemi není něco v pořádku Něco není v pořádku se skupinami vizualizace Všechny údaje jsou nulové @@ -61,7 +59,6 @@ Nemáte přístup k indikátoru programu %s Chybí vizualizace %s Tato vizualizace není dobře nakonfigurována, kontaktujte svého administrátora. - Vlastní hranice nejsou podporovány pro programový indikátor %s Nelze získat přístup k databázi. Kontaktujte prosím svého správce. Nemáte přístup k programu %s Nemáte přístup k atributu %s diff --git a/dhis_android_analytics/src/main/res/values-da/strings.xml b/dhis_android_analytics/src/main/res/values-da/strings.xml index fcbe723511f..d6b540aa4d9 100644 --- a/dhis_android_analytics/src/main/res/values-da/strings.xml +++ b/dhis_android_analytics/src/main/res/values-da/strings.xml @@ -1,6 +1,5 @@ - None Daily Other Weekly diff --git a/dhis_android_analytics/src/main/res/values-en-rUS/strings.xml b/dhis_android_analytics/src/main/res/values-en-rUS/strings.xml index 40df7fe8191..a508bacacfb 100644 --- a/dhis_android_analytics/src/main/res/values-en-rUS/strings.xml +++ b/dhis_android_analytics/src/main/res/values-en-rUS/strings.xml @@ -1,4 +1,4 @@ Accept - \ No newline at end of file + diff --git a/dhis_android_analytics/src/main/res/values-es-rES/strings.xml b/dhis_android_analytics/src/main/res/values-es-rES/strings.xml index cce3fcb86fc..c81848ec9f1 100644 --- a/dhis_android_analytics/src/main/res/values-es-rES/strings.xml +++ b/dhis_android_analytics/src/main/res/values-es-rES/strings.xml @@ -9,7 +9,6 @@ Aceptar Periodo Unid.Org. - Ninguno Diario Hoy Otro @@ -45,7 +44,6 @@ Se mostrará el periodo actual en el gráfico. Los filtros que has aplicado no devolvieron datos No tienes datos para mostrar - Hay algo erróneo en esta visualización Hay algo erróneo con las visualizaciones Hay algo erróneo con los grupos de visualizaciones Todos los datos tienen valor cero @@ -61,7 +59,6 @@ No tiene acceso indicador del programa %s La visualización %s no se encuentra disponible Esta visualización no se encuentra bien configurada, por favor contacte a su administrador. - Los límites personalizados de indicadores de programa no son compatibles con los graficos de Android%s No se puede acceder a la base de datos. Por favor, contacta con tu administrador. No tiene permiso para acceder al programa %s No tiene permiso para acceder al atributo %s diff --git a/dhis_android_analytics/src/main/res/values-es/strings.xml b/dhis_android_analytics/src/main/res/values-es/strings.xml index 14e40b82ccb..a2f88ea0299 100644 --- a/dhis_android_analytics/src/main/res/values-es/strings.xml +++ b/dhis_android_analytics/src/main/res/values-es/strings.xml @@ -9,7 +9,6 @@ Aceptar Periodo Unid.Org. - Ninguno Diario Hoy Otro @@ -45,7 +44,6 @@ Se mostrará el periodo actual en el gráfico. Los filtros que has aplicado no devolvieron datos No tienes datos para mostrar - Hay algo erróneo en esta visualización Hay algo erróneo con las visualizaciones Hay algo erróneo con los grupos de visualizaciones Todos los datos tienen valor cero @@ -61,7 +59,6 @@ No tiene acceso indicador del programa %s La visualización %s no se encuentra disponible Esta visualización no se encuentra bien configurada, por favor contacte a su administrador. - Los límites personalizados de indicadores de programa no son compatibles con los graficos de Android%s No se puede acceder a la base de datos. Por favor, contacta con tu administrador. No tiene permiso para acceder al programa %s No tiene permiso para acceder al atributo %s diff --git a/dhis_android_analytics/src/main/res/values-fr/strings.xml b/dhis_android_analytics/src/main/res/values-fr/strings.xml index de182c6df8d..adca246f900 100644 --- a/dhis_android_analytics/src/main/res/values-fr/strings.xml +++ b/dhis_android_analytics/src/main/res/values-fr/strings.xml @@ -6,7 +6,6 @@ Afficher comme valeur Accepter Période - Aucun Quotidien Aujourd\'hui Autre diff --git a/dhis_android_analytics/src/main/res/values-hi-rIN/strings.xml b/dhis_android_analytics/src/main/res/values-hi-rIN/strings.xml index a4e3d321508..38f6eba739a 100644 --- a/dhis_android_analytics/src/main/res/values-hi-rIN/strings.xml +++ b/dhis_android_analytics/src/main/res/values-hi-rIN/strings.xml @@ -1,7 +1,6 @@ स्वीकार करें - कोई नहीं रोज़ाना आज कल (बिता हुआ) diff --git a/dhis_android_analytics/src/main/res/values-id/strings.xml b/dhis_android_analytics/src/main/res/values-id/strings.xml index fc1cb7776d6..adb45322a85 100644 --- a/dhis_android_analytics/src/main/res/values-id/strings.xml +++ b/dhis_android_analytics/src/main/res/values-id/strings.xml @@ -6,7 +6,6 @@ Lihat sebagai Nilai Terima Periode - Tidak ada Harian Hari ini Lainnya diff --git a/dhis_android_analytics/src/main/res/values-km/strings.xml b/dhis_android_analytics/src/main/res/values-km/strings.xml index 940b38c9527..10115835d63 100644 --- a/dhis_android_analytics/src/main/res/values-km/strings.xml +++ b/dhis_android_analytics/src/main/res/values-km/strings.xml @@ -3,7 +3,6 @@ បង្ហាញតារាង ទទួលយក កំឡុងពេល - គ្មាន រាល់​ថ្ងៃ ថ្ងៃនេះ ផ្សេង diff --git a/dhis_android_analytics/src/main/res/values-lo/strings.xml b/dhis_android_analytics/src/main/res/values-lo/strings.xml index a673b780a1d..e8058a2aea4 100644 --- a/dhis_android_analytics/src/main/res/values-lo/strings.xml +++ b/dhis_android_analytics/src/main/res/values-lo/strings.xml @@ -8,7 +8,6 @@ ຍອມຮັບ ຊ່ວງເວລາ ຫົວໜ່ວຍອົງກອນ - ບໍ່ມີ ທຸກໆມື້ ມື້ນີ້ ອື່ນໆ @@ -44,7 +43,6 @@ ມັນຈະສະແດງຊ່ວງເວລາປະຈຸບັນໃນແຜນວາດ ຕົວກອງທີ່ທ່ານໃຊ້ບໍ່ໄດ້ສົ່ງຂໍ້ມູນຄືນໃດໆ ທ່ານບໍ່ມີຂໍ້ມູນໃດໆທີ່ຈະສະແດງ - ມີບາງຢ່າງຜິດພາດກັບການສະແດງພາບນີ້ ມີບາງຢ່າງຜິດພາດກັບການເບິ່ງເຫັນ ມີບາງຢ່າງຜິດພາດກັບກຸ່ມການເບິ່ງເຫັນ ຂໍ້ມູນທັງໝົດເປັນສູນ @@ -60,7 +58,6 @@ ທ່ານບໍ່ໄດ້ຮັບອານຸຍາດເຂົ້າເຖິງສາຍງານຕົວຊີ້ວັດ%s ການສະແດງພາບ%sຂາດຫາຍໄປ ການສະແດງພາບນີ້ບໍ່ໄດ້ຖືກຕັ້ງຄ່າໄດ້ດີ, ກະລຸນາຕິດຕໍ່ຜູ້ດູແລຂອງທ່ານ. - ບໍ່ຮອງຮັບຂອບເຂດກຳນົດເອງສຳລັບຕົວຊີ້ວັດຂອງສາຍງານ%s ບໍ່ສາມາດເຂົ້າເຖິງຖານຂໍ້ມູນໄດ້. ກະລຸນາຕິດຕໍ່ຜູ້ດູແລລະບົບ ທ່ານບໍ່ໄດ້ຮັບອານຸຍາດເຂົ້າເຖີງໂປຣແກຣມ %s ປະເພດການສັງລວມບໍ່ໄດ້ສະໜັບສະໜູນ %s diff --git a/dhis_android_analytics/src/main/res/values-my/strings.xml b/dhis_android_analytics/src/main/res/values-my/strings.xml index cd195ba6d3a..19c9c50e83e 100644 --- a/dhis_android_analytics/src/main/res/values-my/strings.xml +++ b/dhis_android_analytics/src/main/res/values-my/strings.xml @@ -1,7 +1,6 @@ အချိန်ကာလ - တစ်ခုမှမဟုတ် နေ့စဉ် အခြား အပတ်စဉ် diff --git a/dhis_android_analytics/src/main/res/values-nb/strings.xml b/dhis_android_analytics/src/main/res/values-nb/strings.xml index 134f679d4fc..dcd0cec2954 100644 --- a/dhis_android_analytics/src/main/res/values-nb/strings.xml +++ b/dhis_android_analytics/src/main/res/values-nb/strings.xml @@ -3,7 +3,6 @@ Vis som tabell Aksepter Periode - Ingen Daglig I dag Annet diff --git a/dhis_android_analytics/src/main/res/values-nl/strings.xml b/dhis_android_analytics/src/main/res/values-nl/strings.xml index 312d06a887b..e49e2d5310d 100644 --- a/dhis_android_analytics/src/main/res/values-nl/strings.xml +++ b/dhis_android_analytics/src/main/res/values-nl/strings.xml @@ -9,7 +9,6 @@ Aanvaarden Periode Organisatie-eenheid - Geen Dagelijks Vandaag Anders @@ -45,7 +44,6 @@ Het zal de huidige periode in de grafiek weergeven. De filters die u hebt toegepast, retourneren geen gegevens U hoeft geen gegevens weer te geven - Er is iets mis met deze visualisatie Er is iets mis met de visualisaties Er is iets mis met de visualisatiegroepen Alle gegevens zijn nul @@ -61,7 +59,6 @@ U heeft geen toegang tot programma-indicator %s De visualisatie %s ontbreekt Deze visualisatie is niet goed geconfigureerd, neem contact op met uw beheerder. - Aangepaste grenzen worden niet ondersteund voor programma-indicator %s Geen toegang tot database. Neem contact op met uw beheerder. U heeft geen toegang tot het programma %s U heeft geen toegang tot attribuut %s diff --git a/dhis_android_analytics/src/main/res/values-prs/strings.xml b/dhis_android_analytics/src/main/res/values-prs/strings.xml index 3362235275c..50fe14b4ab6 100644 --- a/dhis_android_analytics/src/main/res/values-prs/strings.xml +++ b/dhis_android_analytics/src/main/res/values-prs/strings.xml @@ -2,7 +2,6 @@ پذیرش دوره - هیچ‌کدام روزنه امروز دیگر diff --git a/dhis_android_analytics/src/main/res/values-ps/strings.xml b/dhis_android_analytics/src/main/res/values-ps/strings.xml index 4f1e5a6e269..8f32ebbc4a3 100644 --- a/dhis_android_analytics/src/main/res/values-ps/strings.xml +++ b/dhis_android_analytics/src/main/res/values-ps/strings.xml @@ -2,7 +2,6 @@ د منلو وړ معیاد - هېڅ یو ورځني [ د مودې ډول ] نن ورځ نور diff --git a/dhis_android_analytics/src/main/res/values-pt-rBR/strings.xml b/dhis_android_analytics/src/main/res/values-pt-rBR/strings.xml index 135b459054b..83c0c64c566 100644 --- a/dhis_android_analytics/src/main/res/values-pt-rBR/strings.xml +++ b/dhis_android_analytics/src/main/res/values-pt-rBR/strings.xml @@ -1,7 +1,6 @@ Período - Nenhum Diário Hoje Outro diff --git a/dhis_android_analytics/src/main/res/values-pt/strings.xml b/dhis_android_analytics/src/main/res/values-pt/strings.xml index 4382d0c612a..7fee702c6aa 100644 --- a/dhis_android_analytics/src/main/res/values-pt/strings.xml +++ b/dhis_android_analytics/src/main/res/values-pt/strings.xml @@ -9,7 +9,6 @@ Aceitar Período UnidOrg - Nenhum Diário Hoje Outro @@ -45,7 +44,6 @@ Inclui o período actual no gráfico Não tem dados associados aos filtros aplicados Não existem dados para mostrar - Algo de errado existe com esta visualização Algo de errado existe com estas visualizações Algo de errado existe com a visualização dos grupos Todos os dados são zero @@ -61,7 +59,6 @@ Não tem acesso ao indicador de programa %s A visualização %s está em falta A visualização não está bem configurada, contacte o seu admin - Limites personalizados não são suportados para o indicador de programa %s Não consegue aceder a base de dados. Favor contactar o seu administrador. Não tem acesso ao programa%s Não tem acesso ao atributo %s diff --git a/dhis_android_analytics/src/main/res/values-ru/strings.xml b/dhis_android_analytics/src/main/res/values-ru/strings.xml index 23fc52b1c80..b0ed579152a 100644 --- a/dhis_android_analytics/src/main/res/values-ru/strings.xml +++ b/dhis_android_analytics/src/main/res/values-ru/strings.xml @@ -9,7 +9,6 @@ Принять Период Орг.подразделение - Не известное значение Ежедневно Сегодня Другое @@ -45,7 +44,6 @@ Это отобразит на графике текущий период. Примененные Вами фильтры не соотносятся ни с какими данными У Вас нет данных для отображения - Что-то не так с этой визуализацией Что-то не так с визуализациями Что-то не так с группами визуализаций Все данные равны нулю diff --git a/dhis_android_analytics/src/main/res/values-sv/strings.xml b/dhis_android_analytics/src/main/res/values-sv/strings.xml index 5803cee0e7b..ae5b810081e 100644 --- a/dhis_android_analytics/src/main/res/values-sv/strings.xml +++ b/dhis_android_analytics/src/main/res/values-sv/strings.xml @@ -2,7 +2,6 @@ Acceptera Period - Ingen Dagligen Övrig Varje vecka diff --git a/dhis_android_analytics/src/main/res/values-tet/strings.xml b/dhis_android_analytics/src/main/res/values-tet/strings.xml index 634b6efb59f..41d0afc3288 100644 --- a/dhis_android_analytics/src/main/res/values-tet/strings.xml +++ b/dhis_android_analytics/src/main/res/values-tet/strings.xml @@ -1,7 +1,6 @@ Períodu - Nein ida Diáriu Seluk Semanal diff --git a/dhis_android_analytics/src/main/res/values-tg/strings.xml b/dhis_android_analytics/src/main/res/values-tg/strings.xml index abe7678f943..7c80bd5ad07 100644 --- a/dhis_android_analytics/src/main/res/values-tg/strings.xml +++ b/dhis_android_analytics/src/main/res/values-tg/strings.xml @@ -1,7 +1,6 @@ Давра - Ягонто Ҳаррӯза Имрӯз Дигар diff --git a/dhis_android_analytics/src/main/res/values-uk/strings.xml b/dhis_android_analytics/src/main/res/values-uk/strings.xml index 851d722453c..0eebc1b8847 100644 --- a/dhis_android_analytics/src/main/res/values-uk/strings.xml +++ b/dhis_android_analytics/src/main/res/values-uk/strings.xml @@ -9,7 +9,6 @@ Прийняти Період Організаційний підрозділ - Жодного Щодня Сьогодні Інше @@ -45,7 +44,6 @@ Це відобразить поточний період на діаграмі. Фільтри, які Ви застосували, не відповідають жодним даним У Вас немає даних для відображення - Помилка із цією візуалізацією Помилка із цими візуалізаціями Помилка із групою візуалізацій Усі значення нульові @@ -61,7 +59,6 @@ Ви не маєте доступу до програмного індикатора %s Візуалізація %s відсутня Цю візуалізацію налаштовано неправильно. Зверніться до адміністратора. - Спеціальні межі не підтримуються для програмного індикатора %s Неможливо отримати доступ до бази даних. Будь ласка, зверніться до свого адміністратора. Ви не маєте доступу до програми %s Ви не маєте доступу до атрибуту %s diff --git a/dhis_android_analytics/src/main/res/values-ur/strings.xml b/dhis_android_analytics/src/main/res/values-ur/strings.xml index 88d2be1923e..2fa15acbda4 100644 --- a/dhis_android_analytics/src/main/res/values-ur/strings.xml +++ b/dhis_android_analytics/src/main/res/values-ur/strings.xml @@ -1,7 +1,6 @@ مدت - کوئی نہں روزانہ آج دیگر diff --git a/dhis_android_analytics/src/main/res/values-uz-rUZ/strings.xml b/dhis_android_analytics/src/main/res/values-uz-rUZ/strings.xml index 8250abcea48..5a5959ce2e9 100644 --- a/dhis_android_analytics/src/main/res/values-uz-rUZ/strings.xml +++ b/dhis_android_analytics/src/main/res/values-uz-rUZ/strings.xml @@ -3,7 +3,6 @@ Jadval sifatida koʼrish Qabul qiling davr - Йўқ Кунлик бугун Boshqa diff --git a/dhis_android_analytics/src/main/res/values-uz/strings.xml b/dhis_android_analytics/src/main/res/values-uz/strings.xml index 3d6cf787646..0e64911eea9 100644 --- a/dhis_android_analytics/src/main/res/values-uz/strings.xml +++ b/dhis_android_analytics/src/main/res/values-uz/strings.xml @@ -9,7 +9,6 @@ Қабул қилинг Давр Ташкилий бирлик - Йўқ Кунлик Бугун Бошқа diff --git a/dhis_android_analytics/src/main/res/values-vi/strings.xml b/dhis_android_analytics/src/main/res/values-vi/strings.xml index c752921c92e..ee65b96dcfe 100644 --- a/dhis_android_analytics/src/main/res/values-vi/strings.xml +++ b/dhis_android_analytics/src/main/res/values-vi/strings.xml @@ -9,7 +9,6 @@ Chấp nhận Thời điểm Đơn vị - Không Hàng ngày Hôm nay Khác @@ -45,7 +44,6 @@ Nó sẽ hiển thị thời điểm hiện tại trong biểu đồ Các yếu tố lọc bạn đã chọn không trả về kết quả nào Bạn không có dữ liệu nào để hiển thị - Có gì đó không đúng với biểu đồ này Có gì đó không đúng với các biểu đồ này Có gì đó không đúng với các nhóm biểu đồ này Tất cả dữ liệu là 0 @@ -61,7 +59,6 @@ Bạn không được truy cập Chỉ Số Chương Trình %s Biểu đồ %s không hiển thị Biểu đồ này cấu hình chưa đúng, xin vui lòng liên hệ quản lý của bạn - Phạm vi tự chọn không được hỗ trợ cho Chỉ Số Chương Trình %s Không thể truy cập cơ sở dữ liệu. Xin vui lòng liên hệ quản lý của bạn. Bạn không có quyền truy cập đến chương trình %s Bạn không có quyền truy cập đến thuộc tính %s diff --git a/dhis_android_analytics/src/main/res/values-zh-rCN/strings.xml b/dhis_android_analytics/src/main/res/values-zh-rCN/strings.xml index 028507b98d7..e761cd68890 100644 --- a/dhis_android_analytics/src/main/res/values-zh-rCN/strings.xml +++ b/dhis_android_analytics/src/main/res/values-zh-rCN/strings.xml @@ -8,7 +8,6 @@ 以饼图形式查看 接受 周期 - 每日 今天 其它 diff --git a/dhis_android_analytics/src/main/res/values-zh/strings.xml b/dhis_android_analytics/src/main/res/values-zh/strings.xml index 73507351b45..c502c6760e0 100644 --- a/dhis_android_analytics/src/main/res/values-zh/strings.xml +++ b/dhis_android_analytics/src/main/res/values-zh/strings.xml @@ -9,7 +9,6 @@ 接受 期间 组织单位 - 没有 每日 今天 其它 @@ -45,7 +44,6 @@ 它将在图表中显示当前周期。 您应用的过滤器不返回任何数据 您没有任何要显示的数据 - 此可视化有问题 可视化有问题 可视化组有问题 所有数据为零 @@ -61,7 +59,6 @@ 您无权访问项目指标 %s 缺少可视化 %s 此可视化未正确配置,请联系您的管理员。 - 项目指标 %s 不支持自定义边界 无法访问数据库。请联系您的管理员。 您无权访问项目%s 您无权访问属性 %s diff --git a/dhis_android_analytics/src/main/res/values/strings.xml b/dhis_android_analytics/src/main/res/values/strings.xml index e002a7f92ce..ffc1de03d32 100644 --- a/dhis_android_analytics/src/main/res/values/strings.xml +++ b/dhis_android_analytics/src/main/res/values/strings.xml @@ -9,7 +9,6 @@ Accept Period OrgUnit - None Daily Today Other @@ -45,7 +44,6 @@ It will display the current period in the chart. The filters you applied do not return any data You do not have any data to be displayed - Something is wrong with this visualization Something is wrong with the visualizations Something is wrong with the visualization groups All data is zero @@ -61,7 +59,6 @@ You don\'t have access to program indicator %s The visualization %s is missing This visualization is not well configured, please contact your admin. - Custom boundaries are not supported for program indicator %s Can\'t access database. Please contact your administrator. You do not have access to program %s You do not have access to attribute %s diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt index adf8c8f65be..9b0f7116197 100644 --- a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ChartsRepositoryTest.kt @@ -16,6 +16,7 @@ import org.hisp.dhis.android.core.analytics.AnalyticsException import org.hisp.dhis.android.core.analytics.aggregated.DimensionItem import org.hisp.dhis.android.core.analytics.aggregated.GridAnalyticsResponse import org.hisp.dhis.android.core.arch.helpers.Result +import org.hisp.dhis.android.core.common.ObjectWithUid import org.hisp.dhis.android.core.common.RelativeOrganisationUnit import org.hisp.dhis.android.core.common.RelativePeriod import org.hisp.dhis.android.core.common.ValueType @@ -43,6 +44,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import kotlinx.coroutines.test.runTest import java.util.Date class ChartsRepositoryTest { @@ -66,7 +68,7 @@ class ChartsRepositoryTest { ) @Test - fun `Should return empty list if enrollment teiUid is null`() { + fun `Should return empty list if enrollment teiUid is null`() = runTest { whenever( d2 .enrollmentModule() @@ -79,6 +81,7 @@ class ChartsRepositoryTest { .uid("enrollmentUid") .program("programUid") .trackedEntityInstance(null) + .attributeOptionCombo("attributeOptionComboUid") .build() val result = repository.getAnalyticsForEnrollment("enrollmentUid") assertTrue( @@ -87,7 +90,7 @@ class ChartsRepositoryTest { } @Test - fun `Should get analytics if settings is not null`() { + fun `Should get analytics if settings is not null`() = runTest { mockEnrollmentCall() mockAnalyticsSettingsCall(mockedAnalyticsSettings()) whenever( @@ -103,7 +106,7 @@ class ChartsRepositoryTest { } @Test - fun `Should get default analytics if settings is null`() { + fun `Should get default analytics if settings is null`() = runTest { mockEnrollmentCall() mockAnalyticsSettingsCall(null) mockRepeatableStagesCall() @@ -134,7 +137,7 @@ class ChartsRepositoryTest { } @Test - fun `Should get default analytics if settings is null and return only dataElement graphs`() { + fun `Should get default analytics if settings is null and return only dataElement graphs`() = runTest { mockEnrollmentCall() mockAnalyticsSettingsCall(null) mockRepeatableStagesCall() @@ -152,7 +155,7 @@ class ChartsRepositoryTest { } @Test - fun `Should get default analytics if settings is null and return only indicator graphs`() { + fun `Should get default analytics if settings is null and return only indicator graphs`() = runTest { mockEnrollmentCall() mockAnalyticsSettingsCall(null) mockRepeatableStagesCall() @@ -178,7 +181,7 @@ class ChartsRepositoryTest { } @Test - fun `Should get default analytics if settings is null and return empty list`() { + fun `Should get default analytics if settings is null and return empty list`() = runTest { mockEnrollmentCall() mockAnalyticsSettingsCall(null) mockRepeatableStagesCall() @@ -538,6 +541,7 @@ class ChartsRepositoryTest { .uid("enrollmentUid") .program("programUid") .trackedEntityInstance("teiUid") + .attributeOptionCombo("attributeOptionComboUid") .build() } @@ -676,6 +680,7 @@ class ChartsRepositoryTest { .builder() .uid("de_1") .valueType(ValueType.NUMBER) + .categoryCombo(ObjectWithUid.create("categoryOptionComboUid")) .build() whenever( d2 @@ -706,7 +711,7 @@ class ChartsRepositoryTest { ProgramStageDataElement .builder() .uid("psde_uid_1") - .dataElement(DataElement.builder().uid("de_1").build()) + .dataElement(ObjectWithUid.create("de_1")) .build(), ) } @@ -756,7 +761,7 @@ class ChartsRepositoryTest { listOf( SerieData( "de_field", - listOf(GraphPoint(Date(), null, GraphFieldValue.Numeric(30f))), + listOf(GraphPoint(Date(), null, GraphFieldValue.Decimal(30f))), ), ), periodToDisplayDefault = null, @@ -771,7 +776,7 @@ class ChartsRepositoryTest { listOf( SerieData( "indicator_field", - listOf(GraphPoint(Date(), null, GraphFieldValue.Numeric(30f))), + listOf(GraphPoint(Date(), null, GraphFieldValue.Decimal(30f))), ), ), null, diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/bindings/DateToPositionTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/bindings/DateToPositionTest.kt index 3a7bc105ff8..05205d9a1ed 100644 --- a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/bindings/DateToPositionTest.kt +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/bindings/DateToPositionTest.kt @@ -32,10 +32,11 @@ class DateToPositionTest { listOf( PeriodType.Daily, PeriodType.Weekly, + PeriodType.WeeklyWednesday, + PeriodType.WeeklyThursday, + PeriodType.WeeklyFriday, PeriodType.WeeklySaturday, PeriodType.WeeklySunday, - PeriodType.WeeklyThursday, - PeriodType.WeeklyWednesday, PeriodType.BiWeekly, PeriodType.Monthly, PeriodType.BiMonthly, @@ -86,8 +87,11 @@ class DateToPositionTest { listOf( PeriodType.Yearly, + PeriodType.FinancialFeb, PeriodType.FinancialApril, PeriodType.FinancialJuly, + PeriodType.FinancialAug, + PeriodType.FinancialSep, PeriodType.FinancialOct, PeriodType.FinancialNov, ).forEach { period -> diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/data/GraphTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/data/GraphTest.kt index 06a96a9f2d7..b9142d34cd3 100644 --- a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/data/GraphTest.kt +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/data/GraphTest.kt @@ -96,9 +96,9 @@ class GraphTest { private fun mockedCoordinates(): List = arrayListOf( - GraphPoint(Date.from(Instant.parse("2020-01-01T00:00:00.00Z")), fieldValue = GraphFieldValue.Numeric(10f)), - GraphPoint(Date.from(Instant.parse("2020-01-02T00:00:00.00Z")), fieldValue = GraphFieldValue.Numeric(20f)), - GraphPoint(Date.from(Instant.parse("2020-01-04T00:00:00.00Z")), fieldValue = GraphFieldValue.Numeric(50f)), - GraphPoint(Date.from(Instant.parse("2020-01-07T00:00:00.00Z")), fieldValue = GraphFieldValue.Numeric(30f)), + GraphPoint(Date.from(Instant.parse("2020-01-01T00:00:00.00Z")), fieldValue = GraphFieldValue.Decimal(10f)), + GraphPoint(Date.from(Instant.parse("2020-01-02T00:00:00.00Z")), fieldValue = GraphFieldValue.Decimal(20f)), + GraphPoint(Date.from(Instant.parse("2020-01-04T00:00:00.00Z")), fieldValue = GraphFieldValue.Decimal(50f)), + GraphPoint(Date.from(Instant.parse("2020-01-07T00:00:00.00Z")), fieldValue = GraphFieldValue.Decimal(30f)), ) } diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/domain/GetEnrollmentAnalyticsUseCaseTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/domain/GetEnrollmentAnalyticsUseCaseTest.kt new file mode 100644 index 00000000000..ae0c5364e57 --- /dev/null +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/domain/GetEnrollmentAnalyticsUseCaseTest.kt @@ -0,0 +1,74 @@ +package dhis2.org.analytics.charts.domain + +import dhis2.org.analytics.charts.ChartsRepository +import dhis2.org.analytics.charts.data.Graph +import dhis2.org.analytics.charts.data.ChartType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.hisp.dhis.android.core.period.PeriodType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class GetEnrollmentAnalyticsUseCaseTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val dispatcherProvider: DispatcherProvider = mock { + on { io() } doReturn testDispatcher + on { computation() } doReturn testDispatcher + on { ui() } doReturn testDispatcher + } + + private val chartsRepository: ChartsRepository = mock() + + private val useCase = GetEnrollmentAnalyticsUseCase(chartsRepository, dispatcherProvider) + + @Test + fun `Should return success result with graph list when repository succeeds`() = runTest { + val expectedGraphs = listOf( + Graph( + title = "graph_1", + series = emptyList(), + periodToDisplayDefault = null, + eventPeriodType = PeriodType.Daily, + periodStep = 0L, + chartType = ChartType.LINE_CHART, + ), + ) + whenever(chartsRepository.getAnalyticsForEnrollment("enrollmentUid")) doReturn expectedGraphs + + val result = useCase("enrollmentUid") + + assertTrue(result.isSuccess) + assertEquals(expectedGraphs, result.getOrNull()) + } + + @Test + fun `Should return failure result when repository throws exception`() = runTest { + val exception = RuntimeException("Repository error") + whenever(chartsRepository.getAnalyticsForEnrollment("enrollmentUid")) doThrow exception + + val result = useCase("enrollmentUid") + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } + + @Test + fun `Should execute on io dispatcher`() = runTest { + whenever(chartsRepository.getAnalyticsForEnrollment("enrollmentUid")) doReturn emptyList() + + useCase("enrollmentUid") + + // Verifying io() was called means the use case used the injected dispatcher + org.mockito.kotlin.verify(dispatcherProvider).io() + } +} diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/mappers/GraphCoordinatesToEntryTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/mappers/GraphCoordinatesToEntryTest.kt index 118b71e0547..43225ac288f 100644 --- a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/mappers/GraphCoordinatesToEntryTest.kt +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/mappers/GraphCoordinatesToEntryTest.kt @@ -46,9 +46,9 @@ class GraphCoordinatesToEntryTest { private fun mockedCoordinates(): List = arrayListOf( - GraphPoint(Date.from(Instant.parse("2020-01-01T00:00:00.00Z")), null, GraphFieldValue.Numeric(10f)), - GraphPoint(Date.from(Instant.parse("2020-01-02T00:00:00.00Z")), null, GraphFieldValue.Numeric(20f)), - GraphPoint(Date.from(Instant.parse("2020-01-04T00:00:00.00Z")), null, GraphFieldValue.Numeric(50f)), - GraphPoint(Date.from(Instant.parse("2020-01-07T00:00:00.00Z")), null, GraphFieldValue.Numeric(30f)), + GraphPoint(Date.from(Instant.parse("2020-01-01T00:00:00.00Z")), null, GraphFieldValue.Decimal(10f)), + GraphPoint(Date.from(Instant.parse("2020-01-02T00:00:00.00Z")), null, GraphFieldValue.Decimal(20f)), + GraphPoint(Date.from(Instant.parse("2020-01-04T00:00:00.00Z")), null, GraphFieldValue.Decimal(50f)), + GraphPoint(Date.from(Instant.parse("2020-01-07T00:00:00.00Z")), null, GraphFieldValue.Decimal(30f)), ) } diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImplTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImplTest.kt index 8522e0f6747..75bd4006f54 100644 --- a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImplTest.kt +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/providers/ChartCoordinatesProviderImplTest.kt @@ -12,6 +12,7 @@ import org.mockito.Mockito import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import kotlinx.coroutines.test.runTest import java.util.Date class ChartCoordinatesProviderImplTest { @@ -22,7 +23,7 @@ class ChartCoordinatesProviderImplTest { ChartCoordinatesProviderImpl(d2, periodStepProvider, resourceManager) @Test - fun `Should get coordinates for data elements`() { + fun `Should get coordinates for data elements`() = runTest { mockedLineListResponse(false) val result = coordinatesProvider.dataElementCoordinates( @@ -38,7 +39,7 @@ class ChartCoordinatesProviderImplTest { } @Test - fun `Should return empty coordinates for data elements`() { + fun `Should return empty coordinates for data elements`() = runTest { mockedLineListResponse(true) val result = coordinatesProvider.dataElementCoordinates( @@ -54,7 +55,7 @@ class ChartCoordinatesProviderImplTest { } @Test - fun `Should get coordinates for indicators`() { + fun `Should get coordinates for indicators`() = runTest { mockedIndicatorLineListResponse(false) val result = coordinatesProvider.indicatorCoordinates( @@ -70,7 +71,7 @@ class ChartCoordinatesProviderImplTest { } @Test - fun `Should return empty coordinates for indicator`() { + fun `Should return empty coordinates for indicator`() = runTest { mockedIndicatorLineListResponse(true) val result = coordinatesProvider.indicatorCoordinates( diff --git a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ui/AnalyticsModelTest.kt b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ui/AnalyticsModelTest.kt index 9555fddb172..8238c9407e8 100644 --- a/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ui/AnalyticsModelTest.kt +++ b/dhis_android_analytics/src/test/java/dhis2/org/analytics/charts/ui/AnalyticsModelTest.kt @@ -77,13 +77,13 @@ class AnalyticsModelTest { GraphPoint( GregorianCalendar(2021, 0, 1).time, 0f, - GraphFieldValue.Numeric(0f), + GraphFieldValue.Decimal(0f), null, ), GraphPoint( GregorianCalendar(2021, 0, 1).time, 1f, - GraphFieldValue.Numeric(0f), + GraphFieldValue.Decimal(0f), null, ), ), diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index fbcac6a5156..00000000000 --- a/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# DHIS 2 Android Capture App - Documentation - -This folder used to contain the the documentation of the DHIS 2 Android App and other technical docs. Since January 2022 it has been moved to [dhis2-android-docs](https://github.com/dhis2-android-docs). diff --git a/docs/compilation-distribution.md b/docs/compilation-distribution.md new file mode 100644 index 00000000000..88827893d71 --- /dev/null +++ b/docs/compilation-distribution.md @@ -0,0 +1,38 @@ +--- +id: compilation-distribution +title: Compiling and distributing +sidebar_position: 4 +--- + +## Compiling APKs + +Run the following gradle commands to build and generate the available APK types: + +- Release apk (both with and without play services) + ```bash + gradlew assembleRelease + ``` +- Training apk + ```bash + gradlew assembleDhis2Training + ``` +- Debug apk + ```bash + gradlew assembleDebug + ``` + +You can also go to the right side of the screen and click on the “gradle” icon, then click on +“dhis2-capture-app” > “app” > “build” > “assembleDebug” (or any other). +After building the APK will be generated in the path project/app/build/outputs/apk + +When it comes to building release APK, “assembleRelease” you will generate a release +APK but it will not be signed, so in this case we recommend you go to “Build” that can easily be +found on top of the screen, then “Generate signed/bundle APK” and you will be asked to load a +keystore and type your password. Finally, after filling the passwords and clicking next the APK will +be generated in the same path mentioned previously project/app/build/outputs/apk. + +## Distribution via MDM + +At this moment you should have the DHIS2 Mobile App compiled with a different applicationId and +signed with your certificates. So it can be distributed via your Mobile Device Management under the +Enterprise Application (or similar) section. diff --git a/docs/configure-openid-oauth.md b/docs/configure-openid-oauth.md new file mode 100644 index 00000000000..4eff1798811 --- /dev/null +++ b/docs/configure-openid-oauth.md @@ -0,0 +1,138 @@ +--- +id: configure-openid-oauth +title: Configure OpenID / Oauth +sidebar_position: 3 +--- + +## Configure OpenID / OAuth + +Since version 2.4, the DHIS2 Android app supports login with an OpenID Connect or OAuth 2.0 +provider. This feature is not available by default — you need to supply the configuration described +below and distribute your own APK. OpenID Connect must also be enabled on the DHIS2 web server and +the user's OpenID parameter must be configured. + +### Configuration + +All OpenID configuration is supplied via **environment variables** (or `local.properties` for local +development). The build script reads these values and embeds them in `BuildConfig` at compile time. + +#### Environment variables + +| Variable | Required | Description | +|---|---|---| +| `OPEN_ID_TYPE` | Yes | Mode: `discovery` (recommended) or `token` | +| `OPEN_ID_SERVER` | Yes | Your DHIS2 server URL | +| `OPEN_ID_CLIENT` | Yes | Client ID from the auth provider | +| `OPEN_ID_REDIRECT_URI` | Yes | Redirect URI registered with the auth provider | +| `OPEN_ID_AUTH_SCHEME` | Yes | URI scheme of the redirect URI (e.g. `com.googleusercontent.apps.CLIENT_ID_PREFIX`) | +| `OPEN_ID_DISCOVERY_URI` | Discovery mode | OpenID discovery endpoint (e.g. `https://provider/.well-known/openid-configuration`) | +| `OPEN_ID_AUTHORIZATION_URL` | Token mode | Authorization endpoint URL | +| `OPEN_ID_TOKEN_URL` | Token mode | Token endpoint URL | +| `OPEN_ID_BUTTON_TEXT` | No | Custom label for the OpenID login button | +| `OPEN_ID_PROMPT` | No | OpenID `prompt` parameter (e.g. `login`, `consent`, `select_account`) | + +For local development you can place the same key-value pairs in `local.properties` at the project +root instead of setting environment variables. The build script checks environment variables first +and falls back to `local.properties`. + +#### Discovery mode vs Token mode + +Set `OPEN_ID_TYPE` to choose how the app locates the provider endpoints: + +- **`discovery`** (recommended) — supply `OPEN_ID_DISCOVERY_URI`. The app fetches the provider's + metadata document at startup and resolves the authorization and token endpoints automatically. +- **`token`** — supply `OPEN_ID_AUTHORIZATION_URL` and `OPEN_ID_TOKEN_URL` directly, without a + discovery document. + +Use `discovery` whenever the provider publishes a `.well-known/openid-configuration` endpoint. + +#### Google example (discovery mode) + +```properties +# local.properties or environment variables +OPEN_ID_TYPE=discovery +OPEN_ID_SERVER=https://play.dhis2.org/android-current +OPEN_ID_CLIENT=CLIENT_ID_PREFIX.apps.googleusercontent.com +OPEN_ID_REDIRECT_URI=com.googleusercontent.apps.CLIENT_ID_PREFIX:/oauth2 +OPEN_ID_AUTH_SCHEME=com.googleusercontent.apps.CLIENT_ID_PREFIX +OPEN_ID_DISCOVERY_URI=https://accounts.google.com/.well-known/openid-configuration +OPEN_ID_BUTTON_TEXT=Login with Google +``` + +### AndroidManifest.xml + +The app's `AndroidManifest.xml` already contains the `RedirectUriReceiverActivity` declaration. +The redirect URI scheme is injected automatically from `OPEN_ID_AUTH_SCHEME` — **no manual editing +of the manifest is required**. + +For reference, the relevant activity entry looks like: + +```xml + + + + + + + + +``` + +#### Advanced: hardcoding OidcInfo in source code + +As an alternative to environment variables, you can hardcode the provider configuration directly in +`login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/LoginScreen.kt` by replacing +the body of `fixedOpenIdProvider`: + +```kotlin +// Discovery mode +private fun fixedOpenIdProvider(oidcInfo: OidcInfo): OidcInfo = + OidcInfo.Discovery( + server = "https://play.dhis2.org/android-current", + loginButtonText = "Login with Google", + clientId = "CLIENT_ID_PREFIX.apps.googleusercontent.com", + redirectUri = "com.googleusercontent.apps.CLIENT_ID_PREFIX:/oauth2", + discoveryUri = "https://accounts.google.com/.well-known/openid-configuration", + prompt = null, + ) + +// Token mode +private fun fixedOpenIdProvider(oidcInfo: OidcInfo): OidcInfo = + OidcInfo.Token( + server = "https://your.dhis2.server", + loginLabel = "Login with MyProvider", + clientId = "your-client-id", + redirectUri = "your.app.scheme:/oauth2", + authorizationUrl = "https://provider/oauth2/authorize", + tokenUrl = "https://provider/oauth2/token", + prompt = null, + ) +``` + +You still need to set `OPEN_ID_AUTH_SCHEME` (or the `manifestPlaceholders["openIdAuthScheme"]` +value in `app/build.gradle.kts`) to match the URI scheme in `redirectUri`, so the OS can redirect +back to the app after a successful login. + +> **This approach is not recommended.** It embeds credentials in source code, complicates +> multi-environment builds, and requires a code change to rotate any value. Prefer environment +> variables for all production and CI/CD builds. + +### Login screen + +When the configuration is present, a new button is displayed on the login screen. + +![](resources/open_id_login.png) + +### OpenID providers and configuration guidelines + +Here you will find a list of available providers and how to obtain the values needed for the +configuration variables above. + +* [Google](https://github.com/openid/AppAuth-Android/blob/master/app/README-Google.md) +* [GitHub](https://docs.github.com/en/developers/apps/authorizing-oauth-apps) +* [ID-porten](https://docs.digdir.no/oidc_protocol_authorize.html) +* [OKTA](https://github.com/openid/AppAuth-Android/blob/master/app/README-Okta.md) +* [KeyCloak](https://www.keycloak.org/docs/latest/authorization_services/index.html#_service_authorization_api) +* [Azure AD](https://learn.microsoft.com/en-us/azure/active-directory-b2c/configure-authentication-sample-android-app?tabs=kotlin) +* [WS02](https://medium.com/@maduranga.siriwardena/configuring-appauth-android-with-wso2-identity-server-8d378835c10a) diff --git a/docs/contributions.md b/docs/contributions.md new file mode 100644 index 00000000000..8aa757f0334 --- /dev/null +++ b/docs/contributions.md @@ -0,0 +1,23 @@ +--- +id: contributions +title: Contributions +sidebar_position: 5 +--- + +# Open an issue + +Open a Jira issue +in [Android app for DHIS2 project](https://dhis2.atlassian.net/jira/software/c/projects/ANDROAPP/boards/118). + +# Open a pull request + +* Fork the repository. Pull changes from the upstream periodically in order to stay up to date when + you submit a pull request. +* Create a branch from **develop** to implement your solution of the issue. +* Your production code should be covered by unit or integration tests. +* Test your changes and make sure they don't break the rest of the project. +* Open a pull request from your **working branch** to **upstream:develop**. +* The pull request title must be descriptive and reference the issue (ANDROAPP-XXX - Description). +* Fill the pull request template with the link to the issue and the rest of checks. +* All the checks in the pull request needs to pass in order to maintain the Continuous Integration. +* The pull request needs to be **reviewed** and approved by the **core team**. \ No newline at end of file diff --git a/docs/creating-custom-usecases.md b/docs/creating-custom-usecases.md new file mode 100644 index 00000000000..0dca6aa5330 --- /dev/null +++ b/docs/creating-custom-usecases.md @@ -0,0 +1,100 @@ +--- +id: creating-custom-usecases +title: Creating Custom Use Cases +sidebar_position: 6 +--- + +To create custom use cases in our DHIS2 Android Capture App, follow the steps below: + +## Step 1: Create a New Module + +The best way to implement a new use case is by creating a new Multiplatform library module. This +module will encapsulate the functionality and can be easily maintained and tested. + +1. **Open your project in Android Studio.** +2. **Go to `File` -> `New` -> `New Module`.** +3. **Select `Kotlin Multiplatform shared module` and click `Next`.** +4. **Name your module according to the pattern `customcase-usecase` (e.g., `stock-usecase`).** +5. **Click `Finish`.** + +## Step 2: Add Dependencies + +In your newly created module, add the `commonskmm` module as a dependency to access the **design +system**, and `commons` for the **DHIS2 SDK**, and other common tools. + +1. **Open `build.gradle.kts` file of your new module.** +2. **Add the following line to the dependencies sections:** + + ```kotlin + commonMain { + dependencies { + implementation(project(":commonskmm")) + } + } + androidMain.dependencies { + implementation(project(":commons")) + } + ``` + +### Configuration Adjustments + +Depending on your Gradle version, you might need to make some configuration adjustments in the +`build.gradle` file. Additionally, if you are using the version catalog, you will need to adjust the +configuration accordingly. + +This allows you to use resources and tools provided in the `commons` modules. + +## Step 3: Implement the Use Case + +Now, you can start implementing your use case within the new module. Structure your code following +the best practices and utilize the resources from the `commons` module. + +### Recommended Architecture + +We recommend using the MVVM (Model-View-ViewModel) architecture with Kotlin language and Jetpack +Compose. This approach ensures a clean separation of concerns and enhances code maintainability. For +more details, refer to the +official [Android Architecture Guidelines](https://developer.android.com/topic/architecture). + +### Example Module Structure + +Here is an example of the module structure: + +- `src/androidMain`: Contains Java/Kotlin source files that needs the android framework as well as + the AndroidManifest and android resources +- `src/commonMain`: Contains Kotlin source files that can be shared between platforms. +- `src/desktopMain`: Contains source files that needs the desktop framework + +It is recommended to include the kotlin multiplatform resource library to share resources. + +## Step 4: Using the Custom Use Case + +To use your custom use case, add your new module as a dependency in the `app` module and call the +entry point (which can be an Activity or a Composable) from where you would like to start using it. + +1. **Open `build.gradle.kts` file of the `app` module.** +2. **Add the following line to the dependencies section:** + + ```kotlin + dependencies { + implementation(project(":usecase-module")) + } + ``` + +3. **Call the entry point in your code:** + + ```kotlin + // Example: Calling an Activity + startActivity(Intent(this, YourUseCaseActivity::class.java)) + + // Example: Calling a Composable + setContent { + YourUseCaseComposable() + } + ``` + +## Benefits + +This modular approach helps to avoid conflicts when updating your project with our new developments. +By isolating custom use cases in separate modules, you can independently maintain and update your +custom functionalities without interfering with the main project components. \ No newline at end of file diff --git a/docs/modifying-apk.md b/docs/modifying-apk.md new file mode 100644 index 00000000000..4341280f908 --- /dev/null +++ b/docs/modifying-apk.md @@ -0,0 +1,84 @@ +--- +id: modifying-apk +title: Modifying the APK +sidebar_position: 2 +--- + +## Change the App logo + +For changing the training logo go to the path **project/app/src/debug/res/mipmap-X/ic_launcher** and +copy/paste your own ic_launcher.png in all mipmap-X directories. + +mipmap-X, where is X, is a specific screen density. + +For the production logo, the process is the same but instead, you will find the logos in the path * +*project/app/src/main/res/mipmap-X/ic_launcher** copy/paste your own ic_launcher.png in all mipmap-X +directories + +You should be able to visualize your icons in Android Studio here too. + +![](resources/launcher_directory.png) + +## Change package name + +Applications are identified in the app stores by their _applicationId _string which must be unique +per store. The official string is com.dhis2. If you try to upload the application to the Google Play +store you will get an error message about that. This can also happen in case you are using Mobile +Device Management tools that allow you to upload your specific applications. If these applications +are going to be stored in the Google Play store as most of them do, you will need to change the +_applicationId_. +If you would like to change the _applicationId_ go to the file build.gradle (Module:app) and scroll +down until you see _defaultConfig_ + +![](resources/applicationId.png) + +Change “com.dhis2” to the new ID that you want. + +You can read more about the applicationId in the official +Android [page](https://developer.android.com/studio/build/application-id) + +## How to generate a Keystore and sign the APK + +By default, the Android system requires that all installed applications be digitally signed by a +certificate whose private is held by the application developer. Android uses the certificate as a +means of identifying the author of an application and establishing trust relationships between +applications. If you try to install an APK without being signed Android will prompt an alert dialog +telling you that the APK is not secure, if you proceed and click “Accept” the apk will be installed. + +Be aware that If you want to upload the APK to the Google Play Store or some other stores you will +be forced to sign the APK, for Github you do not need to sign the APK. In any case, we do recommend +you sign your APK for security reasons. + +For signing the APK you first need a **keystore** that can be easily generated with Android Studio. +Go to top menu “Build” > “Generated Signed Bundle / APK” + +![](resources/build_apk_step_1.png) + +Click on “APK” and ”Next”. Then click on “Create new …” + +![](resources/build_apk_step_2.png) + +After you click on “create new…” you will be asked to fill the following form. +**Key store path**: Choose a path where you want the Keystore to be generated. +**Password and Alias Password**: Choose two secure passwords (they do not need to be the same). The +remaining fields are direct. + +![](resources/build_apk_step_3.png) + +Now clicks “OK” and then you will have to choose a build variant, “release” is fine, after some time +the APKs should be generated and you can find them signed in the path project/app/build/outputs/apk. + +**Important: Once you have generated your Keystore please do not upload it to any public website, +also you will need to sign always with this Keystore for future updates.** + +You can read more about the signing process in the official Android +documentation [page](https://developer.android.com/studio/publish/app-signing) + +## Version name, code and chaning library versions + +Everytime you want to provide your userbase with a new apk you will need to increase the version +code, for doing so go to the directory /gradle/libs.version.toml + +![](resources/version_name_code.png) + +Then you should increase vCode and change vName to the one you want. \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 00000000000..0889af22f70 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,23 @@ +--- +id: overview +title: Overview +sidebar_position: 1 +--- + +# What is the DHIS2 Android app? + +The DHIS2 Android Capture App is a mobile application designed to function seamlessly with your +DHIS2 instance. Our Android app supports data capture across all DHIS2 data models, including +aggregate and individual-level data for Tracker and Event programs. It also includes instant +analysis of data collected in the device. + +The app functions in both online and offline mode, meaning that data and metadata are automatically +synchronized whenever there is internet access, which makes it an effective mobile data entry +solution for locations where internet connectivity is limited. The app is developed and maintained +by the DHIS2 development team, which ensures that the latest versions of the Android app are fully +compatible with all supported DHIS2 core software versions. The app can be downloaded for free +from [Google Play](https://play.google.com/store/apps/details?id=com.dhis2) +or [Github](https://github.com/dhis2/dhis2-android-capture-app/releases). + +To learn more about how the DHIS2 Android app is used to make an impact on health programs around +the world, visit the [Android In Action page](https://dhis2.org/android-in-action/). \ No newline at end of file diff --git a/docs/resources/applicationId.png b/docs/resources/applicationId.png new file mode 100644 index 00000000000..8fcda894470 Binary files /dev/null and b/docs/resources/applicationId.png differ diff --git a/docs/resources/build_apk_step_1.png b/docs/resources/build_apk_step_1.png new file mode 100644 index 00000000000..b3b0dc966ab Binary files /dev/null and b/docs/resources/build_apk_step_1.png differ diff --git a/docs/resources/build_apk_step_2.png b/docs/resources/build_apk_step_2.png new file mode 100644 index 00000000000..7e0dbeaaf05 Binary files /dev/null and b/docs/resources/build_apk_step_2.png differ diff --git a/docs/resources/build_apk_step_3.png b/docs/resources/build_apk_step_3.png new file mode 100644 index 00000000000..59035c1301a Binary files /dev/null and b/docs/resources/build_apk_step_3.png differ diff --git a/docs/resources/launcher_directory.png b/docs/resources/launcher_directory.png new file mode 100644 index 00000000000..224976c5652 Binary files /dev/null and b/docs/resources/launcher_directory.png differ diff --git a/docs/resources/open_id_login.png b/docs/resources/open_id_login.png new file mode 100644 index 00000000000..10e07c0d9d2 Binary files /dev/null and b/docs/resources/open_id_login.png differ diff --git a/docs/resources/version_name_code.png b/docs/resources/version_name_code.png new file mode 100644 index 00000000000..0ccca8a0f38 Binary files /dev/null and b/docs/resources/version_name_code.png differ diff --git a/docs/src/commonmark/en/content/capture-app/local-analytics-features.md b/docs/src/commonmark/en/content/capture-app/local-analytics-features.md deleted file mode 100644 index 934532ecdab..00000000000 --- a/docs/src/commonmark/en/content/capture-app/local-analytics-features.md +++ /dev/null @@ -1,82 +0,0 @@ -# Local Analytics { #capture_app_local_analytics } - -## Local Analytics (NEW 2.5) { #capture_app_local_analytics } - -The DHIS2 Android App can now render analytics that have been created in the Data Visualizer app in DHIS2. Analytics to be displayed require to be configured using the Android Settings WebApp where administrators will be able to decide the charts and tables to be displayed for end users. - -To configure analytics open the Android Settings WebApp on your DHIS2 server and follow the steps below: - - 1. Select Home, Program or Data set from the Analytics menu - 2. Click on the "Add Visualization" button - 3. Search by typing the name of the visualization and select it by clicking on it - 4. Add an alternative title, otherwise, the app will display the name of the visualization - -![](resources/images/capture-app-image162.png){ width=25%} -![](resources/images/capture-app-image164.png){ width=25%} - -Analytics are created and configured using the web tools, however, the data aggregation happens offline using only data stored in the device. - -![](resources/images/capture-app-image165.png){ width=25%} -![](resources/images/capture-app-image166.png){ width=25%} -![](resources/images/capture-app-image167.png){ width=25%} - -### Analytics Types and Filters { #capture_app_local_analytics_types } - -The analytics supported in the Android App are: - - - Pivot Tables - - Column Chart - - Line Chart - - Pie Chart - - Radar chart - - Single Value - -For each visualization object, the user will be able to filter in the app by: - - * Period: Daily, Weekly, Monthly, Yearly, This Quarter, Last Quarter, Last 4 Quarters and Quarter this year. - * OrgUnit: Select "All" to display all the org units available to the user or "Selection" to specify one or multiple org units. - -![](resources/images/capture-app-image180.png){ width=25%} -![](resources/images/capture-app-image168.png){ width=25%} -![](resources/images/capture-app-image169.png){ width=25%} - -A reset button will be displayed if a filter is applied. Select Reset to display the default visualization. - -![](resources/images/capture-app-image170.png){ width=25%} - -Users can also change The anlytics type between chart, table or single value. - -![](resources/images/capture-app-image166.png){ width=25%} -![](resources/images/capture-app-image171.png){ width=25%} - -### Groups { #capture_app_local_analytics_groups } - -All these visualizations can be organised and displayed in groups. Groups are also configured using the Android Settings Webapp following the steps below: - - 1. Open the Android Settings App - 2. Click to Add a new visualization in the Analytics Menu (Home, Program or Data set) - 3. Select a visualization - 4. Mark the "Use a group visualization" checkbox - 5. Create or select a created group - 6. Save the visualization - -You can name your different groups, they will be displayed as a button in the top bar of the analytics screen. - -![](resources/images/capture-app-image173.png){ width=25%} - -### Limitations { #capture_app_local_analytics_limitations } - -Android uses tables and charts created using the web Visualizer app, however only the configuration parameters of the object are used by the Android App: data elements, indicators, chart type, title, periods...; all the aggregations are calculated offline by the android application. The results will be based on the data stored in the device at that exact moment. - -Since the aggregations and calculations displayed are calculated in the device, the implementation of analytics is limited compared to web. In summary the compatible and suported objects and features are: - - - Well formed analytic objects (series, categories, filters) - - User has view access - - Limitations for Pivot Tables - - Number of header lines: 1 - - Number of header columns: 1 - - Limitations for Charts - - Number of Series: No limit (but remember you are rendering in a small screen) - - Number of Categories (doesn’t apply for pie chart): No limit - -There are many more restrictions which apply to Android Analytics regarding the many configuration options available in the Web Visualizer as well as the supported functions and calculations related to indicators and program indicators. [This table](https://docs.google.com/spreadsheets/d/1127cz7M0K4fux5CU0V54V2Z77NZWCr0BTrZ6jcCec4Q) summarises all supported features. diff --git a/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-matomo-instance.png b/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-matomo-instance.png deleted file mode 100644 index 18c23295eb4..00000000000 Binary files a/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-matomo-instance.png and /dev/null differ diff --git a/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-matomo-settings.png b/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-matomo-settings.png deleted file mode 100644 index d114318abe7..00000000000 Binary files a/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-matomo-settings.png and /dev/null differ diff --git a/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-sync-status.png b/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-sync-status.png deleted file mode 100644 index 142218968d0..00000000000 Binary files a/docs/src/commonmark/en/content/capture-app/resources/images/capture-app-sync-status.png and /dev/null differ diff --git a/docs/src/commonmark/en/content/capture-app/settings.md b/docs/src/commonmark/en/content/capture-app/settings.md deleted file mode 100644 index bc2c8fa840f..00000000000 --- a/docs/src/commonmark/en/content/capture-app/settings.md +++ /dev/null @@ -1,45 +0,0 @@ -# Settings { #capture_app_settings } - -![Settings window](resources/images/capture-app-image17.jpg){ width=25%} - -Sync data -: User is able to choose from a drop-down list if the data entered in the app is uploaded to the server manually or scheduled every 15 min, 1 hour or 1 Day. By default the app will sync every 24h. This kind of Syncing includes new and updated events and TEI’s. - -Sync configuration (also known as sync metadata) -: User is able to choose from a drop-down list if the data entered in the app is uploaded to the server manually or scheduled every day or week. By default the app will sync every 24h. This sync will update changes in programs or configurations in web. - -Sync parameters -: These parameters allow the user to specify the maximum number of TEI’s and events that can be stored in the local device. The user can also specify if limits apply per organisation unit or in total. Values can be set to default by clicking on “Reset to default”. - -> **Note** -> -> Sync data, Sync configuration and Sync parameters can be overwritten using the Android Settings Web App as described [in the specific section][#capture_app_andoid_settings_webapp_synchronization] -> -> - -Reserved values -: This will specify the number of reserved Id's available in your device and will allow you to refill them. - -Open sync errors log -: The sync error log gives details about the error and is prepared to be shared with admins. - -Delete local data -: This action will delete local data without having to log out. - -> **Warning** -> -> Using this functionality might lead to data loss if changes have not been previously synced to the server. -> -> - -Reset app data & configuration -: This action will log out the user and delete all data and metadata, this means user and server information is also deleted. It is similar to resetting the App. - -> **Warning** -> -> Using this functionality might lead to data loss if changes have not been previously synced to the server. -> -> - -SMS Settings -: This sections allows to check or edit the parameters related to the sms gateway. diff --git a/form/build.gradle.kts b/form/build.gradle.kts index 21683e3fb79..97deb6fcfc0 100644 --- a/form/build.gradle.kts +++ b/form/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") id("com.google.devtools.ksp") alias(libs.plugins.kotlin.compose.compiler) } @@ -68,10 +67,11 @@ dependencies { implementation(libs.androidx.activity.compose) testImplementation(libs.bundles.form.test) testImplementation(libs.test.junit) + testImplementation(libs.test.turbine) androidTestImplementation(libs.test.compose.ui.test) - androidTestApi(libs.test.mockitoCore) - androidTestApi(libs.test.mockitoKotlin) - androidTestApi(libs.test.dexmaker.mockitoInline) + androidTestImplementation(libs.test.mockitoCore) + androidTestImplementation(libs.test.mockitoKotlin) + androidTestImplementation(libs.test.dexmaker.mockitoInline) debugImplementation(libs.androidx.compose.uitooling) debugImplementation(libs.test.ui.test.manifest) diff --git a/form/src/androidTest/kotlin/org/dhis2/form/ui/FormTest.kt b/form/src/androidTest/kotlin/org/dhis2/form/ui/FormTest.kt index 5153e9dfe2c..aa44b59425c 100644 --- a/form/src/androidTest/kotlin/org/dhis2/form/ui/FormTest.kt +++ b/form/src/androidTest/kotlin/org/dhis2/form/ui/FormTest.kt @@ -10,7 +10,6 @@ import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.mobile.ui.designsystem.component.SectionState import org.junit.Rule import org.junit.Test -import org.mockito.Mockito.mock class FormTest { @@ -75,7 +74,6 @@ class FormTest { sections = sections, intentHandler = {}, uiEventHandler = {}, - resources = mock(), ) } composeTestRule.onNodeWithTag(FORM_VIEW).assertIsDisplayed() diff --git a/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/AgeProviderTest.kt b/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/AgeProviderTest.kt index 8ca5bc97189..b7ff3d891b8 100644 --- a/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/AgeProviderTest.kt +++ b/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/AgeProviderTest.kt @@ -10,8 +10,6 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToLog -import androidx.test.platform.app.InstrumentationRegistry -import org.dhis2.form.di.Injector import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle import org.junit.Rule @@ -19,8 +17,6 @@ import org.junit.Test class AgeProviderTest { - private val resourceManager = - Injector.provideResourcesManager(InstrumentationRegistry.getInstrumentation().getContext()) companion object { @@ -53,10 +49,9 @@ class AgeProviderTest { composeTestRule.setContent { ProvideInputAge( modifier = Modifier.testTag(INPUT_AGE_TEST_TAG), - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, - resources = resourceManager, onNextClicked = {}, ) } @@ -72,10 +67,9 @@ class AgeProviderTest { composeTestRule.setContent { ProvideInputAge( modifier = Modifier.testTag(INPUT_AGE_TEST_TAG), - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, - resources = resourceManager, onNextClicked = { }, ) } @@ -91,11 +85,9 @@ class AgeProviderTest { composeTestRule.setContent { ProvideInputAge( modifier = Modifier.testTag(INPUT_AGE_TEST_TAG), - - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, - resources = resourceManager, onNextClicked = {}, ) } @@ -113,10 +105,9 @@ class AgeProviderTest { composeTestRule.setContent { ProvideInputAge( modifier = Modifier.testTag(INPUT_AGE_TEST_TAG), - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, - resources = resourceManager, onNextClicked = {}, ) @@ -135,10 +126,9 @@ class AgeProviderTest { composeTestRule.setContent { ProvideInputAge( modifier = Modifier.testTag(INPUT_AGE_TEST_TAG), - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, - resources = resourceManager, onNextClicked = {}, ) } @@ -162,10 +152,9 @@ class AgeProviderTest { composeTestRule.setContent { ProvideInputAge( modifier = Modifier.testTag(INPUT_AGE_TEST_TAG), - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, - resources = resourceManager, onNextClicked = {}, ) } @@ -183,10 +172,9 @@ class AgeProviderTest { composeTestRule.setContent { ProvideInputAge( modifier = Modifier.testTag(INPUT_AGE_TEST_TAG), - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, - resources = resourceManager, onNextClicked = {}, ) } diff --git a/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/CategorySelectorProviderTest.kt b/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/CategorySelectorProviderTest.kt index f9523db29c0..a95b10169b4 100644 --- a/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/CategorySelectorProviderTest.kt +++ b/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/CategorySelectorProviderTest.kt @@ -63,7 +63,7 @@ class CategorySelectorProviderTest { composeTestRule.setContent { ProvideCategorySelectorInput( fieldUiModel = uiModel, - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), ) } @@ -115,7 +115,7 @@ class CategorySelectorProviderTest { Column { ProvideCategorySelectorInput( fieldUiModel = uiModel, - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), ) } } diff --git a/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/DateProviderTest.kt b/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/DateProviderTest.kt index a01ca035fd3..f43b1eb108b 100644 --- a/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/DateProviderTest.kt +++ b/form/src/androidTest/kotlin/org/dhis2/form/ui/provider/inputfield/DateProviderTest.kt @@ -40,7 +40,7 @@ class DateProviderTest { generateFieldUiModel(FIELD_UI_MODEL_UID, DATE_VALUE, DATE_VALUE, ValueType.DATE) composeTestRule.setContent { ProvideInputDate( - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), modifier = Modifier.testTag(INPUT_DATE_TEST_TAG), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, @@ -65,7 +65,7 @@ class DateProviderTest { ) composeTestRule.setContent { ProvideInputDate( - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), modifier = Modifier.testTag(INPUT_DATE_TEST_TAG), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, @@ -88,7 +88,7 @@ class DateProviderTest { composeTestRule.setContent { ProvideInputDate( modifier = Modifier.testTag(INPUT_DATE_TEST_TAG), - inputStyle = InputStyle.DataInputStyle(), + inputStyle = InputStyle.DarkInputStyle(), fieldUiModel = dateValueTypeFieldUiModel, intentHandler = {}, onNextClicked = {}, 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 31b1193d6a2..67c4625061f 100644 --- a/form/src/main/java/org/dhis2/form/data/EnrollmentRepository.kt +++ b/form/src/main/java/org/dhis2/form/data/EnrollmentRepository.kt @@ -351,7 +351,7 @@ class EnrollmentRepository( enrollmentDataList.add( getEnrollmentDateField( - conf.program()?.displayEnrollmentLabel() + conf.program()?.displayEnrollmentDateLabel() ?: enrollmentFormLabelsProvider.provideEnrollmentDateDefaultLabel(programUid!!), conf.program()?.selectEnrollmentDatesInFuture(), ), diff --git a/form/src/main/java/org/dhis2/form/data/EventRepository.kt b/form/src/main/java/org/dhis2/form/data/EventRepository.kt index 3b61ebeab03..359cd4d3d12 100644 --- a/form/src/main/java/org/dhis2/form/data/EventRepository.kt +++ b/form/src/main/java/org/dhis2/form/data/EventRepository.kt @@ -150,6 +150,7 @@ class EventRepository( .byProgramStageUid() .eq(event?.programStage()) .withDataElements() + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() .associateBy { section -> section.uid() } } @@ -174,6 +175,7 @@ class EventRepository( .byProgramStageUid() .eq(programStage?.uid()) .withDataElements() + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .get() .flatMap { programStageSection -> if (programStageSection.isEmpty()) { 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 dc06bc1a2f8..7955f56acf6 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepository.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepository.kt @@ -21,9 +21,6 @@ interface FormRepository { suspend fun runDataIntegrityCheck(backPressed: Boolean): DataIntegrityCheckResult fun completedFieldsPercentage(value: List): Float - - fun calculationLoopOverLimit(): Boolean - fun backupOfChangedItems(): List fun updateErrorList(action: RowAction) 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 fb2a70f310d..f6223b8320e 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt @@ -27,8 +27,6 @@ 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 -private const val LOOP_THRESHOLD = 5 - class FormRepositoryImpl( private val formValueStore: FormValueStore, private val fieldErrorMessageProvider: FieldErrorMessageProvider, @@ -49,7 +47,6 @@ class FormRepositoryImpl( private var ruleEffects: List = emptyList() private var ruleEffectsResult: RuleUtilsProviderResult? = null private var runDataIntegrity: Boolean = false - private var calculationLoop: Int = 0 private var backupList: List = emptyList() private val fieldsWithOptionEffects = mutableListOf() @@ -86,7 +83,6 @@ class FormRepositoryImpl( } override suspend fun composeList(skipProgramRules: Boolean): List { - calculationLoop = 0 return itemList .applyRuleEffects(skipProgramRules) .mergeListWithErrorFields(itemsWithError) @@ -461,9 +457,6 @@ class FormRepositoryImpl( ) override fun completedFieldsPercentage(value: List): Float = completionPercentage - - override fun calculationLoopOverLimit(): Boolean = calculationLoop == LOOP_THRESHOLD - override fun backupOfChangedItems() = backupList.minus(itemList.applyRuleEffects()) private suspend fun getFieldsWithError() = @@ -540,14 +533,7 @@ class FormRepositoryImpl( } } - return if (ruleEffectsResult?.fieldsToUpdate?.isNotEmpty() == true && - calculationLoop < LOOP_THRESHOLD - ) { - calculationLoop += 1 - ArrayList(fieldMap.values).applyRuleEffects(skipProgramRules) - } else { - ArrayList(fieldMap.values) - } + return ArrayList(fieldMap.values) } private fun List.setFocusedItem(): List = diff --git a/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt b/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt index 3710d5b6c55..2a6f71cb2da 100644 --- a/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt +++ b/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt @@ -4,6 +4,7 @@ import org.dhis2.commons.bindings.formatData import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.ValueStoreResult import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.maintenance.D2Error import org.hisp.dhis.android.core.program.ProgramRuleActionType import org.hisp.dhis.android.core.program.ProgramStage @@ -287,6 +288,7 @@ class RulesUtilsProviderImpl( ruleEffect: RuleEffect, fieldViewModels: MutableMap, ) { + // Not implemented } private fun displayKeyValuePair( @@ -294,6 +296,7 @@ class RulesUtilsProviderImpl( ruleEffect: RuleEffect, fieldViewModels: MutableMap, ) { + // Not implemented } private fun hideSection( @@ -317,7 +320,11 @@ class RulesUtilsProviderImpl( val fieldUid = assign.field() ?: "" fieldViewModels[fieldUid]?.let { field -> val value = - if (field.optionSet != null && field.displayName != null) { + if ( + field.optionSet != null && + field.displayName != null && + field.valueType != ValueType.MULTI_TEXT + ) { val valueOption = optionsRepository.getOptionByDisplayName( optionSet = field.optionSet!!, @@ -343,38 +350,19 @@ class RulesUtilsProviderImpl( valuesToChange[fieldUid] = it } } - val valueToShow = - if (field.optionSet != null && ruleEffect.data?.isNotEmpty() == true) { - val effectOption = - optionsRepository.getOptionByCode( - optionSet = field.optionSet!!, - code = ruleEffect.data!!, - ) - if (effectOption == null) { - configurationErrors.add( - RulesUtilsProviderConfigurationError( - currentRuleUid, - ActionType.ASSIGN, - ConfigurationError.VALUE_TO_ASSIGN_NOT_IN_OPTION_SET, - listOf( - currentRuleUid ?: "", - ruleEffect.data ?: "", - field.optionSet ?: "", - ), - ), - ) - } - effectOption?.displayName() - } else { - ruleEffect.data - } + val valueToShow = getDisplayValue(field.optionSet, field.valueType, ruleEffect.data) ruleEffect.data?.formatData(field.valueType)?.let { formattedValue -> val updatedField = fieldViewModels[assign.field()] ?.setValue(formattedValue) - ?.setDisplayName(valueToShow?.formatData(field.valueType)) - ?.setEditable(false) + ?.setDisplayName( + formatDisplayName( + valueToShow, + field.valueType, + field.optionSet, + ), + )?.setEditable(false) updatedField?.let { fieldViewModels[fieldUid] = it @@ -387,6 +375,56 @@ class RulesUtilsProviderImpl( } } + private fun formatDisplayName( + value: String?, + valueType: ValueType?, + optionSetUid: String?, + ): String? = if (optionSetUid != null) value else value?.formatData(valueType) + + private fun getDisplayValue( + optionSetUid: String?, + valueType: ValueType?, + ruleEffectData: String?, + ): String? = + if (optionSetUid != null && ruleEffectData?.isNotEmpty() == true) { + if (valueType != ValueType.MULTI_TEXT) { + getOptionName(optionSetUid, ruleEffectData) + } else { + val valueCodes = ruleEffectData.split(",") + valueCodes.joinToString(",") { + getOptionName(optionSetUid, it) ?: "" + } + } + } else { + ruleEffectData + } + + private fun getOptionName( + optionSetUid: String, + code: String, + ): String? { + val effectOption = + optionsRepository.getOptionByCode( + optionSet = optionSetUid, + code = code, + ) + if (effectOption == null) { + configurationErrors.add( + RulesUtilsProviderConfigurationError( + currentRuleUid, + ActionType.ASSIGN, + ConfigurationError.VALUE_TO_ASSIGN_NOT_IN_OPTION_SET, + listOf( + currentRuleUid ?: "", + code, + optionSetUid, + ), + ), + ) + } + return effectOption?.displayName() + } + private fun createEvent( createEvent: RuleAction, fieldViewModels: MutableMap, diff --git a/form/src/main/java/org/dhis2/form/data/metadata/EnrollmentConfiguration.kt b/form/src/main/java/org/dhis2/form/data/metadata/EnrollmentConfiguration.kt index 6143bd41d88..5d9b754a887 100644 --- a/form/src/main/java/org/dhis2/form/data/metadata/EnrollmentConfiguration.kt +++ b/form/src/main/java/org/dhis2/form/data/metadata/EnrollmentConfiguration.kt @@ -46,6 +46,7 @@ class EnrollmentConfiguration( .withAttributes() .byProgramUid() .eq(enrollment()?.program()) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet() fun orgUnit(orgUnitUid: String) = 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 2076d7ee216..7d2760bb76c 100644 --- a/form/src/main/java/org/dhis2/form/di/Injector.kt +++ b/form/src/main/java/org/dhis2/form/di/Injector.kt @@ -32,6 +32,7 @@ import org.dhis2.form.model.coroutine.FormDispatcher import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.form.ui.FieldViewModelFactoryImpl import org.dhis2.form.ui.FormViewModelFactory +import org.dhis2.form.ui.mapper.FormSectionMapper import org.dhis2.form.ui.provider.AutoCompleteProviderImpl import org.dhis2.form.ui.provider.DisplayNameProviderImpl import org.dhis2.form.ui.provider.EnrollmentFormLabelsProvider @@ -68,6 +69,7 @@ object Injector { provideDispatchers(), openErrorLocation, provideFormResultDialogProvider(context), + FormSectionMapper(), ) private fun provideFormResultDialogProvider(context: Context) = @@ -238,11 +240,11 @@ object Injector { private fun provideEnrollmentObjectRepository(enrollmentUid: String): EnrollmentObjectRepository = provideD2().enrollmentModule().enrollments().uid(enrollmentUid) - private fun provideCrashReportController(context: Context) = CrashReportControllerImpl(context) + private fun provideCrashReportController(context: Context) = CrashReportControllerImpl(context, sentryDsn = "") private fun provideNetworkUtils(context: Context) = NetworkUtils(context) - fun provideResourcesManager(context: Context) = + private fun provideResourcesManager(context: Context) = ResourceManager( context, provideColorUtils(), diff --git a/form/src/main/java/org/dhis2/form/model/FormSection.kt b/form/src/main/java/org/dhis2/form/model/FormSection.kt index 093db60ac6a..6543f3045ab 100644 --- a/form/src/main/java/org/dhis2/form/model/FormSection.kt +++ b/form/src/main/java/org/dhis2/form/model/FormSection.kt @@ -8,7 +8,7 @@ data class FormSection( val description: String? = null, val state: SectionState, val fields: List, - var warningMessage: Int? = null, + val warningMessage: Int? = null, val completeFields: Int = 0, val totalFields: Int = 0, val warnings: Int = 0, diff --git a/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt b/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt index 25e87df6809..59675407f53 100644 --- a/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt +++ b/form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt @@ -2,7 +2,7 @@ package org.dhis2.form.simprints import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import org.dhis2.commons.resources.ResourceManager +import androidx.compose.ui.res.stringResource import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.form.R @@ -12,7 +12,6 @@ import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract @Composable fun rememberSimprintsCustomIntentFormPresenter( fieldUiModel: FieldUiModel, - resources: ResourceManager, sessionRepository: SimprintsSessionRepository, ): SimprintsCustomIntentFormPresenter { val customIntent = fieldUiModel.customIntent @@ -30,9 +29,9 @@ fun rememberSimprintsCustomIntentFormPresenter( } val placeholderValue = if (sessionRepository.hasPendingEnrollmentFromPossibleDuplicates()) { - resources.getString(R.string.simprints_using_last_biometrics) + stringResource(R.string.simprints_using_last_biometrics) } else { - resources.getString(R.string.simprints_from_last_biometric_search) + stringResource(R.string.simprints_from_last_biometric_search) } return remember( diff --git a/form/src/main/java/org/dhis2/form/ui/Form.kt b/form/src/main/java/org/dhis2/form/ui/Form.kt index cedc99d03b9..d9cc15a9ff3 100644 --- a/form/src/main/java/org/dhis2/form/ui/Form.kt +++ b/form/src/main/java/org/dhis2/form/ui/Form.kt @@ -26,8 +26,8 @@ import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import kotlinx.coroutines.launch -import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.R import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FormSection @@ -46,7 +46,6 @@ fun Form( sections: List = emptyList(), intentHandler: (FormIntent) -> Unit, uiEventHandler: (RecyclerViewUiEvents) -> Unit, - resources: ResourceManager, ) { val scrollState = rememberLazyListState() val focusManager = LocalFocusManager.current @@ -77,7 +76,8 @@ fun Form( bottomStart = Spacing.Spacing0, bottomEnd = Spacing.Spacing0, ), - ).clickable( + ) + .clickable( interactionSource = remember { MutableInteractionSource() @@ -118,7 +118,7 @@ fun Form( state = section.state, errorCount = section.errors, warningCount = section.warnings, - warningMessage = section.warningMessage?.let { resources.getString(it) }, + warningMessage = section.warningMessage?.let { stringResource(it) }, onNextSection = onNextSection, onSectionClick = { intentHandler.invoke(FormIntent.OnSection(section.uid)) @@ -132,7 +132,6 @@ fun Form( fieldUiModel = fieldUiModel, uiEventHandler = uiEventHandler, intentHandler = intentHandler, - resources = resources, focusManager = focusManager, onNextClicked = { manageOnNextEvent( @@ -164,7 +163,7 @@ fun Form( } } if (shouldDisplayNoFieldsWarning(sections)) { - NoFieldsWarning(resources) + NoFieldsWarning() } } @@ -190,7 +189,7 @@ fun shouldDisplayNoFieldsWarning(sections: List): Boolean = } @Composable -fun NoFieldsWarning(resources: ResourceManager) { +fun NoFieldsWarning() { Column( modifier = Modifier @@ -201,7 +200,7 @@ fun NoFieldsWarning(resources: ResourceManager) { Modifier .clip(shape = RoundedCornerShape(Radius.Full)) .background(SurfaceColor.WarningContainer), - text = resources.getString(R.string.form_without_fields), + text = stringResource(R.string.form_without_fields), icon = { Icon( imageVector = Icons.Outlined.ErrorOutline, 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 fa5c4a4e82d..e3dc8c2ef91 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormView.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormView.kt @@ -13,8 +13,8 @@ import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -48,8 +48,8 @@ import org.dhis2.form.data.RulesUtilsProviderConfigurationError import org.dhis2.form.data.scan.ScanContract import org.dhis2.form.data.toMessage import org.dhis2.form.di.Injector -import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FormRepositoryRecords +import org.dhis2.form.model.FormSection import org.dhis2.form.model.InfoUiModel import org.dhis2.form.model.RowAction import org.dhis2.form.model.UiRenderType @@ -178,12 +178,7 @@ class FormView : Fragment() { ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, ) setContent { - val items by viewModel.items.observeAsState() - val sections = - items?.let { - formSectionMapper.mapFromFieldUiModelList(it) - } ?: emptyList() - + val items by viewModel.items.collectAsState(emptyList()) var resultDialogData: FormViewModel.FormActions.ShowResultDialog? by remember { mutableStateOf(null) } @@ -201,12 +196,14 @@ class FormView : Fragment() { } Form( - sections = sections, + sections = items, intentHandler = ::intentHandler, uiEventHandler = ::uiEventHandler, - resources = Injector.provideResourcesManager(context), ) + LaunchedEffect(items) { + render(items) + } resultDialogData?.let { DataEntryBottomSheet( model = it.model, @@ -252,12 +249,6 @@ class FormView : Fragment() { onItemChangeListener?.let { it(rowAction) } } - viewModel.items.observe( - viewLifecycleOwner, - ) { items -> - render(items) - } - viewModel.loading.observe( viewLifecycleOwner, ) { loading -> @@ -293,14 +284,6 @@ class FormView : Fragment() { ) { percentage -> completionListener?.invoke(percentage) } - - viewModel.calculationLoop.observe( - viewLifecycleOwner, - ) { displayLoopWarning -> - if (displayLoopWarning) { - showLoopWarning() - } - } } private fun showInfoDialog(infoUiModel: InfoUiModel) { @@ -315,15 +298,6 @@ class FormView : Fragment() { ).show() } - private fun showLoopWarning() { - MaterialAlertDialogBuilder(requireContext(), R.style.DhisMaterialDialog) - .setTitle(getString(R.string.program_rules_loop_warning_title)) - .setMessage(getString(R.string.program_rules_loop_warning_message)) - .setPositiveButton(R.string.action_accept) { _, _ -> } - .setCancelable(false) - .show() - } - private fun uiEventHandler(uiEvent: RecyclerViewUiEvents) { when (uiEvent) { is RecyclerViewUiEvents.RequestLocationByMap -> requestLocationByMap(uiEvent) @@ -458,10 +432,9 @@ class FormView : Fragment() { } } - private fun render(items: List) { + private fun render(items: List) { viewModel.calculateCompletedFields() viewModel.updateConfigurationErrors() - viewModel.displayLoopWarningIfNeeded() viewModel.onItemsRendered() onFieldItemsRendered?.invoke(items.isEmpty()) } 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 e1eb21e6108..3c92166b131 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt @@ -13,11 +13,15 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import org.dhis2.commons.date.DateUtils import org.dhis2.commons.dialogs.bottomsheet.BottomSheetDialogUiModel @@ -49,6 +53,7 @@ import org.dhis2.form.model.ValueStoreResult import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.idling.FormCountingIdlingResource import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.mapper.FormSectionMapper import org.dhis2.form.ui.provider.FormResultDialogProvider import org.dhis2.mobile.commons.model.CustomIntentRequestArgumentModel import org.dhis2.mobile.commons.providers.CustomIntentFailure @@ -74,6 +79,7 @@ class FormViewModel( private val geometryController: GeometryController = GeometryController(GeometryParserImpl()), private val openErrorLocation: Boolean = false, private val resultDialogUiProvider: FormResultDialogProvider, + private val formSectionMapper: FormSectionMapper, ) : ViewModel() { val loading = MutableLiveData(true) val showToast = MutableLiveData() @@ -81,8 +87,12 @@ class FormViewModel( val showInfo = MutableLiveData() val confError = MutableLiveData>() var dateFormatConfig: String = "ddMMyyyy" - private val _items = MutableLiveData>() - val items: LiveData> = _items + + private val _items = MutableSharedFlow>() + + val items = _items.map { items -> + formSectionMapper.mapFromFieldUiModelList(items) + }.shareIn(viewModelScope, SharingStarted.Eagerly, 0) var previousActionItem: RowAction? = null @@ -107,9 +117,6 @@ class FormViewModel( private val _completionPercentage = MutableLiveData() val completionPercentage = _completionPercentage - private val _calculationLoop = MutableLiveData(false) - val calculationLoop = _calculationLoop - private val pendingIntents = MutableSharedFlow() private val fieldListChannel = @@ -119,6 +126,7 @@ class FormViewModel( ) private val handler = Handler(Looper.getMainLooper()) + private var textChangeDebounceRunnable: Runnable? = null var filePath: String? = null @@ -143,7 +151,7 @@ class FormViewModel( fieldListChannel.consumeEach { fieldListConfiguration -> FormCountingIdlingResource.increment() val result = repository.composeList(fieldListConfiguration.skipProgramRules) - _items.postValue(result) + _items.emit(result) if (fieldListConfiguration.finish) { runDataIntegrityCheck() } @@ -154,6 +162,11 @@ class FormViewModel( loadData() } + override fun onCleared() { + textChangeDebounceRunnable?.let { handler.removeCallbacks(it) } + super.onCleared() + } + private fun displayResult(result: Pair) { result.second.valueStoreResult?.let { when (it) { @@ -302,6 +315,18 @@ class FormViewModel( private fun handleOnTextChangeAction(action: RowAction): StoreResult { repository.updateValueOnList(action.id, action.value, action.valueType) + textChangeDebounceRunnable?.let { handler.removeCallbacks(it) } + textChangeDebounceRunnable = Runnable { + viewModelScope.launch { + pendingIntents.emit( + FormIntent.OnSave( + uid = action.id, + value = action.value, + valueType = action.valueType, + ), + ) + } + }.also { handler.postDelayed(it, 1_000L) } return StoreResult( action.id, ValueStoreResult.TEXT_CHANGING, @@ -436,26 +461,26 @@ class FormViewModel( false } else { valueType.isNumeric || - valueType.isText && - renderType?.isPolygon() != true || - valueType == ValueType.URL || - valueType == ValueType.EMAIL || - valueType == ValueType.PHONE_NUMBER + valueType.isText && + renderType?.isPolygon() != true || + valueType == ValueType.URL || + valueType == ValueType.EMAIL || + valueType == ValueType.PHONE_NUMBER } private fun getLastFocusedTextItem() = repository.currentFocusedItem()?.takeIf { it.optionSet == null && - ( - valueTypeIsTextField( - it.valueType, - it.renderingType, - ) || - it.valueType == ValueType.AGE || - it.valueType == ValueType.DATETIME || - it.valueType == ValueType.DATE || - it.valueType == ValueType.TIME - ) + ( + valueTypeIsTextField( + it.valueType, + it.renderingType, + ) || + it.valueType == ValueType.AGE || + it.valueType == ValueType.DATETIME || + it.valueType == ValueType.DATE || + it.valueType == ValueType.TIME + ) } private fun rowActionFromIntent(intent: FormIntent): RowAction = @@ -833,7 +858,7 @@ class FormViewModel( Timber.e(e) } finally { val list = repository.composeList() - _items.postValue(list) + _items.emit(list) FormCountingIdlingResource.decrement() } } @@ -874,7 +899,7 @@ class FormViewModel( EventStatus.SCHEDULE, EventStatus.VISITED, EventStatus.OVERDUE, - -> FormActions.OnFinish + -> FormActions.OnFinish } private fun provideShowResultDialog(result: DataIntegrityCheckResult): FormActions.ShowResultDialog? = @@ -941,7 +966,9 @@ class FormViewModel( viewModelScope.launch { val result = async(dispatcher.io()) { - repository.completedFieldsPercentage(_items.value ?: emptyList()) + repository.completedFieldsPercentage( + value = _items.firstOrNull() ?: emptyList() + ) } try { _completionPercentage.postValue(result.await()) @@ -969,20 +996,6 @@ class FormViewModel( } } - fun displayLoopWarningIfNeeded() { - viewModelScope.launch { - val result = - async(dispatcher.io()) { - repository.calculationLoopOverLimit() - } - try { - _calculationLoop.postValue(result.await()) - } catch (e: Exception) { - Timber.e(e) - } - } - } - fun discardChanges() { repository.backupOfChangedItems().forEach { submitIntent( @@ -1022,10 +1035,10 @@ class FormViewModel( repository.getDateFormatConfiguration() }.await() try { - _items.postValue(result) + _items.emit(result) } catch (e: Exception) { Timber.e(e) - _items.postValue(emptyList()) + _items.emit(emptyList()) } finally { FormCountingIdlingResource.decrement() } diff --git a/form/src/main/java/org/dhis2/form/ui/FormViewModelFactory.kt b/form/src/main/java/org/dhis2/form/ui/FormViewModelFactory.kt index aecfa9def13..ec0773d9b5e 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewModelFactory.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewModelFactory.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.data.FormRepository +import org.dhis2.form.ui.mapper.FormSectionMapper import org.dhis2.form.ui.provider.FormResultDialogProvider class FormViewModelFactory( @@ -11,6 +12,7 @@ class FormViewModelFactory( private val dispatcher: DispatcherProvider, private val openErrorLocation: Boolean, private val resultDialogUiProvider: FormResultDialogProvider, + private val formSectionMapper: FormSectionMapper, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = FormViewModel( @@ -18,5 +20,6 @@ class FormViewModelFactory( dispatcher = dispatcher, openErrorLocation = openErrorLocation, resultDialogUiProvider = resultDialogUiProvider, + formSectionMapper = formSectionMapper, ) as T } diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/AgeProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/AgeProvider.kt index a7414fa1c45..f7bae8c565b 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/AgeProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/AgeProvider.kt @@ -7,9 +7,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.R import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.legend @@ -34,7 +34,6 @@ fun ProvideInputAge( inputStyle: InputStyle, fieldUiModel: FieldUiModel, intentHandler: (FormIntent) -> Unit, - resources: ResourceManager, onNextClicked: () -> Unit, ) { var inputType by remember { @@ -96,11 +95,11 @@ fun ProvideInputAge( title = fieldUiModel.label, inputStyle = inputStyle, isRequired = fieldUiModel.mandatory, - dateOfBirthLabel = resources.getString(R.string.date_birth), - orLabel = resources.getString(R.string.or), - ageLabel = resources.getString(R.string.age), - cancelText = resources.getString(R.string.cancel), - acceptText = resources.getString(R.string.ok), + dateOfBirthLabel = stringResource(R.string.date_birth), + orLabel = stringResource(R.string.or), + ageLabel = stringResource(R.string.age), + cancelText = stringResource(R.string.cancel), + acceptText = stringResource(R.string.ok), selectableDates = fieldUiModel.selectableDates, ), inputType = inputType, @@ -151,7 +150,7 @@ fun ProvideInputAge( } } }, - onNextClicked = onNextClicked, + onImeActionClick = { onNextClicked() }, modifier = modifier, ) } diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CheckBoxProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CheckBoxProvider.kt index 5d90a72c3ed..2e920ed081a 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CheckBoxProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CheckBoxProvider.kt @@ -2,8 +2,8 @@ package org.dhis2.form.ui.provider.inputfield import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.paging.compose.collectAsLazyPagingItems -import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.R import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.legend @@ -77,7 +77,6 @@ internal fun ProvideYesNoCheckBoxInput( inputStyle: InputStyle, fieldUiModel: FieldUiModel, intentHandler: (FormIntent) -> Unit, - resources: ResourceManager, ) { val data = listOf( @@ -85,13 +84,13 @@ internal fun ProvideYesNoCheckBoxInput( uid = "true", checked = fieldUiModel.isAffirmativeChecked, enabled = true, - textInput = resources.getString(R.string.yes), + textInput = stringResource(R.string.yes), ), CheckBoxData( uid = "false", checked = fieldUiModel.isNegativeChecked, enabled = true, - textInput = resources.getString(R.string.no), + textInput = stringResource(R.string.no), ), ) diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CoordinatesProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CoordinatesProvider.kt index 7b7a677e104..8b07c757062 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CoordinatesProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CoordinatesProvider.kt @@ -2,7 +2,7 @@ package org.dhis2.form.ui.provider.inputfield import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import org.dhis2.commons.resources.ResourceManager +import androidx.compose.ui.res.stringResource import org.dhis2.form.R import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.legend @@ -42,7 +42,6 @@ fun ProvideInputCoordinate( fieldUiModel: FieldUiModel, intentHandler: (FormIntent) -> Unit, uiEventHandler: (RecyclerViewUiEvents) -> Unit, - resources: ResourceManager, ) { InputCoordinate( title = fieldUiModel.label, @@ -50,9 +49,9 @@ fun ProvideInputCoordinate( supportingText = fieldUiModel.supportingText(), legendData = fieldUiModel.legend(), coordinates = mapGeometry(fieldUiModel.value, FeatureType.POINT), - latitudeText = resources.getString(R.string.latitude), - longitudeText = resources.getString(R.string.longitude), - addLocationBtnText = resources.getString(R.string.add_location), + latitudeText = stringResource(R.string.latitude), + longitudeText = stringResource(R.string.longitude), + addLocationBtnText = stringResource(R.string.add_location), isRequired = fieldUiModel.mandatory, modifier = modifier, onResetButtonClicked = { diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt index fa534c50f1b..58b45e85ac2 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt @@ -12,11 +12,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.R import org.dhis2.form.di.Injector import org.dhis2.form.extensions.inputState @@ -38,7 +38,6 @@ import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState @Composable fun ProvideCustomIntentInput( fieldUiModel: FieldUiModel, - resources: ResourceManager, intentHandler: (FormIntent) -> Unit, uiEventHandler: (RecyclerViewUiEvents) -> Unit, inputStyle: InputStyle, @@ -58,7 +57,7 @@ fun ProvideCustomIntentInput( val errorGettingDataMessage = SupportingTextData( state = SupportingTextState.ERROR, - text = resources.getString(R.string.custom_intent_error), + text = stringResource(R.string.custom_intent_error), ) val fieldErrorMessage = SupportingTextData( @@ -79,7 +78,6 @@ fun ProvideCustomIntentInput( val simprintsCustomIntentFormPresenter = rememberSimprintsCustomIntentFormPresenter( fieldUiModel = fieldUiModel, - resources = resources, sessionRepository = simprintsSessionRepository, ) @@ -144,9 +142,10 @@ fun ProvideCustomIntentInput( } } } + val defaultLauncherTitle = stringResource(R.string.select_app_intent) InputCustomIntent( title = fieldUiModel.label, - buttonText = resources.getString(R.string.custom_intent_launch), + buttonText = stringResource(R.string.custom_intent_launch), supportingText = supportingTextList.toList(), inputShellState = inputShellState, inputStyle = inputStyle, @@ -182,12 +181,12 @@ fun ProvideCustomIntentInput( if (supportingTextList.contains(errorGettingDataMessage)) { supportingTextList.remove(errorGettingDataMessage) } - fieldUiModel.customIntent?.let { + fieldUiModel.customIntent?.let { customIntent -> launcher.launch( CustomIntentInput( fieldUid = fieldUiModel.uid, - customIntent = it, - defaultTitle = fieldUiModel.customIntent?.name ?: resources.getString(R.string.select_app_intent), + customIntent = customIntent, + defaultTitle = customIntent.name ?: defaultLauncherTitle, ), ) } diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/DateProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/DateProvider.kt index 868804d8474..f9a65c4df6d 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/DateProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/DateProvider.kt @@ -105,7 +105,7 @@ fun ProvideInputDate( } intentHandler.invoke(formIntent) }, - onNextClicked = onNextClicked, + onImeActionClick = { onNextClicked() }, ) } diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt index 27ba6a2ee2b..4de80f0d5d8 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.data.EventRepository.Companion.EVENT_ORG_UNIT_UID import org.dhis2.form.extensions.autocompleteList import org.dhis2.form.extensions.inputState @@ -59,11 +58,10 @@ import org.hisp.dhis.mobile.ui.designsystem.component.model.RegExValidations @Composable fun FieldProvider( modifier: Modifier, - inputStyle: InputStyle = InputStyle.DataInputStyle(), + inputStyle: InputStyle = InputStyle.DarkInputStyle(), fieldUiModel: FieldUiModel, uiEventHandler: (RecyclerViewUiEvents) -> Unit, intentHandler: (FormIntent) -> Unit, - resources: ResourceManager, focusManager: FocusManager, onNextClicked: () -> Unit, onFileSelected: (String) -> Unit, @@ -130,7 +128,6 @@ fun FieldProvider( fieldUiModel = fieldUiModel, intentHandler = intentHandler, uiEventHandler = uiEventHandler, - resources = resources, inputStyle = inputStyle, reEvaluateRequestParams = reEvaluateCustomIntentRequestParameters, modifier = modifierWithFocus, @@ -151,7 +148,6 @@ fun FieldProvider( fieldUiModel = fieldUiModel, intentHandler = intentHandler, uiEventHandler = uiEventHandler, - resources = resources, focusRequester = focusRequester, onNextClicked = onNextClicked, focusManager = focusManager, @@ -167,7 +163,6 @@ fun ProvideByValueType( fieldUiModel: FieldUiModel, intentHandler: (FormIntent) -> Unit, uiEventHandler: (RecyclerViewUiEvents) -> Unit, - resources: ResourceManager, focusRequester: FocusRequester, onNextClicked: () -> Unit, focusManager: FocusManager, @@ -310,7 +305,6 @@ fun ProvideByValueType( ProvideInputFileResource( modifier = modifier, fieldUiModel = fieldUiModel, - resources = resources, onFileSelected = onFileSelected, uiEventHandler = uiEventHandler, ) @@ -338,7 +332,6 @@ fun ProvideByValueType( inputStyle = inputStyle, fieldUiModel = fieldUiModel, intentHandler = intentHandler, - resources = resources, ) } @@ -348,7 +341,6 @@ fun ProvideByValueType( inputStyle = inputStyle, fieldUiModel = fieldUiModel, intentHandler = intentHandler, - resources = resources, ) } } @@ -430,7 +422,6 @@ fun ProvideByValueType( fieldUiModel = fieldUiModel, intentHandler = intentHandler, uiEventHandler = uiEventHandler, - resources = resources, onFileSelected = onFileSelected, ) } @@ -452,7 +443,6 @@ fun ProvideByValueType( fieldUiModel = fieldUiModel, intentHandler = intentHandler, uiEventHandler = uiEventHandler, - resources = resources, ) } } @@ -464,7 +454,6 @@ fun ProvideByValueType( inputStyle = inputStyle, fieldUiModel = fieldUiModel, intentHandler = intentHandler, - resources = resources, onNextClicked = onNextClicked, ) } @@ -585,7 +574,7 @@ private fun ProvideIntegerPositive( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -654,7 +643,7 @@ private fun ProvideIntegerPositiveOrZero( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -723,7 +712,7 @@ private fun ProvidePercentage( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -792,7 +781,7 @@ private fun ProvideNumber( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -866,7 +855,7 @@ private fun ProvideIntegerNegative( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -935,9 +924,13 @@ private fun ProvideLongText( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { - clickedOnNext = true - onNextClicked() + onImeActionClick = { imeAction -> + when (imeAction) { + ImeAction.Next -> { + clickedOnNext = true + onNextClicked() + } + } }, onValueChanged = { value = it ?: TextFieldValue() @@ -1005,7 +998,7 @@ private fun ProvideLetter( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -1074,7 +1067,7 @@ private fun ProvideInteger( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -1144,7 +1137,7 @@ private fun ProvideEmail( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -1223,7 +1216,7 @@ private fun ProvideInputPhoneNumber( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -1302,7 +1295,7 @@ private fun ProvideInputLink( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/ImageInputProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/ImageInputProvider.kt index b419091e208..713c1cc5d66 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/ImageInputProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/ImageInputProvider.kt @@ -12,8 +12,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat -import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.R import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.legend @@ -32,7 +32,6 @@ import org.hisp.dhis.mobile.ui.designsystem.component.UploadState internal fun ProvideInputImage( modifier: Modifier, fieldUiModel: FieldUiModel, - resources: ResourceManager, intentHandler: (FormIntent) -> Unit, uiEventHandler: (RecyclerViewUiEvents) -> Unit, onFileSelected: (filePath: String) -> Unit, @@ -75,7 +74,7 @@ internal fun ProvideInputImage( state = fieldUiModel.inputState(), supportingText = fieldUiModel.supportingText(), legendData = fieldUiModel.legend(), - addImageBtnText = resources.getString(R.string.add_image), + addImageBtnText = stringResource(R.string.add_image), isRequired = fieldUiModel.mandatory, uploadState = uploadState, painterFor = { remember { it!! } }, @@ -113,8 +112,8 @@ internal fun ProvideInputImage( ImagePickerOptionsDialog( title = fieldUiModel.label, showImageOptions = showImageOptions, - cameraButtonLabel = resources.getString(R.string.take_photo), - galleryButtonLabel = resources.getString(R.string.from_gallery_v2), + cameraButtonLabel = stringResource(R.string.take_photo), + galleryButtonLabel = stringResource(R.string.from_gallery_v2), onDismiss = { showImageOptions = false }, onTakePicture = { if (ContextCompat.checkSelfPermission( diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputFileProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputFileProvider.kt index 87dc4266e68..7e20ff9d99d 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputFileProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputFileProvider.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import org.dhis2.commons.resources.ResourceManager +import androidx.compose.ui.res.stringResource import org.dhis2.form.R import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.legend @@ -24,7 +24,6 @@ import java.text.DecimalFormat internal fun ProvideInputFileResource( modifier: Modifier, fieldUiModel: FieldUiModel, - resources: ResourceManager, onFileSelected: (filePath: String) -> Unit, uiEventHandler: (RecyclerViewUiEvents) -> Unit, ) { @@ -45,7 +44,7 @@ internal fun ProvideInputFileResource( title = fieldUiModel.label, inputShellState = fieldUiModel.inputState(), supportingText = fieldUiModel.supportingText(), - buttonText = resources.getString(R.string.add_file), + buttonText = stringResource(R.string.add_file), uploadFileState = uploadState, fileName = file?.name, fileWeight = file?.length()?.let { fileSizeLabel(it) }, diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputsForTextValueTypeProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputsForTextValueTypeProvider.kt index 4cb4471e7ea..5fae08416fc 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputsForTextValueTypeProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/InputsForTextValueTypeProvider.kt @@ -102,7 +102,7 @@ private fun ProvideQRInput( inputTextFieldValue = value, inputStyle = inputStyle, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -159,7 +159,7 @@ private fun ProvideQRInput( @Composable private fun ProvideDefaultTextInput( modifier: Modifier, - inputStyle: InputStyle = InputStyle.DataInputStyle(), + inputStyle: InputStyle = InputStyle.DarkInputStyle(), fieldUiModel: FieldUiModel, intentHandler: (FormIntent) -> Unit, focusManager: FocusManager, @@ -187,7 +187,7 @@ private fun ProvideDefaultTextInput( inputTextFieldValue = value, inputStyle = inputStyle, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, @@ -252,7 +252,7 @@ private fun ProvideBarcodeInput( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = { + onImeActionClick = { clickedOnNext = true onNextClicked() }, diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/RadioButtonProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/RadioButtonProvider.kt index 5769e35abc8..d0cb1b28b11 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/RadioButtonProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/RadioButtonProvider.kt @@ -2,8 +2,8 @@ package org.dhis2.form.ui.provider.inputfield import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.paging.compose.collectAsLazyPagingItems -import org.dhis2.commons.resources.ResourceManager import org.dhis2.form.R import org.dhis2.form.extensions.inputState import org.dhis2.form.extensions.legend @@ -76,7 +76,6 @@ internal fun ProvideYesNoRadioButtonInput( inputStyle: InputStyle, fieldUiModel: FieldUiModel, intentHandler: (FormIntent) -> Unit, - resources: ResourceManager, ) { val data = listOf( @@ -84,13 +83,13 @@ internal fun ProvideYesNoRadioButtonInput( uid = "true", selected = fieldUiModel.isAffirmativeChecked, enabled = true, - textInput = resources.getString(R.string.yes), + textInput = stringResource(R.string.yes), ), RadioButtonData( uid = "false", selected = fieldUiModel.isNegativeChecked, enabled = true, - textInput = resources.getString(R.string.no), + textInput = stringResource(R.string.no), ), ) diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/UnitIntervalInputProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/UnitIntervalInputProvider.kt index b2e317aa419..b9331718646 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/UnitIntervalInputProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/UnitIntervalInputProvider.kt @@ -39,7 +39,7 @@ fun ProvideUnitIntervalInput( legendData = fieldUiModel.legend(), inputTextFieldValue = value, isRequiredField = fieldUiModel.mandatory, - onNextClicked = onNextClicked, + onImeActionClick = { onNextClicked() }, onValueChanged = { value = it ?: TextFieldValue() intentHandler( diff --git a/form/src/main/res/values-ar-rIQ/strings.xml b/form/src/main/res/values-ar-rIQ/strings.xml index 9b49034757a..db4300cab91 100644 --- a/form/src/main/res/values-ar-rIQ/strings.xml +++ b/form/src/main/res/values-ar-rIQ/strings.xml @@ -1,13 +1,8 @@ خطأ - التاريخ - السنوات - الأشهر - ‏‏الأيام تاريخ الاستحقاق مكتمل - حفظ على اية حال مشاركة إغلاق تاريخ الواقعة diff --git a/form/src/main/res/values-ar/strings.xml b/form/src/main/res/values-ar/strings.xml index f2a7b265f59..dea6bedbd9c 100644 --- a/form/src/main/res/values-ar/strings.xml +++ b/form/src/main/res/values-ar/strings.xml @@ -10,16 +10,9 @@ لديك بعض الرسائل التحذيرية. لديك بعض الرسائل التحذيرية.\nهل تريد وضع علامة \"مكتمل\" على هذا النموذج؟ مطلوب - التاريخ - السنوات - الأشهر - ‏‏الأيام - لا يمكن تحليل %s كتاريخ مضلع خط العرض خط الطول - قيم الإحداثيات غير صالحة - تم التقاط المضلع أدخل قيمة أدخل رابط url أدخل بريداً إلكترونياً @@ -34,9 +27,6 @@ أدخل نصًا طويلاً أدخل نصًا تاريخ الاستحقاق - حقل الزامي - خط العرض: - خط الطول: لم يتم حفظه المراجعة مكتمل @@ -46,19 +36,16 @@ بعض الحقول بها أخطاء ولم يتم حفظها. \nهل تريد مراجعة النموذج؟ بعض الحقول تحتاج إلى انتباهك.\nهل تريد مراجعة النموذج؟ ليس الان - حفظ على اية حال استمر في التعديل هل تريد وضع علامة على هذا النموذج كمكتمل؟ إذا قمت بالخروج الآن ، فسيتم تجاهل جميع المعلومات الموجودة في النموذج. - تحتوي بعض الحقول على أخطاء ولا يتم حفظها. \nإذا قمت بالخروج الآن سيتم تجاهل التغييرات. بعض الحقول بها أخطاء ولم يتم حفظها. بعض الحقول الإلزامية غير مدخله ولا يمكن حفظ النموذج. بعض الحقول الإلزامية مفقودة ولا يمكن حفظ النموذج. \nإذا قمت بالخروج الآن سيتم تجاهل التغييرات. + بعض الحقول بها أخطاء ولم يتم حفظها. \n إذا قمت بالخروج الآن ، فسيتم تجاهل التغييرات. مشاركة إغلاق - تحذير قواعد البرنامج تم النسخ - لا توجد معلومات عن هذا الحقل التقط صورة تحقق من هذا! لا تظهر مرة أخرى @@ -69,7 +56,6 @@ تاريخ الحدوث تنزيل المسح الضوئي - رمز الاستجابة السريعة إضافة موقع الوحدة التنظيمية الإحداثيات diff --git a/form/src/main/res/values-bn/strings.xml b/form/src/main/res/values-bn/strings.xml index cf7928a2412..4d5737571b2 100644 --- a/form/src/main/res/values-bn/strings.xml +++ b/form/src/main/res/values-bn/strings.xml @@ -1,6 +1,5 @@ ভুল - তারিখ নির্ধারিত তারিখ \ No newline at end of file diff --git a/form/src/main/res/values-ckb/strings.xml b/form/src/main/res/values-ckb/strings.xml index a3b258aaab8..c6769fb51fd 100644 --- a/form/src/main/res/values-ckb/strings.xml +++ b/form/src/main/res/values-ckb/strings.xml @@ -2,12 +2,7 @@ هەڵە ثيويست - بەروار - ساڵەکان - مانگەکان - رۆژەکان تەواوبکە - بەهەرحاڵ تۆماری بکە داخستن هیچ نرخێکی دیکەی تەرخانکراو نەما. پەیوەندی بە بەڕێوەبەری سیستەمەکەتەوە بکە \ No newline at end of file diff --git a/form/src/main/res/values-cs/strings.xml b/form/src/main/res/values-cs/strings.xml index 797b1fe99b8..959afd7d3a0 100644 --- a/form/src/main/res/values-cs/strings.xml +++ b/form/src/main/res/values-cs/strings.xml @@ -12,16 +12,9 @@ Máte několik varovných hlášení. Máte několik varovných hlášení.\nChcete tento formulář označit jako dokončený\? Vyžadováno - datum - Let - Měsíce - Dnů - %s nelze analyzovat jako datum Polygon Zeměpisná šířka Zeměpisná délka - Hodnoty souřadnic nejsou platné - Polygon zaznamenán Zadejte hodnotu Zadejte url Zadejte e-mail @@ -36,9 +29,6 @@ Zadejte dlouhý text Zadejte text Datum vypršení - Povinné pole - Šířka: - Délka: Neuloženo Posouzení Dokončit @@ -48,21 +38,17 @@ Některá pole obsahují chyby a neukládají se. \nChcete formulář zkontrolovat\? Některé oblasti vyžadují vaši pozornost.\nChcete formulář zkontrolovat\? Teď ne - Přesto uložit Pokračovat v úpravách Chcete tento formulář označit jako vyplněný? Pokud nyní odejdete, všechny informace ve formuláři budou zahozeny. - Některá pole obsahují chyby a neukládají se. \nPokud je nyní ukončíte, změny budou zrušeny. Některá pole obsahují chyby a neuloží se. Některá povinná pole chybí a formulář nelze uložit. Chybí některá povinná pole a formulář nelze uložit. \nPokud nyní formulář ukončíte, změny budou zrušeny. + Některá pole obsahují chyby a neuloží se. \nPokud nyní skončíte, změny budou zahozeny. Sdílet Povolení k použití kamery je zamítnuto.\nChcete-li tuto funkci používat, musíte ji povolit. Zavřít - Upozornění na pravidla programu - Došlo k problému s konfigurací, který způsobuje smyčku v pravidlech. Kontaktujte svého správce. Zkopírováno - Žádné informace pro toto pole Vyfotit Z galerie Zkontrolovat to! @@ -72,13 +58,10 @@ Souřadnice pro Neexistují žádné další rezervované hodnoty. Obraťte se na správce systému Datum incidentu - Pro zachycení souřadnic je potřeba oprávnění k poloze. Povolte je prosím v nastavení aplikace Otevřít s Nenalezena žádná možnost Stáhnout Skenování - QR kód - Čárový kód Přidat umístění Tento formulář nemá nakonfigurovaná žádná pole Podrobnosti %s diff --git a/form/src/main/res/values-da/strings.xml b/form/src/main/res/values-da/strings.xml index 7945b4308bb..dfc69377b92 100644 --- a/form/src/main/res/values-da/strings.xml +++ b/form/src/main/res/values-da/strings.xml @@ -1,7 +1,6 @@ Error - Date Due date Close Incident date diff --git a/form/src/main/res/values-en-rUS/strings.xml b/form/src/main/res/values-en-rUS/strings.xml index 4f419438db0..b6b3ecf89fe 100644 --- a/form/src/main/res/values-en-rUS/strings.xml +++ b/form/src/main/res/values-en-rUS/strings.xml @@ -11,16 +11,9 @@ This field is mandatory You have some warning messages. Required - Date - Years - Months - Days - %s can\'t be parsed as a date Polygon Latitude Longitude - Coordinates values are not valid - Polygon captured Enter value Enter url Enter email @@ -35,27 +28,18 @@ Enter long text Enter text Due date - Mandatory field - Lat: - Long: Not saved Review Complete Discard changes Saved! Not now - Save anyway Keep editing Do you want to mark this form as complete? If you exit now all the information in the form will be discarded. - Some fields have errors and they are not saved. - Some mandatory fields are missing and the form cannot be saved. Share Close - Program rules warning - There is a configuration issue causing a loop in the rules. Contact you administrator. Copied - No info for this field Take Photo From gallery Check this! @@ -65,13 +49,10 @@ Coordinates for the There are no more reserved values. Contact your system administrator Incident date - The location permission is needed for capturing coordinates. Please, enable them in the application settings Open with No option found Download Scan - QR code - Bar code Add location This form has no fields configured %s details diff --git a/form/src/main/res/values-es-rES/strings.xml b/form/src/main/res/values-es-rES/strings.xml index 7bc7a7d781f..88b07f7aa21 100644 --- a/form/src/main/res/values-es-rES/strings.xml +++ b/form/src/main/res/values-es-rES/strings.xml @@ -14,11 +14,10 @@ Meses Días %s no es una fecha válida + Tienes algunos mensajes de alerta.\n¿Desea marcar el formulario como Completo? Polígono Latitud Longitud - El valor de las coordenadas no es válido - Polígono guardado Introduzca valor Introduzca url Introduzca email @@ -33,27 +32,21 @@ Introducir texto largo Introducir texto Fecha de vencimiento - Campo obligatorio - Lat: - Long: No guardado Revisar Completar Descartar los cambios ¡Guardado! Ahora no - Guardar de todos modos Seguir editando ¿Desea marcar este formulario como completo? Si sale toda la información del formulario será descartada. Algunos campos tienen errores y no serán guardados. Algunos campos obligatorios faltan llenarse, el formulario no puede ser guardado + Algunos campos tienen errores y no serán guardados. \nSi sale, los cambios serán descartados. Compartir Cerrar - Advertencia de reglas del programa - Hay un problema de configuración que provoca un bucle en las reglas. Póngase en contacto con su administrador. Copiado - No hay información para este campo Tomar foto ¡Importante! No volver a mostrar @@ -62,7 +55,6 @@ Coordenadas para No quedan valores reservados. Contacte con el administrador del sistema. Fecha del incidente - Se requiere el permiso de localización para obtener coordenadas. POR FAVOR, habilítelo desde los ajustes del dispositivo. Unidad Organizativa Cat Combo \ No newline at end of file diff --git a/form/src/main/res/values-es/strings.xml b/form/src/main/res/values-es/strings.xml index 1e2d4d6e2dd..e10b858837f 100644 --- a/form/src/main/res/values-es/strings.xml +++ b/form/src/main/res/values-es/strings.xml @@ -12,16 +12,9 @@ Tienes algunos mensajes de aviso. Tiene algunos mensajes de advertencia.\n¿Deseas marcar este formulario como completo\? Necesario - Fecha - Años - Meses - Días - %s no es una fecha válida Polígono Latitud Longitud - El valor de las coordenadas no es válido - Polígono guardado Introduzca valor Introduzca url Introduzca email @@ -36,9 +29,6 @@ Introducir texto largo Introducir texto Fecha de vencimiento - Campo obligatorio - Lat: - Long: No guardado Revisar Completar @@ -48,21 +38,17 @@ Algunos campos tienen errores y no se han guardado.\n¿Deseas revisar el formulario\? Algunos campos necesitan tu atención.\n¿Deseas revisar el formulario\? Ahora no - Guardar de todos modos Seguir editando ¿Desea marcar este formulario como completo? Si sale toda la información del formulario será descartada. - Algunos campos presentan errores y no se han guardado.\nSi sales ahora, los cambios se descartarán. Algunos campos tienen errores y no serán guardados. Algunos campos obligatorios faltan llenarse, el formulario no puede ser guardado Faltan algunos campos obligatorios y el formulario no puede guardarse.\nSi sales ahora, los cambios se descartarán. + Algunos campos tienen errores y no serán guardados. \nSi sale, los cambios serán descartados. Compartir El permiso de cámara está denegado.\nDebes activarlo para usar esta función. Cerrar - Advertencia de reglas del programa - Hay un problema de configuración que provoca un bucle en las reglas. Póngase en contacto con su administrador. Copiado - No hay información para este campo Tomar foto Desde la galería ¡Importante! @@ -72,13 +58,10 @@ Coordenadas para No quedan valores reservados. Contacte con el administrador del sistema. Fecha del incidente - Se requiere el permiso de localización para obtener coordenadas. Por favor, habilítelo desde los ajustes del dispositivo. Abrir con No se ha encontrado opción Descargar Escanear - Código QR - Código de barras Añadir ubicación Este formulario no tiene campos configurados %s detalles diff --git a/form/src/main/res/values-fr/strings.xml b/form/src/main/res/values-fr/strings.xml index ed4c603d9f2..d3c8bd3f860 100644 --- a/form/src/main/res/values-fr/strings.xml +++ b/form/src/main/res/values-fr/strings.xml @@ -11,16 +11,9 @@ Ce champ est obligatoire Vous avez des messages d\'avertissement. Requis - Date - Années - Mois - Jours - %s ne peut pas être analysé comme une date Polygone Latitude Longitude - Les valeurs des coordonnées ne sont pas valides - Le polygône a été défini Entrez une valeur Entrez url Entrez e-mail @@ -35,25 +28,21 @@ Saisir texte long Saisir du texte Date d\'échéance - Champ obligatoire Non enregistré! Vérifier Terminer Annuler les modifications Enregistré! Pas maintenant - Enregistrer quand même Continuer l’édition Voulez-vous marquer ce formulaire comme complet? Si vous quittez maintenant, toutes les informations du formulaire seront supprimées. Certains champs contiennent des erreurs et ils ne sont pas enregistrés. Certains champs obligatoires manquent et le formulaire ne peut pas être enregistré. + Certains champs contiennent des erreurs et ils ne sont pas enregistrés. \ nSi vous quittez maintenant, les modifications seront ignorées. Partager Fermer - Avertissement relatif aux règles du programme - Il y a un problème de configuration provoquant une boucle dans les règles. Contactez votre administrateur. Copié - Pas d\'information pour ce champ Prendre une photo Attention. Vérifiez ceci! Ne pas montrer de nouveau @@ -62,13 +51,10 @@ Coordonnées pour Il n\'y a plus de valeurs réservées. Veuillez contacter votre administrateur système. Date de l´incident - L\'autorisation de localisation est nécessaire pour capturer les coordonnées. Veuillez les activer dans les paramètres de l\'application Ouvrir avec Aucune option trouvée Télécharger Scanner - Code QR - Code-barre Ajouter un lieu Ce formulaire n\'a pas de champs configurés %s détails diff --git a/form/src/main/res/values-hi-rIN/strings.xml b/form/src/main/res/values-hi-rIN/strings.xml index 3fc9d264536..4393d506f2d 100644 --- a/form/src/main/res/values-hi-rIN/strings.xml +++ b/form/src/main/res/values-hi-rIN/strings.xml @@ -9,11 +9,6 @@ आपके पास कुछ चेतावनी संदेश हैं आपके पास कुछ चेतावनी संदेश हैं\nक्या आप इस फ़ॉर्म को कम्प्लीट मार्क करना चाहते हैं\? आवश्यकता है - तारीख - वर्ष - महीने - दिन - %s डेट के रूप में पढ़ा नहीं जा सकता अक्षांश देशांतर मान दर्ज करें @@ -26,15 +21,12 @@ अक्षर दर्ज करें लंबा अक्षर दर्ज करें पाठ दर्ज करें - अक्षांश - देशांतर सेव नहीं रिव्यू पूरा हुआ बदलावों को छोड़ना है सेव अभी नहीं - सेव करें संपादन करते रहें साझा करें बंद करें diff --git a/form/src/main/res/values-id/strings.xml b/form/src/main/res/values-id/strings.xml index f823e81bae7..2bdafe0001a 100644 --- a/form/src/main/res/values-id/strings.xml +++ b/form/src/main/res/values-id/strings.xml @@ -7,15 +7,9 @@ Pilih unit organisasi Bidang ini wajib Wajib - Tanggal - Tahun - Bulan - Hari Poligon Garis lintang Garis bujur - Nilai koordinat tidak valid - Poligon ditangkap Masukkan nilai Masukkan URL Masukan email @@ -33,12 +27,10 @@ Lengkap Hapus perubahan Tersimpan! - Simpan bagaimanapun Bagikan Izin kamera ditolak.\nAnda harus mengaktifkannya untuk menggunakan fitur ini. Tutup Tersalin - Tidak ada info untuk bidang ini Ambil gambar Periksa ini! Jangan perlihatkan lagi diff --git a/form/src/main/res/values-km/strings.xml b/form/src/main/res/values-km/strings.xml index 4c7c0e6d5cc..cbd574eee1a 100644 --- a/form/src/main/res/values-km/strings.xml +++ b/form/src/main/res/values-km/strings.xml @@ -3,10 +3,6 @@ កំហុសបច្ចេកទេស ជ្រើសរើសថ្ងៃខែឆ្នាំ ទាមទារ - កាលបរិច្ឆេទ - ឆ្នាំ - ខែ - ថ្ងៃ រយៈទទឹង រយៈបណ្តោយ បញ្ជូលទិន្នន័យជាលេខ (វិជ្ជមាន រឺ លេខ ០) diff --git a/form/src/main/res/values-lo/strings.xml b/form/src/main/res/values-lo/strings.xml index 29222df2b69..c641b7d3496 100644 --- a/form/src/main/res/values-lo/strings.xml +++ b/form/src/main/res/values-lo/strings.xml @@ -10,16 +10,9 @@ ຄ່າຂອງຫ້ອງປ້ອນຂໍ້ມູນ%sບໍ່ໄດ້ຂຶ້ນກັບຕົວເລືອກທີ່ຕັ້ງໄວ້ໃນ%s ຊ່ອງປ້ອນຂໍ້ມູນນີ້ແມ່ນຈໍາເປັນຕ້ອງປ້ອນຂໍ້ມູນໃສ່ ຕ້ອງການ - ວັນທີ່ - ປີ - ເດືອນ - ມື້ - %sບໍ່ສາມາດວິເຄາະເປັນວັນທີໄດ້ Polygon ເສັ້ນຂະໜານ ເສັ້ນແວງ - ປະສານງານບໍ່ຖືກຕ້ອງ - ບັນທຶກຮູບຫຼາຍຫຼ່ຽມ ປ້ອນຄ່າ ປ້ອນ url ປ້ອນອີເມລ @@ -34,23 +27,18 @@ ປ້ອນຂໍ້ມູນເປັນຂໍ້ຄວາມຍາວ ຂຽນຄໍາຕອບ ວັນທີຕ້ອງແລ້ວ - ຊ່ອງປ້ອນຂໍ້ມູນທີ່ບັງຄັບ ບໍ່ໄດ້ບັນທຶກ ທົບທວນຄືນ ສຳເລັດແລ້ວ ລຶບການປ່ຽນແປງ ບັນທຶກແລ້ວ! ບໍ່​ແມ່ນ​ຕອນ​ນີ້ - ບັນທຶກ ສຶບຕໍ່ແກ້ໄຂ ທ່ານຕ້ອງການໝາຍແບບຟອມນີ້ວ່າສຳເລັດແລ້ວຫຼືບໍ? ຖ້າທ່ານອອກດຽວນີ້ ຂໍ້ມູນທັງໝົດໃນແບບຟອມຈະຖືກຍົກເລີກ. ເຜີຍແຜ່ ປິດ - ຂໍ້ຄວນລະວັງກົດຂອງສາຍງານ - ມີບັນຫາການກຳນົດຄ່າທີ່ເຮັດໃຫ້ເກີດມີຊ່ອງຫວ່າງໃນກົດລະບຽບ. ຕິດຕໍ່ຜູ້ດູແລລະບົບຂອງທ່ານ. ສຳເນົາແລ້ວ - ບໍ່ມີຂໍ້ມູນສຳລັບຫ້ອງຂໍ້ມູນນີ້ ຖ່າຍຮູບ ກວດເບີ່ງອັນນີ້ ບໍ່ຕ້ອງສະແດງອີກ diff --git a/form/src/main/res/values-my/strings.xml b/form/src/main/res/values-my/strings.xml index 6f007af625e..c6b413b6074 100644 --- a/form/src/main/res/values-my/strings.xml +++ b/form/src/main/res/values-my/strings.xml @@ -3,9 +3,6 @@ အမှား ရက်စွဲ ကိုရွေးချယ်ပါ ဖြည့်သွင်းရန်လိုအပ်သည် - ရက်စွဲ - နှစ်များ - လများ Дата выполнения ပြည့်စုံသည်။ ပိတ်သည် diff --git a/form/src/main/res/values-nb/strings.xml b/form/src/main/res/values-nb/strings.xml index b5b0f8fb3cb..240d55b56b9 100644 --- a/form/src/main/res/values-nb/strings.xml +++ b/form/src/main/res/values-nb/strings.xml @@ -7,15 +7,9 @@ Velg organisasjonsenhet Dette feltet er obligatorisk Påkrevd - Dato - År - Måneder - Dager Polygon Breddegrad Lengdegrad - Koordinatverdiene er ikke gyldige - Polygon oppfanget Legg inn verdi Legg inn URL Legg inn e-post @@ -33,12 +27,10 @@ Fullfør Forkast endringer Lagret! - Lagre likevel Fortsett å rediger Del Lukk Kopiert - Ingen info for dette feltet Sjekk dette! Attributter - %s Koordinater for diff --git a/form/src/main/res/values-ne/strings.xml b/form/src/main/res/values-ne/strings.xml index 39f97f62bf6..efe0a1d8ee1 100644 --- a/form/src/main/res/values-ne/strings.xml +++ b/form/src/main/res/values-ne/strings.xml @@ -1,4 +1,3 @@ - मिति \ No newline at end of file diff --git a/form/src/main/res/values-nl/strings.xml b/form/src/main/res/values-nl/strings.xml index cee4ecf9223..36972853645 100644 --- a/form/src/main/res/values-nl/strings.xml +++ b/form/src/main/res/values-nl/strings.xml @@ -10,16 +10,9 @@ Waarde voor veld %s behoort niet tot de optieset in %s Dit veld is verplicht Vereist - Datum - Jaren - Maanden - Dagen - %s kan niet worden verwerkt als een datum Veelhoek Breedtegraad Lengtegraad - Coördinatenwaarden zijn niet geldig - Polygoon vastgelegd Voer waarde in URL invoeren Voer email in @@ -34,27 +27,21 @@ Voer lange tekst in Tekst invoeren Deadline - Verplicht veld - Breedte: - Lang: Niet opgeslagen Beoordeling Compleet Veranderingen ongedaan maken Opgeslagen! Niet nu - Toch opslaan Blijf bewerken Wilt u dit formulier als voltooid markeren? Als u nu afsluit, wordt alle informatie in het formulier weggegooid. Sommige velden bevatten fouten en worden niet opgeslagen. Sommige verplichte velden ontbreken en het formulier kan niet worden opgeslagen. + Sommige velden bevatten fouten en worden niet opgeslagen. \Als u nu afsluit, worden de wijzigingen verwijderd. Deel Sluit - Waarschuwing programmaregels - Er is een configuratieprobleem dat een lus in de regels veroorzaakt. Neem contact op met uw beheerder. Gekopieerd - Geen info voor dit veld Neem foto Check dit! Niet meer laten zien @@ -63,7 +50,6 @@ Coördinaten voor de Er zijn geen gereserveerde waarden meer. Neem contact op met uw systeembeheerder Incident datum - De locatietoestemming is nodig voor het vastleggen van coördinaten. Schakel ze alstublieft in de applicatie-instellingen in Openen met Geen optie gevonden Downloaden diff --git a/form/src/main/res/values-prs/strings.xml b/form/src/main/res/values-prs/strings.xml index fe9eae69f5d..7a2e0d64a6d 100644 --- a/form/src/main/res/values-prs/strings.xml +++ b/form/src/main/res/values-prs/strings.xml @@ -2,10 +2,6 @@ خطا ضروری - تاریخ - سالانه - ماه ها - روزها چند ضلعی عرض البلد طول البلد diff --git a/form/src/main/res/values-ps/strings.xml b/form/src/main/res/values-ps/strings.xml index 03d9781ed48..f2f7e61f1e9 100644 --- a/form/src/main/res/values-ps/strings.xml +++ b/form/src/main/res/values-ps/strings.xml @@ -2,10 +2,6 @@ تېروته اړین دي - نېټه - کلونه - میاشت - ورځې لېټېټيوډ لانګېټيوډ ټاکلې نېټه diff --git a/form/src/main/res/values-pt-rBR/strings.xml b/form/src/main/res/values-pt-rBR/strings.xml index 022afde4550..2c2d2e33f43 100644 --- a/form/src/main/res/values-pt-rBR/strings.xml +++ b/form/src/main/res/values-pt-rBR/strings.xml @@ -2,14 +2,9 @@ Erro Exigido - Data - Anos - Meses - Dias Poligono Latitude Longitude - Polígono capturado Data de vencimento Completo Partilhar diff --git a/form/src/main/res/values-pt/strings.xml b/form/src/main/res/values-pt/strings.xml index e23cbc70f87..a50a97fea74 100644 --- a/form/src/main/res/values-pt/strings.xml +++ b/form/src/main/res/values-pt/strings.xml @@ -11,16 +11,9 @@ Este campo é obrigatório Tem algumas mensagens de aviso. Obrigatório - Data - Anos - Meses - Dias - %s não pode ser analisado como uma data Poligono Latitude Longitude - Os valores das coordenadas não são válidos - Polígono capturado Digite o valor Digite o URL Digite o email @@ -35,27 +28,21 @@ Inserir texto longo Inserir texto Data de Vencimento - Campo obrigatório - Latitude: - Longitude: Não guardado Revisão Completo Descartar mudanças Gravado! Não agora - Gravar assim mesmo Continue editando Deseja marcar este formulário como completo? Se sair agora, todas as informações do formulário serão descartadas. Alguns campos apresentam erros e não são guardados. Alguns campos obrigatórios estão em falta e o formulário não pode ser guardado. + Alguns campos têm erros e não são guardados. \Se sair agora, as suas alterações serão descartadas. Partilha Fechar - Aviso de regras do programa - Existe um problema de configuração que está a provocar um ciclo nas regras. Contacte o seu administrador. Copiado - Nenhuma informação para este campo Tirar fotografia Verifique isso! Não mostrar novamente @@ -64,13 +51,10 @@ Coordenadas para o Não há mais valores reservados. Entre em contato com o administrador do sistema Data do incidente - A permissão de localização é necessária para captar as coordenadas. Por favor, ative-a nas definições do aplicativo Abrir com Nenhuma opção encontrada Descarregar Digitalizar - Código QR - Código de barras Adicionar localização Este formulário não tem campos configurados %s detalhes diff --git a/form/src/main/res/values-ro/strings.xml b/form/src/main/res/values-ro/strings.xml index be206b05e5b..a6299e631c1 100644 --- a/form/src/main/res/values-ro/strings.xml +++ b/form/src/main/res/values-ro/strings.xml @@ -1,11 +1,6 @@ Eroare - Data - Ani - Luni - Zile - Salvare oricum Partajare Închidere Data incidentului diff --git a/form/src/main/res/values-ru/strings.xml b/form/src/main/res/values-ru/strings.xml index 833482f8d39..d43ba453726 100644 --- a/form/src/main/res/values-ru/strings.xml +++ b/form/src/main/res/values-ru/strings.xml @@ -11,15 +11,9 @@ У Вас есть несколько предупреждающих сообщений. У Вас есть несколько предупреждающих сообщений.\nХотите ли Вы отметить эту форму как завершенную\? Обязательный - Дата - Годы - Месяцы - Дни Полигон Широта Долгота - Значения координат не действительны - Полигон зафиксирован Введите значение Введите url Введите электронную почту @@ -34,7 +28,6 @@ Ввод длинного текста Введите текст Дата выполнения - Обязательное поле Не сохранено Просмотр Завершенный @@ -44,21 +37,17 @@ В некоторых полях есть ошибки, и они не сохраняются. \nВы хотите просмотреть форму\? Некоторые поля требуют Вашего внимания.\nХотите просмотреть форму\? Не сейчас - Сохранить в любом случае Продолжить редактировать Хотите ли Вы отметить эту форму как завершенную? Если Вы выйдете сейчас, вся информация в форме будет удалена. - Некоторые поля содержат ошибки, и они не сохраняются. \nЕсли Вы выйдете сейчас, изменения будут отменены. Некоторые поля содержат ошибки, и они не сохранены. Некоторые обязательные поля не заполнены, и форму невозможно сохранить. Некоторые обязательные поля отсутствуют, и форма не может быть сохранена. \nЕсли вы выйдете сейчас, изменения будут отменены. + Некоторые поля содержат ошибки, и они не сохраняются. Если Вы выйдете сейчас, изменения будут отменены. Поделиться Разрешение камеры запрещено.\nЧтобы использовать эту функцию, ее необходимо разрешить. Закрыть - Предупреждение о правилах программы - Существует проблема конфигурации, вызывающая зацикливание правил. Обратитесь к администратору. Скопировано - Нет информации для этого поля Сделать фото Из галереи Проверьте это! @@ -68,13 +57,10 @@ Координаты для Больше нет зарезервированных значений. Обратитесь к системному администратору Дата происшествия - Для захвата координат необходимо разрешение на определение местоположения. Пожалуйста, включите их в настройках приложения Открыть с помощью Опции не найдены Скачать Сканировать - QR-код - Штрих-код Добавить локацию В этой форме нет настроенных полей %s деталей diff --git a/form/src/main/res/values-si/strings.xml b/form/src/main/res/values-si/strings.xml index 1e933248190..4aaa0fc8c4b 100644 --- a/form/src/main/res/values-si/strings.xml +++ b/form/src/main/res/values-si/strings.xml @@ -1,10 +1,6 @@ දෝෂය - දිනය - අවුරුදු - මාස - දින වෙනස් කිරීම් ඉවතලන්න වසන්න බාගත කරන්න diff --git a/form/src/main/res/values-sv/strings.xml b/form/src/main/res/values-sv/strings.xml index 502e2849be2..55ab9a3b71b 100644 --- a/form/src/main/res/values-sv/strings.xml +++ b/form/src/main/res/values-sv/strings.xml @@ -3,10 +3,6 @@ Fel Välj datum Nödvändig - Datum - År - Månader - Dagar Latitud Longitud Ange ett positivt heltal eller noll. diff --git a/form/src/main/res/values-tet/strings.xml b/form/src/main/res/values-tet/strings.xml index c127ca9ac6f..00e97d80005 100644 --- a/form/src/main/res/values-tet/strings.xml +++ b/form/src/main/res/values-tet/strings.xml @@ -2,9 +2,6 @@ Erru Nesesáriu - Data - Tinan - Fulan Kompletu Partilla Taka diff --git a/form/src/main/res/values-tg/strings.xml b/form/src/main/res/values-tg/strings.xml index d2575c6d443..184beac6486 100644 --- a/form/src/main/res/values-tg/strings.xml +++ b/form/src/main/res/values-tg/strings.xml @@ -2,9 +2,6 @@ Хатогӣ Ҳатмӣ - Сана - Солҳо - Моҳҳо Бисёркунҷа Тӯл Арз diff --git a/form/src/main/res/values-uk/strings.xml b/form/src/main/res/values-uk/strings.xml index a54098a1157..b905503413a 100644 --- a/form/src/main/res/values-uk/strings.xml +++ b/form/src/main/res/values-uk/strings.xml @@ -10,16 +10,9 @@ Значення для поля %s не належить параметру, встановленому в %s Це поле обов\'язкове для заповнення Обов\'язкове поле - Дата - Роки - Місяці - Дні - %s не можна проаналізувати як дату Багатокутник Широта Довгота - Значення координат недійсні - Зафіксовано багатокутник Введіть значення Введіть URL-адресу Введіть адресу електронної пошти @@ -34,9 +27,6 @@ Введіть довгий текст Введіть текст Запланована дата - Обов\'язкове поле - Широта: - Довгота: Не збережено Перегляд Завершити @@ -48,12 +38,10 @@ Якщо Ви зараз вийдете, уся інформація у формі буде відхилена. Деякі поля містять помилки і не були збережені. Деякі обов’язкові поля не заповнено, і форму неможливо зберегти. + Деякі поля містять помилки і не були збережені. Якщо Ви вийдете зараз, внесені зміни буде скасовано. Поділитися Закрити - Попередження про правила програми - Існує проблема конфігурації, яка спричиняє помилку у правилах. Зверніться до адміністратора. Скопійовано - Немає інформації для цього поля Зробити фото Перевірте це! Не показувати знову @@ -63,7 +51,6 @@ Більше немає зарезервованих значень. Зверніться до свого системного адміністратора Дата інциденту Завантажити - QR-код Додати місцезнаходження %s Деталі Організаційний підрозділ diff --git a/form/src/main/res/values-ur/strings.xml b/form/src/main/res/values-ur/strings.xml index 54ef83f7f58..25ed7a141d5 100644 --- a/form/src/main/res/values-ur/strings.xml +++ b/form/src/main/res/values-ur/strings.xml @@ -2,13 +2,10 @@ غلطی لازمی - تاریخ - سال - مہینے - دن کثیر زبان طول لمبائی + کثیر الاضلاع (حدود) محفوظ کر لی گئیں اخری تاریخ مکمل بانٹیں diff --git a/form/src/main/res/values-uz-rUZ/strings.xml b/form/src/main/res/values-uz-rUZ/strings.xml index 9abebe48ea0..e02469217ec 100644 --- a/form/src/main/res/values-uz-rUZ/strings.xml +++ b/form/src/main/res/values-uz-rUZ/strings.xml @@ -7,15 +7,9 @@ Tashkiliy boʼlimni tanlang Maydon toʼldirilishi majburiy Majburiy - Sana - Yillar - Oylar - Kunlar Poligon Kenglik Kenglik - Koordinata qiymatlari yaroqsiz - Koʼpburchak ushlandi Qiymatni tanlang URL ni tanlang ye-pochta manzilini kiriting @@ -34,12 +28,10 @@ Bajarildi Oʼzgartirishlar bekor qilinsin Saqlandi! - Nima boʼlganda ham saqlash Tahrirni davom eting Улашиш Yopmoq Nusxa olindi - Ushbu maydon uchun maʼlumot yoʼq Buni tekshiring! Аtribatlar - %s Uchun koordinatalar diff --git a/form/src/main/res/values-uz/strings.xml b/form/src/main/res/values-uz/strings.xml index 8326314f01a..ba47049049a 100644 --- a/form/src/main/res/values-uz/strings.xml +++ b/form/src/main/res/values-uz/strings.xml @@ -7,15 +7,9 @@ Ташкилий бўлимни танланг Майдон тўлдирилиши мажбурий Мажбурий - Сана - Йиллар - Ойлар - Кунлар Полигон Кенглик Узунлик - Координата қийматлари яроқсиз - Кўпбурчак ушланди Қийматни танланг URL ни танланг е-почта манзилини киритинг @@ -35,12 +29,10 @@ Ўзгартиришлар бекор қилинсин Сақланди! Hozir emas - Нима бўлганда ҳам сақлаш Таҳрирни давом этинг Улашиш Ёпиш Нусха олинди - Ушбу майдон учун маълумот йўқ Суратга олиш Буни текширинг! Атрибатлар - %s @@ -48,7 +40,6 @@ Сақланган бошқа қийматлар йўқ. Тизим администраторига мурожаат қилинг. Ҳодиса санаси Юклаш - QR код Локация қўшинг Ташкилий бўлим Координаталар diff --git a/form/src/main/res/values-vi/strings.xml b/form/src/main/res/values-vi/strings.xml index 3787bb48c40..bb8a52b73b6 100644 --- a/form/src/main/res/values-vi/strings.xml +++ b/form/src/main/res/values-vi/strings.xml @@ -12,16 +12,9 @@ Bạn có một số tin nhắn cảnh báo. Bạn có một số tin nhắn cảnh báo..\nBạn có muốn đánh dấu biểu nhập này là hoàn tất không\? Bắt buộc - Ngày - Năm - Tháng - Ngày - %s không thể chuyển đổi thành Ngày Bản đồ Vĩ độ Kinh độ - Giá trị tọa độ không đúng - Thêm bản đồ Nhập dữ liệu Nhập đường dẫn liên kết Nhập email @@ -36,9 +29,6 @@ Nhập văn bản dài Nhập văn bản Ngày đến hạn - Trường bắt buộc - Vĩ độ: - Kinh độ: Chưa lưu Xem xét Hoàn tất @@ -48,21 +38,17 @@ Một số trường dữ liệu có lỗi và chưa được lưu. \nBạn có muốn xem lại biểu nhập không\? Một số trường dữ liệu cần bạn chú ý.\nBạn có muốn xem xét biểu nhập không\? Không phải bây giờ - Tiếp tục Lưu Tiếp tục chỉnh sửa Bạn có muốn đánh dấu biểu nhập này là hoàn tất không? Nếu bạn tắt bây giờ, tất cả thông tin trong biểu nhập sẽ bị hủy bỏ. - Một số trường dữ liệu có lỗi và chưa được lưu.. \nNếu bạn tắt bây giờ, tất cả thay đổi sẽ bị hủy bỏ. Một số trường dữ liệu bị lỗi và chưa được lưu Thiếu một số trường dữ liệu bắt buộc và Không thể lưu biểu nhập. hiếu một số trường dữ liệu bắt buộc và không thể lưu biểu nhập. \nNếu bạn tắt bây giờ, tất cả thay đổi sẽ bị hủy bỏ.. + Một số trường dữ liệu có lỗi và chưa được lưu.\n Nếu bạn tắt bây giờ, tất cả thay đổi sẽ bị hủy bỏ. Chia sẻ Quyền mở máy ảnh bị từ chối..\nBạn cần bật tính năng máy ảnh để sử dụng chức năng này. Đóng - Cảnh báo quy tắc chương trình - Có một vấn đề cấu hình gây ra một vòng lặp trong các quy tắc chương trình. Hãy liên lạc với quản lý của bạn. Đã sao chép - Không có thông tin cho trường dữ liệu này Chụp ảnh Từ thư viện ảnh Kiểm tra cái này! @@ -72,13 +58,10 @@ Tọa độ của Không có giá trị dành riêng nào nữa. Liên hệ với quản lý của bạn Ngày khởi phát - Cần quyền định vị để thu thập vị trí. Xin vui lòng mở chúng trong cài đặt ứng dụng Mở với Không tìm thấy tùy chọn Tải về Quét - Mã QR - Mã vạch Thêm vị trí Biểu này không có trường dữ liệu được cấu hình Chi tiết %s diff --git a/form/src/main/res/values-zh-rCN/strings.xml b/form/src/main/res/values-zh-rCN/strings.xml index 58632b0adfc..990a2f02c74 100644 --- a/form/src/main/res/values-zh-rCN/strings.xml +++ b/form/src/main/res/values-zh-rCN/strings.xml @@ -11,16 +11,9 @@ 此字段是必填字段 您收到了一些警告信息。 必须的 - 日期 - - - - %s 无法解析为日期 多边形 纬度 经度 - 坐标值无效 - 采集的多边形。 输入值 输入url 输入电子邮件 @@ -35,16 +28,12 @@ 输入长文本 输入文本 到期日 - 强制字段 - 纬度: - 经度: 未保存 审查 完成 忽略修改 已经保存! 现在不要 - 仍然保存 继续编辑 您想将此表单标记为已完成吗? 如果您现在退出,表单中的所有信息都将被丢弃。 @@ -52,10 +41,7 @@ 缺少一些必填字段,无法保存表单。 分享 关闭 - 项目规则警告 - 存在导致规则循环的配置问题。联系您的管理员。 副本 - 该字段没有相关信息 拍照 来自画廊 检查 @@ -65,13 +51,10 @@ 坐标 没有更多保留值,请联系管理员 事件日期 - 获取坐标需要位置权限。请在应用程序设置中启用它们 打开用 找不到选项 下载 扫描 - 二维码 - 条码 添加位置 该表单没有配置任何字段 %s详细信息 diff --git a/form/src/main/res/values-zh/strings.xml b/form/src/main/res/values-zh/strings.xml index 1edf001267b..681caec1189 100644 --- a/form/src/main/res/values-zh/strings.xml +++ b/form/src/main/res/values-zh/strings.xml @@ -12,16 +12,9 @@ 您收到了一些警告信息。 您有一些警告消息。\n您想将此表单标记为完成吗? 必须的 - 日期 - - - - %s 无法解析为日期 多边形 维度 经度 - 坐标值无效 - 采集的多边形。 输入值 输入url 输入电子邮件 @@ -36,9 +29,6 @@ 输入长文本 输入文本 到期日期 - 强制字段 - 纬度: - 经度: 未保存 审查 完成 @@ -48,7 +38,6 @@ 一些字段有错误并且它们没有被保存。 \n您要查看表单吗? 某些字段需要您注意。\n您要查看表单吗? 现在不要 - 仍然保存 继续编辑 您想将此表单标记为已完成吗? 如果您现在退出,表单中的所有信息都将被丢弃。 @@ -59,10 +48,7 @@ 分享 相机权限被拒绝。\n您需要启用它才能使用此功能。 关闭 - 项目规则警告 - 存在导致规则循环的配置问题。联系您的管理员。 副本 - 该字段没有相关信息 拍照 来自画廊 检查 @@ -72,13 +58,10 @@ 坐标 没有更多保留值,请联系管理员 事件日期 - 获取坐标需要位置权限。请在应用程序设置中启用它们 打开用 找不到选项 下载 扫描 - 二维码 - 条码 添加位置 该表单没有配置任何字段 %s详细信息 diff --git a/form/src/main/res/values/strings.xml b/form/src/main/res/values/strings.xml index 7170b91146b..c336695b847 100644 --- a/form/src/main/res/values/strings.xml +++ b/form/src/main/res/values/strings.xml @@ -13,16 +13,9 @@ You have some warning messages. You have some warning messages.\nDo you want to mark this form as complete? Required - Date - Years - Months - Days - %s can\'t be parsed as a date Polygon Latitude Longitude - Coordinates values are not valid - Polygon captured Enter value Enter url Enter email @@ -37,9 +30,6 @@ Enter long text Enter text Due date - Mandatory field - Lat: - Long: Not saved Review Complete @@ -49,21 +39,14 @@ Some fields have errors and they are not saved. \nDo you want to review the form? Some fields need your attention.\nDo you want to review the form? Not now - Save anyway Keep editing Do you want to mark this form as complete? If you exit now all the information in the form will be discarded. Some fields have errors and they are not saved. \nIf you exit now the changes will be discarded. - Some fields have errors and they are not saved. - Some mandatory fields are missing and the form cannot be saved. - Some mandatory fields are missing and the form cannot be saved. \nIf you exit now the changes will be discarded. Share Camera permission is denied.\nYou need to enable it to use this feature. Close - Program rules warning - There is a configuration issue causing a loop in the rules. Contact you administrator. Copied - No info for this field Take Photo From gallery Check this! @@ -73,13 +56,10 @@ Coordinates for the There are no more reserved values. Contact your system administrator Incident date - The location permission is needed for capturing coordinates. Please, enable them in the application settings Open with No option found Download Scan - QR code - Bar code Add location This form has no fields configured %s details 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 e356e9ac561..1d66933b4a1 100644 --- a/form/src/test/java/org/dhis2/form/data/EnrollmentRepositoryTest.kt +++ b/form/src/test/java/org/dhis2/form/data/EnrollmentRepositoryTest.kt @@ -58,7 +58,7 @@ class EnrollmentRepositoryTest { whenever(conf.program()?.selectIncidentDatesInFuture()) doReturn true whenever(conf.captureOrgUnitsCount()) doReturn 10 - whenever(conf.program()?.enrollmentDateLabel()) doReturn "Enrollment Date" + whenever(conf.program()?.displayEnrollmentDateLabel()) doReturn "Enrollment Date" whenever(enrolmentFormLabelsProvider.provideEnrollmentDateDefaultLabel("Program_UID")) doReturn "Enrollment Date" whenever( diff --git a/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt b/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt index f5fb706e9be..8b51b6ef16a 100644 --- a/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt +++ b/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt @@ -10,6 +10,7 @@ import org.dhis2.form.model.EventMode import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.category.CategoryCombo import org.hisp.dhis.android.core.common.Geometry import org.hisp.dhis.android.core.event.Event @@ -258,6 +259,16 @@ class EventRepositoryTest { .byProgramStageUid() .eq(anyOrNull()) .withDataElements() + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC), + ) doReturn mock() + whenever( + d2 + .programModule() + .programStageSections() + .byProgramStageUid() + .eq(anyOrNull()) + .withDataElements() + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) .blockingGet(), ) doReturn listOf(mockedFirstSection, mockedSecondSection) } 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 2e91e0324c4..5899c327e98 100644 --- a/form/src/test/java/org/dhis2/form/data/FormValueStoreTest.kt +++ b/form/src/test/java/org/dhis2/form/data/FormValueStoreTest.kt @@ -731,6 +731,7 @@ class FormValueStoreTest { .builder() .uid("fieldUid") .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() val storeResult = deValueStore.deleteOptionValueIfSelected( @@ -1178,6 +1179,7 @@ class FormValueStoreTest { .builder() .uid(testingUid) .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() whenever( d2 @@ -1386,6 +1388,7 @@ class FormValueStoreTest { .builder() .uid("uid") .valueType(ValueType.TEXT) + .categoryCombo(ObjectWithUid.create("categoryComboUid")) .build() private fun mockedUniqueAttribute(): TrackedEntityAttribute = diff --git a/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt b/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt index e82111ac419..252ec89ee86 100644 --- a/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt +++ b/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt @@ -2,6 +2,7 @@ package org.dhis2.form.integration import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.databinding.ObservableField +import app.cash.turbine.test import io.reactivex.Flowable import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -31,6 +32,7 @@ import org.dhis2.form.model.StoreResult import org.dhis2.form.model.ValueStoreResult import org.dhis2.form.ui.FormViewModel import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.mapper.FormSectionMapper import org.dhis2.form.ui.provider.FormResultDialogProvider import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 @@ -110,10 +112,10 @@ class ProgramRulesTest { } whenever(formValueStore.save(any(), anyOrNull(), anyOrNull())) doReturn - StoreResult( - "", - ValueStoreResult.VALUE_CHANGED, - ) + StoreResult( + "", + ValueStoreResult.VALUE_CHANGED, + ) repository = FormRepositoryImpl( @@ -142,6 +144,7 @@ class ProgramRulesTest { }, geometryController, resultDialogUiProvider = resultDialogUiProvider, + formSectionMapper = FormSectionMapper(), ) testingDispatcher.scheduler.advanceUntilIdle() @@ -158,17 +161,17 @@ class ProgramRulesTest { fun `Should assign a value`() = runTest { whenever(ruleEngineHelper.evaluate()) doReturn - listOf( - RuleEffect( - "", - RuleAction( - "assignedValue", - ProgramRuleActionType.ASSIGN.name, - mutableMapOf(Pair("field", "uid001")), + listOf( + RuleEffect( + "", + RuleAction( + "assignedValue", + ProgramRuleActionType.ASSIGN.name, + mutableMapOf(Pair("field", "uid001")), + ), + "newValue", ), - "newValue", - ), - ) + ) val intent = FormIntent.OnSave( @@ -177,12 +180,13 @@ class ProgramRulesTest { valueType = ValueType.TEXT, ) - formViewModel.submitIntent(intent) - advanceUntilIdle() + formViewModel.items.test { + formViewModel.submitIntent(intent) + val items = awaitItem() - val items = formViewModel.items.value ?: emptyList() + assert(items.find { it -> it.fields.find { field -> field.uid == "uid001" }?.value == "newValue" } != null) + } - assert(items.find { it.uid == "uid001" }?.value == "newValue") } @OptIn(ExperimentalCoroutinesApi::class) @@ -190,19 +194,19 @@ class ProgramRulesTest { fun `Should hide field`() = runTest { whenever(ruleEngineHelper.evaluate()) doReturn - listOf( - RuleEffect( - "ruleUid", - RuleAction( - "data", - ProgramRuleActionType.HIDEFIELD.name, - mutableMapOf( - "content" to "content", - "field" to "uid001", + listOf( + RuleEffect( + "ruleUid", + RuleAction( + "data", + ProgramRuleActionType.HIDEFIELD.name, + mutableMapOf( + "content" to "content", + "field" to "uid001", + ), ), ), - ), - ) + ) val intent = FormIntent.OnSave( @@ -211,15 +215,17 @@ class ProgramRulesTest { valueType = ValueType.TEXT, ) - formViewModel.submitIntent(intent) - advanceUntilIdle() - - val items = formViewModel.items.value ?: emptyList() + formViewModel.items.test { + formViewModel.submitIntent(intent) + val sections = awaitItem() - items.forEach { - assert(it.uid != "uid001") + sections.forEach { + assert(it.fields.size == 6) + it.fields.forEach { field -> + assert(field.uid != "uid001") + } + } } - assert(items.size == 6) } @OptIn(ExperimentalCoroutinesApi::class) @@ -227,20 +233,20 @@ class ProgramRulesTest { fun `Should hide section`() = runTest { whenever(ruleEngineHelper.evaluate()) doReturn - listOf( - RuleEffect( - "ruleUid", - RuleAction( - "data", - ProgramRuleActionType.HIDESECTION.name, - mutableMapOf( - "content" to "content", - "programStageSection" to "section1", + listOf( + RuleEffect( + "ruleUid", + RuleAction( + "data", + ProgramRuleActionType.HIDESECTION.name, + mutableMapOf( + "content" to "content", + "programStageSection" to "section1", + ), ), + "data", ), - "data", - ), - ) + ) val intent = FormIntent.OnSave( @@ -249,16 +255,18 @@ class ProgramRulesTest { valueType = ValueType.TEXT, ) - formViewModel.submitIntent(intent) - advanceUntilIdle() + formViewModel.items.test { + formViewModel.submitIntent(intent) + val sections = awaitItem() - val items = formViewModel.items.value ?: emptyList() + assert(sections.size == 1) + assertTrue(sections.first().fields.size == 4) + assertTrue(sections.first().fields[0].uid == "uid004") + assertTrue(sections.first().fields[1].uid == "uid005") + assertTrue(sections.first().fields[2].uid == "uid006") + assertTrue(sections.first().fields[3].uid == "uid007") + } - assertTrue(items.size == 4) - assertTrue(items[0].uid == "uid004") - assertTrue(items[1].uid == "uid005") - assertTrue(items[2].uid == "uid006") - assertTrue(items[3].uid == "uid007") } @OptIn(ExperimentalCoroutinesApi::class) @@ -266,32 +274,32 @@ class ProgramRulesTest { fun `Should show warning and error message`() = runTest { whenever(ruleEngineHelper.evaluate()) doReturn - listOf( - RuleEffect( - "ruleUid", - RuleAction( - "data", - ProgramRuleActionType.SHOWWARNING.name, - mutableMapOf( - "content" to "content", - "field" to "uid002", + listOf( + RuleEffect( + "ruleUid", + RuleAction( + "data", + ProgramRuleActionType.SHOWWARNING.name, + mutableMapOf( + "content" to "content", + "field" to "uid002", + ), ), + "warning message", ), - "warning message", - ), - RuleEffect( - "ruleUid2", - RuleAction( - "data", - ProgramRuleActionType.SHOWERROR.name, - mutableMapOf( - "content" to "content", - "field" to "uid005", + RuleEffect( + "ruleUid2", + RuleAction( + "data", + ProgramRuleActionType.SHOWERROR.name, + mutableMapOf( + "content" to "content", + "field" to "uid005", + ), ), + "error message", ), - "error message", - ), - ) + ) val intent = FormIntent.OnSave( @@ -300,19 +308,21 @@ class ProgramRulesTest { valueType = ValueType.TEXT, ) - formViewModel.submitIntent(intent) - advanceUntilIdle() - - val items = formViewModel.items.value ?: emptyList() - - items.forEach { - if (it.uid == "uid002") { - assertNotNull(it.warning) - assertEquals(it.warning, "content warning message") - } - if (it.uid == "uid005") { - assertNotNull(it.error) - assertEquals(it.error, "content error message") + formViewModel.items.test { + formViewModel.submitIntent(intent) + + val sections = awaitItem() + assertTrue(sections.size == 1) + + sections.first().fields.forEach { + if (it.uid == "uid002") { + assertNotNull(it.warning) + assertEquals(it.warning, "content warning message") + } + if (it.uid == "uid005") { + assertNotNull(it.error) + assertEquals(it.error, "content error message") + } } } } @@ -322,20 +332,20 @@ class ProgramRulesTest { fun `Should set mandatory field`() = runTest { whenever(ruleEngineHelper.evaluate()) doReturn - listOf( - RuleEffect( - "ruleUid", - RuleAction( - "data", - ProgramRuleActionType.SETMANDATORYFIELD.name, - mutableMapOf( - "content" to "content", - "field" to "uid003", + listOf( + RuleEffect( + "ruleUid", + RuleAction( + "data", + ProgramRuleActionType.SETMANDATORYFIELD.name, + mutableMapOf( + "content" to "content", + "field" to "uid003", + ), ), + "data", ), - "data", - ), - ) + ) val intent = FormIntent.OnSave( @@ -344,16 +354,17 @@ class ProgramRulesTest { valueType = ValueType.TEXT, ) - formViewModel.submitIntent(intent) - advanceUntilIdle() - - val items = formViewModel.items.value ?: emptyList() - - items.forEach { - if (it.uid == "uid003") { - assertTrue(it.mandatory) + formViewModel.items.test { + formViewModel.submitIntent(intent) + val sections = awaitItem() + assertTrue(sections.size == 1) + sections.first().fields.forEach { + if (it.uid == "uid003") { + assertTrue(it.mandatory) + } } } + } @OptIn(ExperimentalCoroutinesApi::class) @@ -361,21 +372,21 @@ class ProgramRulesTest { fun `Should show option`() = runTest { whenever(ruleEngineHelper.evaluate()) doReturn - listOf( - RuleEffect( - "ruleUid", - RuleAction( - "data", - ProgramRuleActionType.SHOWOPTIONGROUP.name, - mutableMapOf( - "content" to "content", - "field" to "uid006", - "optionGroup" to "optionGroupId", + listOf( + RuleEffect( + "ruleUid", + RuleAction( + "data", + ProgramRuleActionType.SHOWOPTIONGROUP.name, + mutableMapOf( + "content" to "content", + "field" to "uid006", + "optionGroup" to "optionGroupId", + ), ), + "data", ), - "data", - ), - ) + ) whenever( dataEntryRepository.options( @@ -394,10 +405,10 @@ class ProgramRulesTest { ) whenever(formValueStore.deleteOptionValueIfSelected(any(), any())) doReturn - StoreResult( - "uid006", - ValueStoreResult.VALUE_CHANGED, - ) + StoreResult( + "uid006", + ValueStoreResult.VALUE_CHANGED, + ) formViewModel.submitIntent(intent) advanceUntilIdle() @@ -415,21 +426,21 @@ class ProgramRulesTest { fun `Should hide option`() = runTest { whenever(ruleEngineHelper.evaluate()) doReturn - listOf( - RuleEffect( - "ruleUid", - RuleAction( - "data", - ProgramRuleActionType.HIDEOPTION.name, - mutableMapOf( - "content" to "content", - "field" to "uid007", - "option" to "Option2", + listOf( + RuleEffect( + "ruleUid", + RuleAction( + "data", + ProgramRuleActionType.HIDEOPTION.name, + mutableMapOf( + "content" to "content", + "field" to "uid007", + "option" to "Option2", + ), ), + "data", ), - "data", - ), - ) + ) whenever( dataEntryRepository.options( @@ -448,10 +459,10 @@ class ProgramRulesTest { ) whenever(formValueStore.deleteOptionValueIfSelected(any(), any())) doReturn - StoreResult( - "uid007", - ValueStoreResult.VALUE_HAS_NOT_CHANGED, - ) + StoreResult( + "uid007", + ValueStoreResult.VALUE_HAS_NOT_CHANGED, + ) formViewModel.submitIntent(intent) advanceUntilIdle() diff --git a/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt b/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt index a2baa1d88c9..88b11944b12 100644 --- a/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt @@ -2,8 +2,8 @@ package org.dhis2.form.ui import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.databinding.ObservableField -import androidx.lifecycle.Observer import androidx.paging.PagingData +import app.cash.turbine.test import io.reactivex.Flowable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -30,6 +30,7 @@ import org.dhis2.form.model.SectionUiModelImpl import org.dhis2.form.model.StoreResult import org.dhis2.form.model.ValueStoreResult import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.mapper.FormSectionMapper import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.form.ui.provider.FormResultDialogProvider import org.dhis2.form.ui.provider.LegendValueProvider @@ -164,140 +165,143 @@ class DataEntryIntegrationTest { geometryController = geometryController, openErrorLocation = false, resultDialogUiProvider = resultDialogUiProvider, + formSectionMapper = FormSectionMapper(), ) } @Test fun shouldAllowDataEntryCorrectly() = runTest { - val observedItems = mutableListOf>() - val observer = - Observer> { items -> - observedItems.add(items) - } - - formViewModel.items.observeForever(observer) - - val focusOnReportDateIntent = - FormIntent.OnFocus( - uid = "EVENT_REPORT_DATE_UID", - value = "", + formViewModel.items.test { + + val focusOnReportDateIntent = + FormIntent.OnFocus( + uid = "EVENT_REPORT_DATE_UID", + value = "", + ) + formViewModel.submitIntent(focusOnReportDateIntent) + + assert( + awaitItem().first().fields + .find { it.uid == "EVENT_REPORT_DATE_UID" } + ?.value + .isNullOrEmpty(), ) - formViewModel.submitIntent(focusOnReportDateIntent) - - assert( - observedItems - .last() - .find { it.uid == "EVENT_REPORT_DATE_UID" } - ?.value - .isNullOrEmpty(), - ) - - val enterReportDateIntent = - FormIntent.OnTextChange( - uid = "EVENT_REPORT_DATE_UID", - value = "2024-03-20", - valueType = ValueType.DATE, - ) - formViewModel.submitIntent(enterReportDateIntent) - - val focusOnOrgUnitIntent = - FormIntent.OnFocus( - uid = "EVENT_ORG_UNIT_UID", - value = "", - ) - formViewModel.submitIntent(focusOnOrgUnitIntent) - val enterOrgUnitIntent = - FormIntent.OnTextChange( - uid = "EVENT_ORG_UNIT_UID", - value = "g8upMTyEZGZ", - valueType = ValueType.ORGANISATION_UNIT, + val enterReportDateIntent = + FormIntent.OnTextChange( + uid = "EVENT_REPORT_DATE_UID", + value = "2024-03-20", + valueType = ValueType.DATE, + ) + formViewModel.submitIntent(enterReportDateIntent) + + val focusOnOrgUnitIntent = + FormIntent.OnFocus( + uid = "EVENT_ORG_UNIT_UID", + value = "", + ) + formViewModel.submitIntent(focusOnOrgUnitIntent) + + assert( + awaitItem().first().fields + .find { it.uid == "EVENT_REPORT_DATE_UID" }?.value == "2024-03-20", ) - formViewModel.submitIntent(enterOrgUnitIntent) - val focusOnCoordinatesIntent = - FormIntent.OnFocus( - uid = "EVENT_COORDINATE_UID", - value = "", + val enterOrgUnitIntent = + FormIntent.OnTextChange( + uid = "EVENT_ORG_UNIT_UID", + value = "g8upMTyEZGZ", + valueType = ValueType.ORGANISATION_UNIT, + ) + formViewModel.submitIntent(enterOrgUnitIntent) + + val focusOnCoordinatesIntent = + FormIntent.OnFocus( + uid = "EVENT_COORDINATE_UID", + value = "", + ) + formViewModel.submitIntent(focusOnCoordinatesIntent) + + assert( + awaitItem().first().fields + .find { it.uid == "EVENT_ORG_UNIT_UID" }?.value == "g8upMTyEZGZ", ) - formViewModel.submitIntent(focusOnCoordinatesIntent) - // Change section - formViewModel.submitIntent(FormIntent.OnSection(sectionUid = "EVENT_DATA_SECTION_UID")) - - // Focus on age - val focusOnAgeIntent = - FormIntent.OnFocus( - uid = "qrur9Dvnyt5", - value = "", - ) - formViewModel.submitIntent(focusOnAgeIntent) - - // Enter age - val enterAgeIntent = - FormIntent.OnTextChange( - uid = "qrur9Dvnyt5", - value = "20", - valueType = ValueType.AGE, + // Change section + formViewModel.submitIntent(FormIntent.OnSection(sectionUid = "EVENT_DATA_SECTION_UID")) + + awaitItem() + + // Focus on age + val focusOnAgeIntent = + FormIntent.OnFocus( + uid = "qrur9Dvnyt5", + value = "", + ) + formViewModel.submitIntent(focusOnAgeIntent) + + // Enter age + val enterAgeIntent = + FormIntent.OnTextChange( + uid = "qrur9Dvnyt5", + value = "20", + valueType = ValueType.AGE, + ) + formViewModel.submitIntent(enterAgeIntent) + + awaitItem() + + // focus on input field with legend + val focusOnLegendFieldIntent = + FormIntent.OnFocus( + uid = "INPUT_NUMBER_WITH_LEGEND_UID", + value = "", + ) + formViewModel.submitIntent(focusOnLegendFieldIntent) + + assert( + awaitItem()[1].fields + .find { it.uid == "qrur9Dvnyt5" }?.value == "20", ) - formViewModel.submitIntent(enterAgeIntent) - // focus on input field with legend - val focusOnLegendFieldIntent = - FormIntent.OnFocus( - uid = "INPUT_NUMBER_WITH_LEGEND_UID", - value = "", - ) - formViewModel.submitIntent(focusOnLegendFieldIntent) - - // enter value on input field with legend - val enterValueOnLegendFieldIntent = - FormIntent.OnTextChange( - uid = "INPUT_NUMBER_WITH_LEGEND_UID", - value = "25", - valueType = ValueType.NUMBER, + // enter value on input field with legend + val enterValueOnLegendFieldIntent = + FormIntent.OnTextChange( + uid = "INPUT_NUMBER_WITH_LEGEND_UID", + value = "25", + valueType = ValueType.NUMBER, + ) + + formViewModel.submitIntent(enterValueOnLegendFieldIntent) + + // Focus on gender + val focusOnGenderIntent = + FormIntent.OnFocus( + uid = "oZg33kd9taw", + value = "", + ) + formViewModel.submitIntent(focusOnGenderIntent) + + assert( + awaitItem()[1].fields + .find { it.uid == "INPUT_NUMBER_WITH_LEGEND_UID" } + ?.legend == legendValueItem, ) - formViewModel.submitIntent(enterValueOnLegendFieldIntent) - - // Focus on gender - val focusOnGenderIntent = - FormIntent.OnFocus( - uid = "oZg33kd9taw", - value = "", + val enterGenderIntent = + FormIntent.OnSave( + uid = "oZg33kd9taw", + value = "Female", + valueType = ValueType.MULTI_TEXT, + ) + formViewModel.submitIntent(enterGenderIntent) + + assert( + awaitItem()[1].fields + .find { it.uid == "oZg33kd9taw" }?.value == "Female", ) - formViewModel.submitIntent(focusOnGenderIntent) - - val enterGenderIntent = - FormIntent.OnSave( - uid = "oZg33kd9taw", - value = "Female", - valueType = ValueType.MULTI_TEXT, - ) - formViewModel.submitIntent(enterGenderIntent) - - assert( - observedItems.last().find { it.uid == "EVENT_REPORT_DATE_UID" }?.value == "2024-03-20", - ) - assert( - observedItems.last().find { it.uid == "EVENT_ORG_UNIT_UID" }?.value == "g8upMTyEZGZ", - ) - assert( - observedItems - .last() - .find { it.uid == "INPUT_NUMBER_WITH_LEGEND_UID" } - ?.legend == legendValueItem, - ) - assert( - observedItems.last().find { it.uid == "qrur9Dvnyt5" }?.value == "20", - ) - assert( - observedItems.last().find { it.uid == "oZg33kd9taw" }?.value == "Female", - ) - - // Clean up - formViewModel.items.removeObserver(observer) + } } private fun provideMalariaCaseRegistrationEventItems(): List { 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 d18234f3660..f10c4d96255 100644 --- a/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt @@ -4,6 +4,7 @@ import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -16,6 +17,7 @@ import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.RowAction import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.intent.FormIntent +import org.dhis2.form.ui.mapper.FormSectionMapper import org.dhis2.form.ui.provider.FormResultDialogProvider import org.dhis2.mobile.commons.model.CustomIntentRequestArgumentModel import org.hisp.dhis.android.core.common.ValueType @@ -37,7 +39,10 @@ class FormViewModelTest { @get:Rule val instantExecutorRule = InstantTaskExecutorRule() - private val repository: FormRepository = mock() + private val repository: FormRepository = mock { + on { runBlocking { fetchFormItems(any()) } } doReturn emptyList() + on { runBlocking { composeList(any()) } } doReturn emptyList() + } private val testingDispatcher = StandardTestDispatcher() private val dispatcher: DispatcherProvider = mock { @@ -46,6 +51,8 @@ class FormViewModelTest { private val geometryController: GeometryController = mock() private val resultDialogUiProvider: FormResultDialogProvider = mock() + private val formSectionMapper = FormSectionMapper() + private lateinit var viewModel: FormViewModel @Before @@ -58,6 +65,7 @@ class FormViewModelTest { dispatcher, geometryController, resultDialogUiProvider = resultDialogUiProvider, + formSectionMapper = formSectionMapper, ) whenever(repository.getDateFormatConfiguration()) doReturn "ddMMyyyy" } diff --git a/gradle.properties b/gradle.properties index f4bd86ad56c..9dde98c3fd5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,10 @@ org.gradle.jvmargs=-Xmx4096M -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true android.useAndroidX=true -android.enableJetifier=true +# TODO: Remove when SonarQube Gradle plugin supports AGP 9.x new DSL (AppExtension removal) +# NOTE: AGP 9.1.0 also removed getCDirectories() from SourceProvider, breaking sonar-scanner-gradle +# 7.2.3.7755. Pinned to AGP 9.0.1 until SonarQube Gradle plugin 7.3 adds AGP 9.1+ support. +android.newDsl=false #disable firebase performance. Should be enabled for release firebasePerformanceInstrumentationEnabled=false @@ -35,10 +38,15 @@ org.gradle.caching=true android.suppressUnsupportedCompileSdk=32 android.nonTransitiveRClass=false -android.nonFinalResIds=false - -android.enableR8.fullMode=false # Kotlin compiler flags -kotlin.daemon.jvmargs=-Xmx2048M -kotlin.incremental=true \ No newline at end of file +kotlin.daemon.jvmargs=-Xmx4096M +kotlin.incremental=true + +# Dependency verification is disabled for local development and CI to avoid +# noise from unverified artifacts. Release pipelines run with strict +# dependency verification enabled (e.g. --dependency-verification strict) using +# existing verification metadata. +org.gradle.dependency.verification=off +android.r8.strictFullModeForKeepRules=false +kotlinx.atomicfu.enableJvmIrTransformation=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5efc12114d..579a4e2234d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,23 @@ [versions] kotlinxDatetime = "0.7.1-0.6.x-compat" #Needs to be compatible with the Rule engine, update when it's updated -lifecycleRuntimeCompose = "2.9.5" -navigationCompose = "2.9.1" +lifecycleRuntimeCompose = "2.9.6" +navigationCompose = "2.9.2" sdk = "36" -minSdk = "21" -vCode = "151" -vName = "3.3.1" -gradle = "8.13.2" -kotlin = '2.2.21' +minSdk = "23" +vCode = "155" +vName = "3.4.1-RC" +gradle = "9.0.1" +kotlin = '2.3.20' dagger = '2.57.2' jacoco = '0.8.14' -designSystem = "0.6.1" -dhis2sdk = "1.13.1" -ruleEngine = "3.3.11" -expressionParser = "1.2.2" +designSystem = "0.7.1-SNAPSHOT" +dhis2sdk = "1.14.1-SNAPSHOT" +ruleEngine = "3.7.2" +expressionParser = "1.4.1" appcompat = "1.7.1" annotation = "1.9.1" cardview = "1.0.0" legacysupport = "1.0.0" -multidex = "2.0.1" constraintlayout = "2.2.1" lifecycle = "2.9.4" work = "2.10.5" @@ -26,17 +25,15 @@ exif = "1.4.2" corektx = "1.17.0" fragmentktx = "1.8.9" recyclerview = "1.4.0" -compose = "1.9.3" -composePluginVersion = "1.9.1" -composePaging = "3.3.6" -pagingTesting = "3.3.6" +compose = "1.10.6" +composePluginVersion = "1.10.3" +composePaging = "3.4.2" +pagingTesting = "3.4.2" composeConstraintLayout = "1.1.1" activityCompose = "1.11.0" viewModelCompose = "2.9.4" material = "1.13.0" material3 = "1.4.0" -themeAdapter = "0.36.0" -pinlock = "2.1.0" fancyshowcase = "1.4.0" lottie = "6.6.10" rxjava = "2.2.21" @@ -44,19 +41,19 @@ rxandroid = "2.1.1" maplibremarkerview = "3.0.2" maplibreannotation = "3.0.2" matomo = "4.4" -sentry = "8.29.0" -sentryPlugin = "5.12.2" +sentry = "8.33.0" +sentryPlugin = "6.1.0" timber = "5.0.1" rxlint = "1.7.8" crashactivity = "2.4.0" zxing = "3.5.4" zxingandroid = "4.3.0" -root = "0.1.1" +root = "0.1.2" openid = "0.11.1" conscrypt = "2.5.3" gson = "2.13.2" okhttp = "4.12.0" -jodatime = "2.14.0" +jodatime = "2.14.1" glide = "5.0.5" guava = "33.5.0-android" mockito = "5.20.0" @@ -84,19 +81,21 @@ material3WindowSize = "1.4.0" material3WindowSizeCompose = "1.8.2" material3AdaptiveAndroid = "1.1.0" koin = "4.1.1" -junitJupiter = "6.0.1" +junitJupiter = "6.0.3" ktxml = "1.0.0" atomicfu = "0.29.0" playservicesauth = "21.4.0" -playservicesphone = "18.0.1" #Can not be updated to latest due minSDK requirement of 23 -ksp = "2.2.20-2.0.4" +playservicesphone = "18.0.2" #Can not be updated to latest due minSDK requirement of 23 +ksp = "2.3.6" commonsText = "1.14.0" ktlint = "13.1.0" -sonarqube = "7.0.0.6105" +sonarqube = "7.2.3.7755" browser = "1.9.0" biometric = "1.1.0" coil = "3.3.0" cyclonedx = "2.1.0" +jetbrains-material3="1.9.0" +materialIconsExtended = "1.7.3" [libraries] gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "gradle" } @@ -129,7 +128,6 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" } androidx-legacy-support-v4 = { group = "androidx.legacy", name = "legacy-support-v4", version.ref = "legacysupport" } -androidx-multidex = { group = "androidx.multidex", name = "multidex", version.ref = "multidex" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-work = { group = "androidx.work", name = "work-runtime", version.ref = "work" } androidx-workrx = { group = "androidx.work", name = "work-rxjava2", version.ref = "work" } @@ -146,7 +144,6 @@ google-guava = { group = "com.google.guava", name = "guava", version.ref = "guav google-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playservicesauth" } google-auth-apiphone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playservicesphone" } google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } -google-material3-themeadapter = { group = "com.google.accompanist", name = "accompanist-themeadapter-material3", version.ref = "themeAdapter" } google-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } network-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } @@ -156,7 +153,6 @@ dagger-compiler = { group = "com.google.dagger", name = "dagger-compiler", versi rx-java = { group = "io.reactivex.rxjava2", name = "rxjava", version.ref = "rxjava" } rx-android = { group = "io.reactivex.rxjava2", name = "rxandroid", version.ref = "rxandroid" } rx-relay = { group = "com.jakewharton.rxrelay2", name = "rxrelay", version.ref = "rxrelay" } -github-pinlock = { group = "com.github.aritraroy", name = "PinLockView", version.ref = "pinlock" } github-fancyshowcase = { group = "com.github.faruktoptas", name = "FancyShowCaseView", version.ref = "fancyshowcase" } github-glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } github-glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" } @@ -211,6 +207,7 @@ koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-composeVM = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } +koin-work = { module = "io.insert-koin:koin-androidx-workmanager", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } koin-test-junit5 = { module = "io.insert-koin:koin-test-junit5", version.ref = "koin" } koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin" } @@ -220,23 +217,30 @@ ktxml = { module = "org.kobjects.ktxml:core", version.ref = "ktxml" } commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" } coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"} coil-network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil"} +compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "composePluginVersion" } +compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "composePluginVersion" } +compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "composePluginVersion" } +compose-ui-uiToolingPreview = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview", version.ref = "composePluginVersion" } +compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "jetbrains-material3" } +compose-components-resources = { group = "org.jetbrains.compose.components", name = "components-resources", version.ref = "composePluginVersion" } +compose-desktop-common = { group = "org.jetbrains.compose.desktop", name = "desktop", version.ref = "composePluginVersion" } +compose-material3-iconsExtendend = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" } [plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "gradle" } kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "kotlinx-serialization" } compose = { id = "org.jetbrains.compose", version.ref = "composePluginVersion" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } +legacy-kapt = { id = "com.android.legacy-kapt", version.ref = "gradle" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } sentry = { id = "io.sentry.android.gradle", version.ref = "sentryPlugin" } cyclonedx = { id = "org.cyclonedx.bom", version.ref = "cyclonedx" } [bundles] -uicomponents-implementation = ["androidx-coreKtx", "androidx-material3", "androidx-material3-window", "androidx-material3-adaptative-android", "google-material", "lottie-compose", "dhis2-mobile-designsystem"] -uicomponents-api = ["dhis2-mobile-designsystem", "androidx-compose-constraintlayout", "androidx-compose-preview", "androidx-compose-ui", "google-material3-themeadapter"] -uicomponents-debugapi = ["androidx-compose-uitooling"] -uicomponents-androidtest = ["test-junit-ext"] analytics-implementation = ["androidx-cardview", "androidx-constraintlayout"] analytics-api = ["github-charts"] analytics-test = ["test-mockitoCore", "test-mockitoInline", "test-mockitoKotlin", "test-kotlinCoroutines", "test-archCoreTesting"] @@ -249,5 +253,3 @@ table-androidTest = ["test-compose-ui-test", "test-uiautomator", "test-junitKtx" stock-implementation = ["androidx-activity-compose", "androidx-annotation", "rx-relay", "security-openId", "androidx-preferenceKtx", "androidx-work", "androidx-activityKtx", "androidx-lifecycle-viewmodel-compose", "analytics-customactivityoncrash"] stock-core = ["desugar"] stock-test = ["test-mockitoKotlin", "test-mockitoInline", "test-archCoreTesting", "test-javafaker", "test-kotlinCoroutines"] -tracker-implementation = ["androidx-lifecycle-viewmodel-compose", "androidx-activity-compose"] -tracker-test = ["test-mockitoCore", "test-mockitoInline", "test-mockitoKotlin", "test-testCore", "test-archCoreTesting", "test-kotlinCoroutines"] diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml new file mode 100644 index 00000000000..2e930a4cd2f --- /dev/null +++ b/gradle/verification-metadata.xml @@ -0,0 +1,5750 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ff96c38feaf..ac6cfd349c9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Jul 22 10:02:18 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/login/build.gradle.kts b/login/build.gradle.kts index b142ac086d9..380aa71c941 100644 --- a/login/build.gradle.kts +++ b/login/build.gradle.kts @@ -1,10 +1,7 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree - plugins { - kotlin("multiplatform") + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) - id("com.android.library") + alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.serialization) } @@ -14,12 +11,23 @@ kotlin { compilerOptions { freeCompilerArgs.add("-Xcontext-parameters") } - androidTarget { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + + androidLibrary { + namespace = "org.dhis2.mobile.login" + compileSdk = libs.versions.sdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + enableCoreLibraryDesugaring = true + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } + androidResources { enable = true } + packaging { + resources { + pickFirsts.add("values*/**") + } + } + withHostTestBuilder {}.configure {} + withDeviceTestBuilder { sourceSetTreeName = "test" }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - @OptIn(ExperimentalKotlinGradlePluginApi::class) - instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) } jvm("desktop") @@ -33,20 +41,20 @@ kotlin { resources.srcDirs("src/commonMain/composeResources") dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.ui) - implementation(compose.material3) - api(compose.materialIconsExtended) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + api(libs.compose.material3.iconsExtendend) val designSystem = libs.dhis2.mobile.designsystem implementation("${designSystem.get().group}:${designSystem.get().name}:${designSystem.get().version}") { isChanging = true } implementation(libs.compose.material3.window) - implementation(compose.components.resources) + implementation(libs.compose.components.resources) implementation(project(":commonskmm")) implementation(libs.navigation.compose) - implementation(compose.components.uiToolingPreview) + implementation(libs.compose.ui.uiToolingPreview) // Koin api(libs.koin.core) @@ -58,14 +66,10 @@ kotlin { commonTest { dependencies { implementation(kotlin("test")) - // Koin Test features - implementation(libs.koin.test) - implementation(libs.koin.test.junit5) - implementation(libs.koin.test.junit4) implementation(libs.test.turbine) implementation(libs.test.kotlinCoroutines) implementation(libs.test.mockitoKotlin) - implementation(compose.components.resources) + implementation(libs.compose.components.resources) } } @@ -78,12 +82,26 @@ kotlin { implementation(libs.koin.androidx.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) + implementation(libs.androidx.compose.uitooling) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.junit.jupiter) + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.test.compose.ui.test.junit4.android) + implementation(libs.test.ui.test.manifest) } } val desktopMain by getting { dependencies { - implementation(compose.desktop.common) + implementation(libs.compose.desktop.common) } } } @@ -96,57 +114,6 @@ compose.resources { generateResClass = always } -android { - namespace = "org.dhis2.mobile.login" - compileSdk = libs.versions.sdk.get().toInt() - - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - sourceSets["main"].res.srcDirs("src/androidMain/res") - - defaultConfig { - minSdk = libs.versions.minSdk.get().toInt() - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - compileOptions { - isCoreLibraryDesugaringEnabled = true - - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - buildTypes { - getByName("debug") { - - } - getByName("release") { - - } - } - buildFeatures.buildConfig = true - - flavorDimensions += listOf("default") - productFlavors { - create("dhis2") { - buildConfigField("String", "LOGIN_TEST", "\"test\"") - } - create("dhis2PlayServices") { - buildConfigField("String", "LOGIN_TEST", "\"test\"") - - } - create("dhis2Training") { - buildConfigField("String", "LOGIN_TEST", "\"test\"") - } - } -} - dependencies { coreLibraryDesugaring(libs.desugar) } - -dependencies { - testImplementation(libs.junit.jupiter) - debugImplementation(libs.androidx.compose.preview) - debugImplementation(libs.androidx.compose.uitooling) - androidTestImplementation(libs.test.compose.ui.test.junit4.android) - debugImplementation(libs.test.ui.test.manifest) -} \ No newline at end of file diff --git a/login/src/androidInstrumentedTest/kotlin/screen/TwoFAToEnableScreenTest.kt b/login/src/androidInstrumentedTest/kotlin/screen/TwoFAToEnableScreenTest.kt deleted file mode 100644 index 51b89b75804..00000000000 --- a/login/src/androidInstrumentedTest/kotlin/screen/TwoFAToEnableScreenTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -package screen - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.runComposeUiTest -import org.dhis2.mobile.login.authentication.ui.screen.TURN_ON_BUTTON_TEST_TAG -import org.dhis2.mobile.login.authentication.ui.screen.TwoFAToEnableScreen -import org.dhis2.mobile.login.authentication.ui.state.TwoFAUiState -import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme -import org.junit.Rule -import kotlin.test.Test - -@OptIn(ExperimentalTestApi::class) -class TwoFAToEnableScreenTest { - @get:Rule - val composeTestRule = createComposeRule() - - private val secretCode = "secret_code" - - @Test - fun should_init_with_disabled_button() { - with(composeTestRule) { - setContent { - DHIS2Theme { - TwoFAToEnableScreen( - enableUiState = - TwoFAUiState.Enable( - secretCode = secretCode, - isEnabling = false, - enableErrorMessage = null, - ), - onAuthenticatorButtonClicked = {}, - onCopyCodeButtonClicked = {}, - onEnableButtonClicked = {}, - ) - } - } - - onNodeWithTag(TURN_ON_BUTTON_TEST_TAG).assertIsNotEnabled() - } - } - - @Test - fun should_set_error_if_code_is_wrong() = - runComposeUiTest { - with(composeTestRule) { - setContent { - DHIS2Theme { - TwoFAToEnableScreen( - enableUiState = - TwoFAUiState.Enable( - secretCode = secretCode, - isEnabling = false, - enableErrorMessage = "Error code", - ), - onAuthenticatorButtonClicked = {}, - onCopyCodeButtonClicked = {}, - onEnableButtonClicked = {}, - ) - } - } - - onNode( - hasTestTag("INPUT_TEXT_SUPPORTING_TEXT") and - hasText("two_fa_failed_to_turn_on"), - ).assertIsDisplayed() - } - } -} diff --git a/login/src/androidMain/kotlin/org/dhis2/mobile/login/accounts/data/repository/AccountRepositoryImpl.kt b/login/src/androidMain/kotlin/org/dhis2/mobile/login/accounts/data/repository/AccountRepositoryImpl.kt index 173dafcd054..4b6f786a65d 100644 --- a/login/src/androidMain/kotlin/org/dhis2/mobile/login/accounts/data/repository/AccountRepositoryImpl.kt +++ b/login/src/androidMain/kotlin/org/dhis2/mobile/login/accounts/data/repository/AccountRepositoryImpl.kt @@ -1,7 +1,6 @@ package org.dhis2.mobile.login.accounts.data.repository import org.dhis2.mobile.commons.providers.PreferenceProvider -import org.dhis2.mobile.login.BuildConfig import org.dhis2.mobile.login.accounts.data.credentials.defaultTestingCredentials import org.dhis2.mobile.login.accounts.data.credentials.trainingTestingCredentials import org.dhis2.mobile.login.accounts.domain.model.AccountModel @@ -12,6 +11,8 @@ import org.hisp.dhis.android.core.configuration.internal.DatabaseAccount class AccountRepositoryImpl( private val d2: D2, private val preferenceProvider: PreferenceProvider, + private val isDebug: Boolean, + private val isTrainingFlavor: Boolean, ) : AccountRepository { override suspend fun getLoggedInAccounts(): List = d2.userModule().accountManager().getAccounts().map { @@ -20,9 +21,9 @@ class AccountRepositoryImpl( override suspend fun availableServers(): List { val providedServers = - if (BuildConfig.DEBUG) { + if (isDebug) { defaultTestingCredentials - } else if (BuildConfig.FLAVOR == "dhis2Training") { + } else if (isTrainingFlavor) { trainingTestingCredentials } else { emptyList() diff --git a/login/src/androidMain/kotlin/org/dhis2/mobile/login/authentication/OpenIdControllerImpl.kt b/login/src/androidMain/kotlin/org/dhis2/mobile/login/authentication/OpenIdControllerImpl.kt new file mode 100644 index 00000000000..70e10603007 --- /dev/null +++ b/login/src/androidMain/kotlin/org/dhis2/mobile/login/authentication/OpenIdControllerImpl.kt @@ -0,0 +1,99 @@ +package org.dhis2.mobile.login.authentication + +import android.app.Activity.RESULT_OK +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.dhis2.mobile.login.resources.Res +import org.dhis2.mobile.login.resources.openid_error_controller_not_bounded +import org.dhis2.mobile.login.resources.openid_error_expected_intent +import org.dhis2.mobile.login.resources.openid_login_cancelled +import org.hisp.dhis.android.core.user.openid.IntentWithRequestCode +import org.jetbrains.compose.resources.getString + +class OpenIdControllerImpl : OpenIdController { + private var openIDRequestCode = -1 + private var onResultCallback: (suspend (Result) -> Unit)? = null + private var openIdLauncher: ActivityResultLauncher? = null + private var lifecycleScope: LifecycleCoroutineScope? = null + + override fun bind(context: Any) { + if (context !is FragmentActivity) { + unbind() + throw IllegalArgumentException( + "Context must be an instance of FragmentActivity", + ) + } + + openIdLauncher?.unregister() + lifecycleScope = context.lifecycleScope + openIdLauncher = + context.activityResultRegistry.register( + key = "OPEN_ID_LAUNCHER", + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + val scope = lifecycleScope ?: return@register + if (result.resultCode == RESULT_OK && result.data != null) { + val intentResult = + IntentWithRequestCode( + intent = result.data!!, + requestCode = openIDRequestCode, + ) + scope.launch { + onResultCallback?.invoke(Result.success(intentResult)) + } + } else { + scope.launch { + onResultCallback?.invoke( + Result.failure( + Exception(getString(Res.string.openid_login_cancelled)), + ), + ) + } + } + } + } + + override fun unbind() { + openIdLauncher?.unregister() + openIdLauncher = null + lifecycleScope = null + onResultCallback = null + } + + override fun handleIntent( + intent: Any, + onResult: suspend (Result) -> Unit, + ) { + if (intent !is IntentWithRequestCode) { + lifecycleScope?.launch { + onResult( + Result.failure( + IllegalArgumentException(getString(Res.string.openid_error_expected_intent)), + ), + ) + } ?: throw IllegalStateException() + return + } + + val launcher = openIdLauncher + if (launcher == null) { + lifecycleScope?.launch { + onResult( + Result.failure( + IllegalStateException(getString(Res.string.openid_error_controller_not_bounded)), + ), + ) + } ?: throw IllegalStateException() + return + } + + onResultCallback = onResult + openIDRequestCode = intent.requestCode + launcher.launch(intent.intent) + } +} diff --git a/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepositoryImpl.kt b/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepositoryImpl.kt index 1d8a923ad3a..375aaa33f2f 100644 --- a/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepositoryImpl.kt +++ b/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepositoryImpl.kt @@ -2,12 +2,14 @@ package org.dhis2.mobile.login.main.data import androidx.core.net.toUri import coil3.PlatformContext +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import org.dhis2.mobile.commons.auth.OpenIdController import org.dhis2.mobile.commons.biometrics.BiometricActions import org.dhis2.mobile.commons.biometrics.CryptographicActions import org.dhis2.mobile.commons.coroutine.Dispatcher +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.commons.error.DomainErrorMapper import org.dhis2.mobile.commons.providers.BIOMETRIC_CREDENTIALS import org.dhis2.mobile.commons.providers.PreferenceProvider import org.dhis2.mobile.commons.providers.SECURE_PASS @@ -15,13 +17,18 @@ import org.dhis2.mobile.commons.providers.SECURE_SERVER_URL import org.dhis2.mobile.commons.reporting.AnalyticActions import org.dhis2.mobile.commons.reporting.CrashReportController import org.dhis2.mobile.commons.resources.D2ErrorMessageProvider +import org.dhis2.mobile.login.authentication.OpenIdController +import org.dhis2.mobile.login.main.domain.model.OpenIdLoginConfiguration import org.dhis2.mobile.login.main.domain.model.ServerValidationResult import org.dhis2.mobile.login.resources.Res +import org.dhis2.mobile.login.resources.error_device_not_registered import org.dhis2.mobile.login.resources.openid_invalid_auth_result import org.dhis2.mobile.login.resources.openid_process_cancelled import org.dhis2.mobile.login.resources.server_url_error import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.Result +import org.hisp.dhis.android.core.maintenance.D2Error +import org.hisp.dhis.android.core.user.oauth2.OAuth2Config import org.hisp.dhis.android.core.user.openid.IntentWithRequestCode import org.hisp.dhis.android.core.user.openid.OpenIDConnectConfig import org.jetbrains.compose.resources.getString @@ -47,6 +54,7 @@ class LoginRepositoryImpl( private val analyticActions: AnalyticActions, private val openIdController: OpenIdController, private val dispatcher: Dispatcher, + private val domainErrorMapper: DomainErrorMapper, ) : LoginRepository { override suspend fun validateServer( server: String, @@ -55,26 +63,24 @@ class LoginRepositoryImpl( withContext(dispatcher.io) { when (val result = d2.serverModule().blockingCheckServerUrl(server)) { is Result.Success -> { - if (result.value.isOauthEnabled()) { - ServerValidationResult.Oauth - } else { - val oidcProvider = result.value.oidcProviders.firstOrNull() - val serverName = - result.value.applicationTitle ?: try { - server.substringAfter("://").substringBefore("/") - } catch (_: Exception) { - server - } - ServerValidationResult.Legacy( - serverName = serverName, - serverDescription = result.value.applicationDescription, - countryFlag = result.value.countryFlag, - allowRecovery = result.value.allowAccountRecovery, - oidcIcon = oidcProvider?.icon, - oidcLoginText = oidcProvider?.loginText, - oidcUrl = oidcProvider?.url, - ) - } + val serverName = + result.value.applicationTitle ?: try { + server.substringAfter("://").substringBefore("/") + } catch (_: Exception) { + server + } + + val oidcProvider = result.value.oidcProviders.firstOrNull() + ServerValidationResult.Success( + serverName = serverName.ifEmpty { "DHIS2" }, + serverDescription = result.value.applicationDescription, + countryFlag = result.value.countryFlag, + allowRecovery = result.value.allowAccountRecovery, + oidcIcon = oidcProvider?.icon, + oidcLoginText = oidcProvider?.loginText, + oidcUrl = oidcProvider?.url, + oAuthEnabled = result.value.isOauthEnabled(), + ) } is Result.Failure -> { @@ -123,6 +129,51 @@ class LoginRepositoryImpl( } } + override suspend fun getDeviceEnrollmentUrl(serverUrl: String) = + withContext(dispatcher.io) { + try { + d2.userModule().oauth2Handler().blockingBuildEnrollmentUrl(serverUrl) + } catch (d2Error: D2Error) { + throw domainErrorMapper.mapToDomainError(d2Error) + } + } + + override suspend fun enrollDevice( + iat: String, + serverURL: String, + ) = withContext(dispatcher.io) { + try { + d2.userModule().oauth2Handler().blockingHandleEnrollmentResponse( + serverUrl = serverURL, + iat = iat, + ) + + if (!d2.userModule().oauth2Handler().isDeviceRegistered()) { + throw DomainError.AuthenticationError(getString(Res.string.error_device_not_registered)) + } + + val config = OAuth2Config(serverUrl = serverURL) + // Return the authorization URL + d2.userModule().oauth2Handler().blockingLogIn(config) + } catch (d2Error: D2Error) { + throw domainErrorMapper.mapToDomainError(d2Error) + } + } + + override suspend fun loginUserWithOAuth( + serverUrl: String, + code: String, + ) = withContext(dispatcher.io) { + try { + val user = + d2.userModule().oauth2Handler().blockingHandleLogInResponse(serverUrl, code) + kotlin.Result.success(user.username()) + } catch (d2Error: D2Error) { + val mappedError = domainErrorMapper.mapToDomainError(d2Error) + kotlin.Result.failure(mappedError) + } + } + override suspend fun getAvailableLoginUsernames(): List = withContext(dispatcher.io) { preferences.getSet(PREF_USERS, HashSet())?.toList() ?: emptyList() @@ -157,12 +208,16 @@ class LoginRepositoryImpl( override suspend fun displayTrackingMessage(): Boolean = withContext(dispatcher.io) { - d2 - .dataStoreModule() - .localDataStore() - .value(DATA_STORE_ANALYTICS_PERMISSION_KEY) - .blockingGet() - ?.value() == null + try { + d2 + .dataStoreModule() + .localDataStore() + .value(DATA_STORE_ANALYTICS_PERMISSION_KEY) + .blockingGet() + ?.value() == null + } catch (_: Exception) { + false + } } private fun hasEnabledBiometricsPermission(): Boolean = @@ -175,7 +230,10 @@ class LoginRepositoryImpl( override suspend fun initialSyncDone( serverUrl: String, username: String, - ): Boolean = withContext(dispatcher.io) { isImportedDatabase(serverUrl, username) or entryExists() } + ): Boolean = + withContext(dispatcher.io) { + isImportedDatabase(serverUrl, username) or entryExists() + } override suspend fun canLoginWithBiometrics(serverUrl: String): Boolean = withContext(dispatcher.io) { @@ -260,7 +318,7 @@ class LoginRepositoryImpl( preferences.getBiometricCredentials()?.let { ciphertextWrapper -> cryptographyManager .getInitializedCipherForDecryption(ciphertextWrapper.initializationVector) - ?.let { cipher -> + .let { cipher -> suspendCancellableCoroutine { continuation -> authenticator.authenticate(cipher) { cipher -> val pass = @@ -285,15 +343,7 @@ class LoginRepositoryImpl( } } - override suspend fun loginWithOpenId( - serverUrl: String, - isNetworkAvailable: Boolean, - clientId: String, - redirectUri: String, - discoveryUri: String?, - authorizationUri: String?, - tokenUrl: String?, - ): kotlin.Result = + override suspend fun loginWithOpenId(openIdLoginConfiguration: OpenIdLoginConfiguration): kotlin.Result = withContext(dispatcher.io) { suspendCancellableCoroutine { continuation -> val intent = @@ -302,54 +352,16 @@ class LoginRepositoryImpl( .openIdHandler() .blockingLogIn( OpenIDConnectConfig( - clientId = clientId, - redirectUri = redirectUri.toUri(), - discoveryUri = discoveryUri?.toUri(), - authorizationUri = authorizationUri, - tokenUrl = tokenUrl, + clientId = openIdLoginConfiguration.clientId, + redirectUri = openIdLoginConfiguration.redirectUri.toUri(), + discoveryUri = openIdLoginConfiguration.discoveryUri?.toUri(), + authorizationUri = openIdLoginConfiguration.authorizationUri, + tokenUrl = openIdLoginConfiguration.tokenUrl, + prompt = openIdLoginConfiguration.prompt, ), ) openIdController.handleIntent(intent) { resultIntent -> - val result = - when { - resultIntent.isFailure -> { - kotlin.Result.failure( - resultIntent.exceptionOrNull() - ?: Exception(getString(Res.string.openid_process_cancelled)), - ) - } - - resultIntent.isSuccess and (resultIntent.getOrNull() !is IntentWithRequestCode) -> { - kotlin.Result.failure(Exception(getString(Res.string.openid_invalid_auth_result))) - } - - else -> { - try { - val intent = resultIntent.getOrNull() as IntentWithRequestCode - d2 - .userModule() - .openIdHandler() - .blockingHandleLogInResponse( - serverUrl = serverUrl, - intent = intent.intent, - requestCode = intent.requestCode, - ) - - kotlin.Result.success(Unit) - } catch (e: Exception) { - kotlin.Result.failure( - Exception( - d2ErrorMessageProvider.getErrorMessage( - e, - isNetworkAvailable, - ), - ), - ) - } - } - } - - continuation.resume(value = result) { _, _, _ -> } + manageIntentResult(resultIntent, openIdLoginConfiguration, continuation) } continuation.invokeOnCancellation { kotlin.Result.failure(Exception("")) @@ -357,6 +369,61 @@ class LoginRepositoryImpl( } } + private suspend fun manageIntentResult( + resultIntent: kotlin.Result, + openIdLoginConfiguration: OpenIdLoginConfiguration, + continuation: CancellableContinuation>, + ) { + withContext(dispatcher.io) { + val result = + when { + resultIntent.isFailure -> { + kotlin.Result.failure( + resultIntent.exceptionOrNull() + ?: Exception(getString(Res.string.openid_process_cancelled)), + ) + } + + resultIntent.isSuccess and (resultIntent.getOrNull() !is IntentWithRequestCode) -> { + kotlin.Result.failure(Exception(getString(Res.string.openid_invalid_auth_result))) + } + + else -> { + try { + val intent = + resultIntent.getOrNull() as IntentWithRequestCode + + d2 + .userModule() + .openIdHandler() + .blockingHandleLogInResponse( + serverUrl = openIdLoginConfiguration.serverUrl, + intent = intent.intent, + requestCode = intent.requestCode, + ) + + kotlin.Result.success(Unit) + } catch (d2Error: D2Error) { + kotlin.Result.failure( + Exception( + d2ErrorMessageProvider.getErrorMessage( + d2Error, + openIdLoginConfiguration.isNetworkAvailable, + ), + ), + ) + } catch (_: Exception) { + kotlin.Result.failure( + resultIntent.exceptionOrNull() + ?: Exception(getString(Res.string.openid_process_cancelled)), + ) + } + } + } + continuation.resume(value = result) { _, _, _ -> } + } + } + override suspend fun getUsername(): String = withContext(dispatcher.io) { d2 diff --git a/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.android.kt b/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.android.kt index 89064cbbbbe..096c46a6409 100644 --- a/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.android.kt +++ b/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.android.kt @@ -1,12 +1,15 @@ package org.dhis2.mobile.login.main.di -import org.dhis2.mobile.commons.auth.OpenIdController -import org.dhis2.mobile.commons.auth.OpenIdControllerImpl +import android.content.Context +import android.content.pm.ApplicationInfo import org.dhis2.mobile.login.accounts.data.repository.AccountRepository import org.dhis2.mobile.login.accounts.data.repository.AccountRepositoryImpl import org.dhis2.mobile.login.accounts.ui.viewmodel.AccountsViewModel +import org.dhis2.mobile.login.authentication.OpenIdController +import org.dhis2.mobile.login.authentication.OpenIdControllerImpl import org.dhis2.mobile.login.main.data.LoginRepository import org.dhis2.mobile.login.main.data.LoginRepositoryImpl +import org.dhis2.mobile.login.main.ui.state.OidcInfo import org.koin.core.module.dsl.viewModel import org.koin.core.parameter.parametersOf import org.koin.dsl.module @@ -14,27 +17,55 @@ import org.koin.dsl.module internal actual val accountModule = module { factory { + val context = get() + val isDebug = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 AccountRepositoryImpl( get(), get(), + isDebug = isDebug, + isTrainingFlavor = getProperty("isTrainingFlavor", false), ) } - single { params -> - OpenIdControllerImpl(params.get()) + single { OpenIdControllerImpl() } + + single { + when (getProperty("openIdType", "")) { + "token" -> + OidcInfo.Token( + server = getProperty("openIdServer", ""), + loginLabel = getPropertyOrNull("openIdButtonText"), + clientId = getProperty("openIdClient", ""), + redirectUri = getProperty("openIdRedirectUri", ""), + authorizationUrl = getProperty("openIdAuthorizationUrl", ""), + tokenUrl = getProperty("openIdTokenUrl", ""), + prompt = getPropertyOrNull("openIdPrompt")?.takeIf { it.isNotEmpty() }, + ) + + else -> + OidcInfo.Discovery( + server = getProperty("openIdServer", ""), + loginButtonText = getPropertyOrNull("openIdButtonText"), + clientId = getProperty("openIdClient", ""), + redirectUri = getProperty("openIdRedirectUri", ""), + discoveryUri = getProperty("openIdDiscoveryUri", ""), + prompt = getPropertyOrNull("openIdPrompt")?.takeIf { it.isNotEmpty() }, + ) + } } - factory { params -> + factory { _ -> LoginRepositoryImpl( - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get { parametersOf(params.get()) }, - get(), + d2 = get(), + authenticator = get(), + cryptographyManager = get(), + preferences = get(), + d2ErrorMessageProvider = get(), + crashReportController = get(), + analyticActions = get(), + openIdController = get(), + dispatcher = get(), + domainErrorMapper = get(), ) } diff --git a/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/ui/screen/WebAuthenticator.android.kt b/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/ui/screen/WebAuthenticator.android.kt index e9999983252..8c4e18dfa78 100644 --- a/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/ui/screen/WebAuthenticator.android.kt +++ b/login/src/androidMain/kotlin/org/dhis2/mobile/login/main/ui/screen/WebAuthenticator.android.kt @@ -14,17 +14,6 @@ actual fun WebAuthenticator( url: String, onDismiss: () -> Unit, ) { - // TODO Use the url parameter when the backend supports dynamic redirect URIs - val redirectUri = "https://vgarciabnz.github.io" - val oauthAuthUrl = - "https://dev.im.dhis2.org/oauth2-android-test/oauth2/authorize?" + - "response_type=code" + - "&client_id=dhis2-client" + - "&redirect_uri=$redirectUri" + - "&scope=openid%20email" + - "&state=abc123" - - // Custom Tab launcher will handle the result of the Custom Tab to detect if the user closed it val customTabLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), @@ -41,11 +30,13 @@ actual fun WebAuthenticator( .setShowTitle(false) .setUrlBarHidingEnabled(true) .build() + // Custom Tab is not kept in the activity history stack and it's removed from memory. + // It won't appear in the "Recent Apps" list customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) val intent = customTabsIntent.intent.apply { - data = oauthAuthUrl.toUri() + data = url.toUri() } customTabLauncher.launch(intent) diff --git a/login/src/androidMain/kotlin/org/dhis2/mobile/login/pin/PinBottomSheetLauncher.kt b/login/src/androidMain/kotlin/org/dhis2/mobile/login/pin/PinBottomSheetLauncher.kt new file mode 100644 index 00000000000..8a2da1bea2c --- /dev/null +++ b/login/src/androidMain/kotlin/org/dhis2/mobile/login/pin/PinBottomSheetLauncher.kt @@ -0,0 +1,45 @@ +package org.dhis2.mobile.login.pin + +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.FragmentActivity +import org.dhis2.mobile.login.pin.domain.model.PinMode +import org.dhis2.mobile.login.pin.ui.components.PinDialog +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme + +/** + * Adds a [PinDialog] composable as an overlay on the Activity's decor view. + * + * Returns the [ComposeView] that was added, so the caller can remove it if needed + * (e.g., on Activity stop). The [PinDialog] is displayed via [FullScreenDialog] internally, + * which creates its own dialog window on top of the Activity. + * + * @param mode PIN mode: SET (create PIN) or ASK (verify PIN). + * @param onSuccess Invoked when the PIN operation completes successfully. + * @param onDismiss Invoked when the dialog is dismissed (back press, forgot PIN, too many attempts). + * @return The [ComposeView] added to the window, or null if the decor view is unavailable. + */ +fun FragmentActivity.addPinBottomSheet( + mode: PinMode, + onSuccess: () -> Unit, + onDismiss: () -> Unit, +): ComposeView? { + val decorView = window?.decorView as? ViewGroup ?: return null + return ComposeView(this).apply { + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool, + ) + setContent { + DHIS2Theme { + PinDialog( + mode = mode, + onSuccess = onSuccess, + onDismiss = onDismiss, + ) + } + } + decorView.addView(this, MATCH_PARENT, MATCH_PARENT) + } +} diff --git a/login/src/commonMain/composeResources/values-ar-rIQ/strings.xml b/login/src/commonMain/composeResources/values-ar-rIQ/strings.xml index 9bd139301f0..54c20417320 100644 --- a/login/src/commonMain/composeResources/values-ar-rIQ/strings.xml +++ b/login/src/commonMain/composeResources/values-ar-rIQ/strings.xml @@ -1,4 +1,4 @@ - + التالي اسم المستخدم diff --git a/login/src/commonMain/composeResources/values-ar/strings.xml b/login/src/commonMain/composeResources/values-ar/strings.xml index aff9209a9cc..4aabf6b8e45 100644 --- a/login/src/commonMain/composeResources/values-ar/strings.xml +++ b/login/src/commonMain/composeResources/values-ar/strings.xml @@ -1,4 +1,4 @@ - + التالي تسجيل الدخول السريع diff --git a/login/src/commonMain/composeResources/values-bn/strings.xml b/login/src/commonMain/composeResources/values-bn/strings.xml index 7713f637a38..b413e40faef 100644 --- a/login/src/commonMain/composeResources/values-bn/strings.xml +++ b/login/src/commonMain/composeResources/values-bn/strings.xml @@ -1,4 +1,4 @@ - + হ্যাঁ \ No newline at end of file diff --git a/login/src/commonMain/composeResources/values-ckb/strings.xml b/login/src/commonMain/composeResources/values-ckb/strings.xml index 8a027b413b2..c7e2cab6921 100644 --- a/login/src/commonMain/composeResources/values-ckb/strings.xml +++ b/login/src/commonMain/composeResources/values-ckb/strings.xml @@ -1,4 +1,4 @@ - + داهاتوو بەڵێ diff --git a/login/src/commonMain/composeResources/values-cs/strings.xml b/login/src/commonMain/composeResources/values-cs/strings.xml index ff617a7b78c..4035c4a7221 100644 --- a/login/src/commonMain/composeResources/values-cs/strings.xml +++ b/login/src/commonMain/composeResources/values-cs/strings.xml @@ -22,13 +22,12 @@ Zpět na nastavení Další Navázání spojení... - Ke kterému serveru se chcete připojit\? + Ke kterému serveru se chcete připojit? URL serveru Je zapnuté dvoufaktorové ověřování. Vypnutí dvoufaktorového ověřování Chcete-li vypnout dvoufaktorové ověřování, otevřete aplikaci Authenticator. Níže zadejte kód z aplikace autentizátoru. - Ověřovací kód Vypnout Vypnutí... Nepodařilo se vypnout dvoufaktorové ověřování. @@ -57,7 +56,7 @@ Zadejte svůj kód PIN Pro přístup k účtu zadejte čtyřmístný kód PIN. Odemknout - Zapomněli jste kód PIN\? + Zapomněli jste kód PIN? Nesprávný kód PIN Není nakonfigurován žádný kód PIN Nepodařilo se uložit kód PIN diff --git a/login/src/commonMain/composeResources/values-da/strings.xml b/login/src/commonMain/composeResources/values-da/strings.xml index e48cd4774df..9ae14a93640 100644 --- a/login/src/commonMain/composeResources/values-da/strings.xml +++ b/login/src/commonMain/composeResources/values-da/strings.xml @@ -1,4 +1,4 @@ - + Next Username diff --git a/login/src/commonMain/composeResources/values-es-rES/strings.xml b/login/src/commonMain/composeResources/values-es-rES/strings.xml index bda435c5146..4d9ff87946b 100644 --- a/login/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/login/src/commonMain/composeResources/values-es-rES/strings.xml @@ -1,4 +1,4 @@ - + Siguiente Nombre de usuario diff --git a/login/src/commonMain/composeResources/values-fr/strings.xml b/login/src/commonMain/composeResources/values-fr/strings.xml index 3651f95b7bd..fa6f5ed9804 100644 --- a/login/src/commonMain/composeResources/values-fr/strings.xml +++ b/login/src/commonMain/composeResources/values-fr/strings.xml @@ -1,4 +1,4 @@ - + Authentification à deux facteurs Code d\'authentification @@ -16,4 +16,4 @@ Pas maintenant Oui Déverrouiller - \ No newline at end of file + \ No newline at end of file diff --git a/login/src/commonMain/composeResources/values-hi-rIN/strings.xml b/login/src/commonMain/composeResources/values-hi-rIN/strings.xml index af04834da4e..8f7f33d0552 100644 --- a/login/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/login/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -1,8 +1,7 @@ - + ऑथेंटिकेशन कोड आगे - ऑथेंटिकेशन कोड यूज़रनेम पासवर्ड लॉग इन हो रहा है… diff --git a/login/src/commonMain/composeResources/values-id/strings.xml b/login/src/commonMain/composeResources/values-id/strings.xml index 0337aae27b2..4c0f15d42d9 100644 --- a/login/src/commonMain/composeResources/values-id/strings.xml +++ b/login/src/commonMain/composeResources/values-id/strings.xml @@ -1,4 +1,4 @@ - + Selanjutnya Nama pengguna diff --git a/login/src/commonMain/composeResources/values-km/strings.xml b/login/src/commonMain/composeResources/values-km/strings.xml index 8b653396bbe..1fa4a3e05f6 100644 --- a/login/src/commonMain/composeResources/values-km/strings.xml +++ b/login/src/commonMain/composeResources/values-km/strings.xml @@ -1,4 +1,4 @@ - + បន្ទាប់ ឈ្មោះ​គណនី diff --git a/login/src/commonMain/composeResources/values-lo/strings.xml b/login/src/commonMain/composeResources/values-lo/strings.xml index 1667abe1268..10e147b8737 100644 --- a/login/src/commonMain/composeResources/values-lo/strings.xml +++ b/login/src/commonMain/composeResources/values-lo/strings.xml @@ -1,4 +1,4 @@ - + ກວດສອບຄວາມຖືກຕ້ອງແບບ 2 ຂັ້ນ ຕໍ່ໄປ diff --git a/login/src/commonMain/composeResources/values-my/strings.xml b/login/src/commonMain/composeResources/values-my/strings.xml index c3b11bebb24..27b9d88841a 100644 --- a/login/src/commonMain/composeResources/values-my/strings.xml +++ b/login/src/commonMain/composeResources/values-my/strings.xml @@ -1,4 +1,4 @@ - + ရှေ့သို့ အသုံးပြုသူအမည် diff --git a/login/src/commonMain/composeResources/values-nb/strings.xml b/login/src/commonMain/composeResources/values-nb/strings.xml index 0696e46c4da..90b1cbf0a1f 100644 --- a/login/src/commonMain/composeResources/values-nb/strings.xml +++ b/login/src/commonMain/composeResources/values-nb/strings.xml @@ -1,4 +1,4 @@ - + Neste Brukernavn diff --git a/login/src/commonMain/composeResources/values-nl/strings.xml b/login/src/commonMain/composeResources/values-nl/strings.xml index 4e8b53798d0..82c082e57a9 100644 --- a/login/src/commonMain/composeResources/values-nl/strings.xml +++ b/login/src/commonMain/composeResources/values-nl/strings.xml @@ -1,10 +1,9 @@ - + Twee-factor-authenticatie Authenticatiecode Volgende Twee-factor-authenticatie staat aan. - Authenticatiecode Gebruikersnaam Wachtwoord Aan het inloggen... @@ -13,4 +12,4 @@ privacybeleid Niet nu Ja - \ No newline at end of file + \ No newline at end of file diff --git a/login/src/commonMain/composeResources/values-or/strings.xml b/login/src/commonMain/composeResources/values-or/strings.xml index d367006546e..1c06fa0a994 100644 --- a/login/src/commonMain/composeResources/values-or/strings.xml +++ b/login/src/commonMain/composeResources/values-or/strings.xml @@ -1,4 +1,4 @@ - + ହଁ \ No newline at end of file diff --git a/login/src/commonMain/composeResources/values-prs/strings.xml b/login/src/commonMain/composeResources/values-prs/strings.xml index 5ade5633f41..9e8b90fdcca 100644 --- a/login/src/commonMain/composeResources/values-prs/strings.xml +++ b/login/src/commonMain/composeResources/values-prs/strings.xml @@ -1,4 +1,4 @@ - + بعدی نام کاربر diff --git a/login/src/commonMain/composeResources/values-ps/strings.xml b/login/src/commonMain/composeResources/values-ps/strings.xml index 90974f871eb..f2bcae60ce4 100644 --- a/login/src/commonMain/composeResources/values-ps/strings.xml +++ b/login/src/commonMain/composeResources/values-ps/strings.xml @@ -1,4 +1,4 @@ - + راتلونکی کارېدونکی نوم diff --git a/login/src/commonMain/composeResources/values-pt-rBR/strings.xml b/login/src/commonMain/composeResources/values-pt-rBR/strings.xml index ca68c9dc9c7..166a5d8ac0a 100644 --- a/login/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/login/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -1,4 +1,4 @@ - + Autenticação de dois fatores Próximo diff --git a/login/src/commonMain/composeResources/values-pt/strings.xml b/login/src/commonMain/composeResources/values-pt/strings.xml index 81c95c67961..127c1bdec91 100644 --- a/login/src/commonMain/composeResources/values-pt/strings.xml +++ b/login/src/commonMain/composeResources/values-pt/strings.xml @@ -1,4 +1,4 @@ - + Autenticação de dois factores Próximo diff --git a/login/src/commonMain/composeResources/values-ro/strings.xml b/login/src/commonMain/composeResources/values-ro/strings.xml index 65017cbfb6b..0b73c7adfd0 100644 --- a/login/src/commonMain/composeResources/values-ro/strings.xml +++ b/login/src/commonMain/composeResources/values-ro/strings.xml @@ -1,4 +1,4 @@ - + Următorul Da diff --git a/login/src/commonMain/composeResources/values-ru/strings.xml b/login/src/commonMain/composeResources/values-ru/strings.xml index c4c5aafffa4..ddf5b074af7 100644 --- a/login/src/commonMain/composeResources/values-ru/strings.xml +++ b/login/src/commonMain/composeResources/values-ru/strings.xml @@ -1,4 +1,4 @@ - + Далее Быстрый вход в систему diff --git a/login/src/commonMain/composeResources/values-si/strings.xml b/login/src/commonMain/composeResources/values-si/strings.xml index 0b5cc8a3adf..7b6f94ddda7 100644 --- a/login/src/commonMain/composeResources/values-si/strings.xml +++ b/login/src/commonMain/composeResources/values-si/strings.xml @@ -1,4 +1,4 @@ - + රහස්‍ය පදය ඔව් diff --git a/login/src/commonMain/composeResources/values-sv/strings.xml b/login/src/commonMain/composeResources/values-sv/strings.xml index 79e10deb0e0..63cc6c99538 100644 --- a/login/src/commonMain/composeResources/values-sv/strings.xml +++ b/login/src/commonMain/composeResources/values-sv/strings.xml @@ -1,4 +1,4 @@ - + Nästa Användarnamn diff --git a/login/src/commonMain/composeResources/values-tet/strings.xml b/login/src/commonMain/composeResources/values-tet/strings.xml index 6156f69b5c4..722b475abe2 100644 --- a/login/src/commonMain/composeResources/values-tet/strings.xml +++ b/login/src/commonMain/composeResources/values-tet/strings.xml @@ -1,4 +1,4 @@ - + Próximu Utilizador nia Naran diff --git a/login/src/commonMain/composeResources/values-tg/strings.xml b/login/src/commonMain/composeResources/values-tg/strings.xml index cc2ba39fd04..e25c4e713a5 100644 --- a/login/src/commonMain/composeResources/values-tg/strings.xml +++ b/login/src/commonMain/composeResources/values-tg/strings.xml @@ -1,4 +1,4 @@ - + Навбатӣ Номи корбар diff --git a/login/src/commonMain/composeResources/values-ur/strings.xml b/login/src/commonMain/composeResources/values-ur/strings.xml index c6cb378cc79..654509a9567 100644 --- a/login/src/commonMain/composeResources/values-ur/strings.xml +++ b/login/src/commonMain/composeResources/values-ur/strings.xml @@ -1,4 +1,4 @@ - + اگلہ صارف کا نا٠diff --git a/login/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/login/src/commonMain/composeResources/values-uz-rUZ/strings.xml index 30f62d9def1..35a442dcafd 100644 --- a/login/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/login/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -1,4 +1,4 @@ - + Кейинги Фойдаланувчи исми diff --git a/login/src/commonMain/composeResources/values-uz/strings.xml b/login/src/commonMain/composeResources/values-uz/strings.xml index 2418950d2c2..8859dd53bec 100644 --- a/login/src/commonMain/composeResources/values-uz/strings.xml +++ b/login/src/commonMain/composeResources/values-uz/strings.xml @@ -1,8 +1,7 @@ - + Аутентификация коди Кейинги - Аутентификация коди Фойдаланувчи исми Пароль Кириш... diff --git a/login/src/commonMain/composeResources/values-vi/strings.xml b/login/src/commonMain/composeResources/values-vi/strings.xml index d3175d53cad..e121c25b9f4 100644 --- a/login/src/commonMain/composeResources/values-vi/strings.xml +++ b/login/src/commonMain/composeResources/values-vi/strings.xml @@ -1,10 +1,9 @@ - + Xác thực hai yếu tố Mã xác thực Tiếp tục Xác thực hai yếu tố đang được bật. - Mã xác thực Đăng nhập nhanh Bạn có muốn sử dụng dấu vân tay của bạn hoặc phương thức sinh học khác để đăng nhập không? Tên đăng nhập @@ -18,4 +17,4 @@ Mở khoá Nhập thành công - \ No newline at end of file + \ No newline at end of file diff --git a/login/src/commonMain/composeResources/values-zh-rCN/strings.xml b/login/src/commonMain/composeResources/values-zh-rCN/strings.xml index 5a8748d3738..0945cbb1170 100644 --- a/login/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/login/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -1,8 +1,7 @@ - + 验证码 下一个 - 验证码 快速登录 您想使用指纹或其他生物识别方法登录吗? 用户名 diff --git a/login/src/commonMain/composeResources/values-zh/strings.xml b/login/src/commonMain/composeResources/values-zh/strings.xml index 034612438d4..691cf81d86c 100644 --- a/login/src/commonMain/composeResources/values-zh/strings.xml +++ b/login/src/commonMain/composeResources/values-zh/strings.xml @@ -1,4 +1,4 @@ - + 添加新账户 双重身份验证 @@ -28,7 +28,6 @@ 关闭双因素身份验证 要禁用双因素身份验证,请打开身份验证程序。 在下方输入验证程序中的代码。 - 验证码 关闭 关闭... 未关闭双因素身份验证。 @@ -67,4 +66,4 @@ 导入成功 登录被取消 授权结果无效 - \ No newline at end of file + \ No newline at end of file diff --git a/login/src/commonMain/composeResources/values/strings.xml b/login/src/commonMain/composeResources/values/strings.xml index a257953321e..7d42b2b0719 100644 --- a/login/src/commonMain/composeResources/values/strings.xml +++ b/login/src/commonMain/composeResources/values/strings.xml @@ -1,8 +1,8 @@ - + Add new account Two factor authentication - Can't connect to network + Can\'t connect to network You need an internet connection to turn two-factor authentication on or off. Try to reconnect Checking status... @@ -28,7 +28,6 @@ Turning off two-factor authentication To disable two-factor authentication open your authenticator app. Enter the code from your authenticator app below. - Authentication code Turn off Turning off... Fail to turn two factor authentication off. @@ -67,4 +66,9 @@ Import successful The login was cancelled Invalid authorization result + Expected IntentWithRequestCode + OpenIdController is not bound to a lifecycle scope + Authentication cancelled + Device not registered + Missing username from OAuth login response \ No newline at end of file diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/screen/AccountItem.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/screen/AccountItem.kt index af7a132c5e1..7ee54e2e0e5 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/screen/AccountItem.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/screen/AccountItem.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import org.dhis2.mobile.commons.resources.getDrawableResource import org.dhis2.mobile.login.accounts.domain.model.AccountModel import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem @@ -20,6 +21,8 @@ import org.hisp.dhis.mobile.ui.designsystem.component.ListCardTitleModel import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberAdditionalInfoColumnState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberListCardState +const val ACCOUNT_ITEM_TAG = "account_item" + @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun AccountItem( @@ -61,7 +64,8 @@ fun AccountItem( ListCard( modifier = modifier - .fillMaxWidth(), + .fillMaxWidth() + .testTag("${ACCOUNT_ITEM_TAG}_${account.serverUrl}_${account.name}"), listCardState = listCardState, onCardClick = { onItemClicked(account) }, listAvatar = { diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/viewmodel/AccountsViewModel.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/viewmodel/AccountsViewModel.kt index 65acf2dbe6b..f751d0ec19c 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/viewmodel/AccountsViewModel.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/accounts/ui/viewmodel/AccountsViewModel.kt @@ -36,12 +36,13 @@ class AccountsViewModel( fun onAccountClicked(account: AccountModel) { viewModelScope.launch { navigator.navigate( - LoginScreenState.LegacyLogin( + LoginScreenState.LoginCredentials( selectedServer = account.serverUrl, selectedUsername = account.name, serverName = account.serverName, selectedServerFlag = account.serverFlag, allowRecovery = account.allowRecovery, + oAuthEnabled = account.isOauthEnabled, ), ) } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/OpenIdController.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/OpenIdController.kt new file mode 100644 index 00000000000..9af4d7672b0 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/OpenIdController.kt @@ -0,0 +1,12 @@ +package org.dhis2.mobile.login.authentication + +interface OpenIdController { + fun handleIntent( + intent: Any, + onResult: suspend (Result) -> Unit, + ) + + fun bind(context: Any) + + fun unbind() +} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFADisableScreen.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFADisableScreen.kt index ad17df82cf1..171f81c9a22 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFADisableScreen.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFADisableScreen.kt @@ -19,11 +19,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import org.dhis2.mobile.login.authentication.ui.state.TwoFAUiState import org.dhis2.mobile.login.resources.Res -import org.dhis2.mobile.login.resources.two_fa_code +import org.dhis2.mobile.login.resources.two_fa_authentication_code import org.dhis2.mobile.login.resources.two_fa_disable_button import org.dhis2.mobile.login.resources.two_fa_disable_desc_1 import org.dhis2.mobile.login.resources.two_fa_disable_desc_2 @@ -35,8 +34,9 @@ import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle import org.hisp.dhis.mobile.ui.designsystem.component.ColorStyle import org.hisp.dhis.mobile.ui.designsystem.component.InfoBar +import org.hisp.dhis.mobile.ui.designsystem.component.InputSegmentedShell import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState -import org.hisp.dhis.mobile.ui.designsystem.component.InputText +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @@ -49,7 +49,7 @@ fun TwoFADisableScreen( onAuthCodeUpdated: (String) -> Unit, onDisable: (String) -> Unit, ) { - var authCode: TextFieldValue by remember(twoFADisableUiState) { mutableStateOf(TextFieldValue("")) } + var authCode by remember(twoFADisableUiState) { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { InfoBar( @@ -77,27 +77,28 @@ fun TwoFADisableScreen( stepNumber = "2.", text = stringResource(Res.string.two_fa_disable_desc_2), ) { - InputText( - inputTextFieldValue = authCode, - onValueChanged = { - it?.let { - authCode = it - onAuthCodeUpdated(it.text) - } - }, - title = stringResource(Res.string.two_fa_code), - state = twoFADisableUiState.state, - supportingText = + Text( + text = stringResource(Res.string.two_fa_authentication_code), + style = MaterialTheme.typography.bodyMedium, + ) + InputSegmentedShell( + segmentCount = 6, + inputStyle = InputStyle.BrightInputStyle(), + enabled = !twoFADisableUiState.isDisabling, + supportingTextData = if (twoFADisableUiState.state == InputShellState.ERROR) { - listOf( - SupportingTextData( - text = stringResource(Res.string.two_fa_disable_error), - state = SupportingTextState.ERROR, - ), + SupportingTextData( + text = stringResource(Res.string.two_fa_disable_error), + state = SupportingTextState.ERROR, ) } else { null }, + initialValue = authCode, + onValueChanged = { + authCode = it.replace("-", "") + onAuthCodeUpdated(authCode) + }, ) Button( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -108,8 +109,8 @@ fun TwoFADisableScreen( }, colorStyle = ColorStyle.ERROR, style = ButtonStyle.FILLED, - enabled = authCode.text.length >= 6 && twoFADisableUiState.isDisabling.not(), - onClick = { onDisable(authCode.text) }, + enabled = authCode.length >= 6 && twoFADisableUiState.isDisabling.not(), + onClick = { onDisable(authCode) }, icon = { Icon( imageVector = Icons.Outlined.KeyOff, diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFASettingsScreen.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFASettingsScreen.kt index e6cba2a36bb..2a776b6db93 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFASettingsScreen.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFASettingsScreen.kt @@ -110,8 +110,6 @@ fun TwoFASettingsScreen( Snackbar( modifier = Modifier.dropShadow(shape = SnackbarDefaults.shape), snackbarData = data, - containerColor = SurfaceColor.SurfaceBright, - contentColor = TextColor.OnSurface, ) } }, diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFAToEnableScreen.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFAToEnableScreen.kt index 47f548a0a78..9846e3b312c 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFAToEnableScreen.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/authentication/ui/screen/TwoFAToEnableScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.dhis2.mobile.login.authentication.ui.state.TwoFAUiState @@ -51,8 +51,8 @@ import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle import org.hisp.dhis.mobile.ui.designsystem.component.IconButton import org.hisp.dhis.mobile.ui.designsystem.component.IconButtonStyle import org.hisp.dhis.mobile.ui.designsystem.component.InfoBar -import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState -import org.hisp.dhis.mobile.ui.designsystem.component.InputText +import org.hisp.dhis.mobile.ui.designsystem.component.InputSegmentedShell +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme @@ -62,7 +62,6 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview const val TURN_ON_BUTTON_TEST_TAG = "turn_on_button_test_tag" @@ -215,8 +214,8 @@ fun TwoFAAuthStepThree( enableUiState: TwoFAUiState.Enable, onEnableButtonClicked: (String) -> Unit, ) { - var textValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue("")) + var textValue by rememberSaveable { + mutableStateOf("") } Column( @@ -247,24 +246,25 @@ fun TwoFAAuthStepThree( .padding(top = 16.dp), verticalArrangement = spacedBy(12.dp), ) { - InputText( - title = stringResource(Res.string.two_fa_authentication_code), - supportingText = + Text( + text = stringResource(Res.string.two_fa_authentication_code), + style = MaterialTheme.typography.bodyMedium, + ) + InputSegmentedShell( + segmentCount = 6, + inputStyle = InputStyle.BrightInputStyle(), + enabled = !enableUiState.isEnabling, + supportingTextData = enableUiState.enableErrorMessage?.let { - listOf( - SupportingTextData( - text = stringResource(Res.string.two_fa_failed_to_turn_on), - state = SupportingTextState.ERROR, - ), + SupportingTextData( + text = stringResource(Res.string.two_fa_failed_to_turn_on), + state = SupportingTextState.ERROR, ) }, - inputTextFieldValue = textValue, + initialValue = textValue, onValueChanged = { - if (it != null) { - textValue = it - } + textValue = it.replace("-", "") }, - state = InputShellState.FOCUSED, ) Button( @@ -272,7 +272,7 @@ fun TwoFAAuthStepThree( when (enableUiState.isEnabling) { true -> false else -> { - textValue.text.length == 6 + textValue.length == 6 } }, modifier = @@ -297,7 +297,7 @@ fun TwoFAAuthStepThree( contentDescription = "Status Icon", ) }, - onClick = { onEnableButtonClicked(textValue.text) }, + onClick = { onEnableButtonClicked(textValue) }, ) } } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepository.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepository.kt index 9da51f7fbfc..0a825ab87fa 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepository.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/data/LoginRepository.kt @@ -1,7 +1,9 @@ package org.dhis2.mobile.login.main.data import coil3.PlatformContext +import org.dhis2.mobile.login.main.domain.model.OpenIdLoginConfiguration import org.dhis2.mobile.login.main.domain.model.ServerValidationResult + typealias UserPassword = String interface LoginRepository { @@ -51,17 +53,21 @@ interface LoginRepository { suspend fun importDatabase(path: String): Result - suspend fun loginWithOpenId( - serverUrl: String, - isNetworkAvailable: Boolean, - clientId: String, - redirectUri: String, - discoveryUri: String?, - authorizationUri: String?, - tokenUrl: String?, - ): Result + suspend fun loginWithOpenId(openIdLoginConfiguration: OpenIdLoginConfiguration): Result suspend fun getUsername(): String suspend fun logoutUser(): Result + + suspend fun getDeviceEnrollmentUrl(serverUrl: String): String + + suspend fun enrollDevice( + iat: String, + serverURL: String, + ): String + + suspend fun loginUserWithOAuth( + serverUrl: String, + code: String, + ): Result } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.kt index 895d4a6c236..ad3a8473672 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.kt @@ -5,12 +5,15 @@ import org.dhis2.mobile.login.authentication.di.twoFAModule import org.dhis2.mobile.login.main.domain.usecase.BiometricLogin import org.dhis2.mobile.login.main.domain.usecase.GetAvailableUsernames import org.dhis2.mobile.login.main.domain.usecase.GetBiometricInfo +import org.dhis2.mobile.login.main.domain.usecase.GetDeviceEnrollmentUrl import org.dhis2.mobile.login.main.domain.usecase.GetHasOtherAccounts import org.dhis2.mobile.login.main.domain.usecase.GetInitialScreen import org.dhis2.mobile.login.main.domain.usecase.ImportDatabase import org.dhis2.mobile.login.main.domain.usecase.LogOutUser import org.dhis2.mobile.login.main.domain.usecase.LoginUser +import org.dhis2.mobile.login.main.domain.usecase.LoginUserWithOAuth import org.dhis2.mobile.login.main.domain.usecase.OpenIdLogin +import org.dhis2.mobile.login.main.domain.usecase.ProcessDeviceEnrollment import org.dhis2.mobile.login.main.domain.usecase.UpdateBiometricPermission import org.dhis2.mobile.login.main.domain.usecase.UpdateTrackingPermission import org.dhis2.mobile.login.main.domain.usecase.ValidateServer @@ -71,15 +74,27 @@ internal val mainLoginModule = factory { params -> OpenIdLogin(get { parametersOf(params.get()) }) } + + factory { params -> + GetDeviceEnrollmentUrl(get { parametersOf(params.get()) }) + } + + factory { params -> + ProcessDeviceEnrollment(get { parametersOf(params.get()) }) + } + + factory { params -> + LoginUserWithOAuth(get { parametersOf(params.get()) }) + } + viewModel { parameters -> val context = parameters.get() LoginViewModel( - get(), - get(), - get { parametersOf(context) }, - get { parametersOf(context) }, - get(), - get(), + navigator = get(), + getInitialScreen = get(), + importDatabase = get { parametersOf(context) }, + validateServer = get { parametersOf(context) }, + networkStatusProvider = get(), ) } viewModel { parameters -> @@ -90,6 +105,7 @@ internal val mainLoginModule = val oidcInfo = parameters[4] as OidcInfo? val context = parameters[5] as PlatformContext val fromHome = parameters[6] as Boolean + val oAuthEnable = parameters[7] as Boolean CredentialsViewModel( navigator = get(), getAvailableUsernames = get { parametersOf(context) }, @@ -99,8 +115,12 @@ internal val mainLoginModule = logOutUser = get { parametersOf(context) }, openIdLogin = get { parametersOf(context) }, biometricLogin = get { parametersOf(context) }, + loginUserWithOAuth = get { parametersOf(context) }, + getDeviceEnrollmentUrl = get { parametersOf(context) }, + processDeviceEnrollment = get { parametersOf(context) }, updateTrackingPermission = get { parametersOf(context) }, updateBiometricPermission = get { parametersOf(context) }, + appLinkNavigation = get(), networkStatusProvider = get(), serverName = serverName, serverUrl = serverUrl, @@ -110,6 +130,7 @@ internal val mainLoginModule = oidcInfo = oidcInfo, forgotPinUseCase = get(), fromHome = fromHome, + oAuthEnable = oAuthEnable, ) } } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/DeviceEnrollmentInfo.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/DeviceEnrollmentInfo.kt new file mode 100644 index 00000000000..6b5031cdf17 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/DeviceEnrollmentInfo.kt @@ -0,0 +1,6 @@ +package org.dhis2.mobile.login.main.domain.model + +data class DeviceEnrollmentInfo( + val iat: String, + val serverURL: String, +) diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/LoginScreenState.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/LoginScreenState.kt index 7e2050a2434..2a11b406016 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/LoginScreenState.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/LoginScreenState.kt @@ -16,16 +16,17 @@ sealed interface LoginScreenState { ) : LoginScreenState @Serializable - data class LegacyLogin( + data class LoginCredentials( val selectedServer: String, val selectedUsername: String?, val serverName: String?, val selectedServerFlag: String?, val allowRecovery: Boolean, + val oAuthEnabled: Boolean, ) : LoginScreenState @Serializable - data class OauthLogin( + data class OauthAuthentication( val selectedServer: String, ) : LoginScreenState diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/OpenIdLoginConfiguration.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/OpenIdLoginConfiguration.kt new file mode 100644 index 00000000000..6cbf7a50ad7 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/OpenIdLoginConfiguration.kt @@ -0,0 +1,12 @@ +package org.dhis2.mobile.login.main.domain.model + +data class OpenIdLoginConfiguration( + val serverUrl: String, + val isNetworkAvailable: Boolean, + val clientId: String, + val redirectUri: String, + val discoveryUri: String?, + val authorizationUri: String?, + val tokenUrl: String?, + val prompt: String?, +) diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/ServerValidationResult.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/ServerValidationResult.kt index 527f18a6c64..d870f7b880f 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/ServerValidationResult.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/model/ServerValidationResult.kt @@ -1,7 +1,7 @@ package org.dhis2.mobile.login.main.domain.model sealed interface ServerValidationResult { - data class Legacy( + data class Success( val serverName: String?, val serverDescription: String?, val countryFlag: String?, @@ -9,10 +9,9 @@ sealed interface ServerValidationResult { val oidcIcon: String?, val oidcLoginText: String?, val oidcUrl: String?, + val oAuthEnabled: Boolean, ) : ServerValidationResult - object Oauth : ServerValidationResult - data class Error( val message: String, ) : ServerValidationResult diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetDeviceEnrollmentUrl.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetDeviceEnrollmentUrl.kt new file mode 100644 index 00000000000..fc3d1d5c854 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetDeviceEnrollmentUrl.kt @@ -0,0 +1,16 @@ +package org.dhis2.mobile.login.main.domain.usecase + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.login.main.data.LoginRepository + +class GetDeviceEnrollmentUrl( + private val repository: LoginRepository, +) : UseCase { + override suspend fun invoke(input: String): Result = + try { + Result.success(repository.getDeviceEnrollmentUrl(input)) + } catch (e: DomainError) { + Result.failure(e) + } +} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetInitialScreen.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetInitialScreen.kt index 31af22fa7d8..0d8f56985ce 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetInitialScreen.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/GetInitialScreen.kt @@ -27,28 +27,24 @@ class GetInitialScreen( } private fun handleSingleAccount(account: AccountModel): LoginScreenState = - if (account.isOauthEnabled) { - LoginScreenState.OauthLogin(selectedServer = account.serverUrl) - } else { - account.toLegacyLoginState() - } + LoginScreenState.LoginCredentials( + selectedServer = account.serverUrl, + selectedUsername = account.name, + serverName = account.serverName, + selectedServerFlag = account.serverFlag, + allowRecovery = account.allowRecovery, + oAuthEnabled = account.isOauthEnabled, + ) private suspend fun handleLockedSession(): LoginScreenState { val activeAccount = accountRepository.getActiveAccount() ?: return LoginScreenState.Accounts - - return if (activeAccount.isOauthEnabled) { - LoginScreenState.OauthLogin(selectedServer = activeAccount.serverUrl) - } else { - activeAccount.toLegacyLoginState() - } - } - - private fun AccountModel.toLegacyLoginState(): LoginScreenState.LegacyLogin = - LoginScreenState.LegacyLogin( - selectedServer = serverUrl, - selectedUsername = name, - serverName = serverName, - selectedServerFlag = serverFlag, - allowRecovery = allowRecovery, + return LoginScreenState.LoginCredentials( + selectedServer = activeAccount.serverUrl, + selectedUsername = activeAccount.name, + serverName = activeAccount.serverName, + selectedServerFlag = activeAccount.serverFlag, + allowRecovery = activeAccount.allowRecovery, + oAuthEnabled = activeAccount.isOauthEnabled, ) + } } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUser.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUser.kt index 4565d7956e9..fcf376d5dc5 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUser.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUser.kt @@ -12,7 +12,8 @@ class LoginUser( password: String, isNetworkAvailable: Boolean, ): LoginResult { - val result = repository.loginUser(serverUrl, username, password, isNetworkAvailable) - return handleResult(result, serverUrl, username) + val trimmedUsername = username.trim() + val result = repository.loginUser(serverUrl, trimmedUsername, password, isNetworkAvailable) + return handleResult(result, serverUrl, trimmedUsername) } } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUserWithOAuth.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUserWithOAuth.kt new file mode 100644 index 00000000000..87ea9e47967 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUserWithOAuth.kt @@ -0,0 +1,32 @@ +package org.dhis2.mobile.login.main.domain.usecase + +import org.dhis2.mobile.login.main.data.LoginRepository +import org.dhis2.mobile.login.main.domain.model.LoginResult +import org.dhis2.mobile.login.resources.Res +import org.dhis2.mobile.login.resources.missing_username_from_oauth_login_response +import org.jetbrains.compose.resources.getString + +class LoginUserWithOAuth( + repository: LoginRepository, +) : BaseLogin(repository) { + suspend operator fun invoke( + serverUrl: String, + code: String, + ): LoginResult { + val result = repository.loginUserWithOAuth(serverUrl, code) + return when { + result.isSuccess -> { + val username = result.getOrNull() + if (username != null) { + handleResult(Result.success(Unit), serverUrl, username) + } else { + LoginResult.Error(getString(Res.string.missing_username_from_oauth_login_response)) + } + } + + else -> { + LoginResult.Error(result.exceptionOrNull()?.message) + } + } + } +} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/OpenIdLogin.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/OpenIdLogin.kt index 3ec5ccda6e4..8ab72e4d9a3 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/OpenIdLogin.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/OpenIdLogin.kt @@ -2,30 +2,14 @@ package org.dhis2.mobile.login.main.domain.usecase import org.dhis2.mobile.login.main.data.LoginRepository import org.dhis2.mobile.login.main.domain.model.LoginResult +import org.dhis2.mobile.login.main.domain.model.OpenIdLoginConfiguration class OpenIdLogin( repository: LoginRepository, ) : BaseLogin(repository) { - suspend operator fun invoke( - serverUrl: String, - isNetworkAvailable: Boolean, - clientId: String, - redirectUri: String, - discoveryUri: String?, - authorizationUri: String?, - tokenUrl: String?, - ): LoginResult { - val result = - repository.loginWithOpenId( - serverUrl = serverUrl, - isNetworkAvailable = isNetworkAvailable, - clientId = clientId, - redirectUri = redirectUri, - discoveryUri = discoveryUri, - authorizationUri = authorizationUri, - tokenUrl = tokenUrl, - ) + suspend operator fun invoke(openIdLoginConfiguration: OpenIdLoginConfiguration): LoginResult { + val result = repository.loginWithOpenId(openIdLoginConfiguration) val username = if (result.isSuccess) repository.getUsername() else "" - return handleResult(result, serverUrl, username) + return handleResult(result, openIdLoginConfiguration.serverUrl, username) } } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/ProcessDeviceEnrollment.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/ProcessDeviceEnrollment.kt new file mode 100644 index 00000000000..36af353f0f2 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/ProcessDeviceEnrollment.kt @@ -0,0 +1,22 @@ +package org.dhis2.mobile.login.main.domain.usecase + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.login.main.data.LoginRepository +import org.dhis2.mobile.login.main.domain.model.DeviceEnrollmentInfo + +class ProcessDeviceEnrollment( + private val repository: LoginRepository, +) : UseCase { + override suspend fun invoke(input: DeviceEnrollmentInfo): Result = + try { + Result.success( + repository.enrollDevice( + iat = input.iat, + serverURL = input.serverURL, + ), + ) + } catch (e: DomainError) { + Result.failure(e) + } +} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/UpdateBiometricPermission.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/UpdateBiometricPermission.kt index 71aa7cb3eaf..b9cf84f5d14 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/UpdateBiometricPermission.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/domain/usecase/UpdateBiometricPermission.kt @@ -32,20 +32,19 @@ class UpdateBiometricPermission( val cancellationMessage = getString(Res.string.biometrics_permission_cancelled) suspendCancellableCoroutine { continuation -> val scope = CoroutineScope(continuation.context) - cryptographics.getInitializedCipherForEncryption()?.let { - biometrics.authenticate(it) { cryptoObjectCipher -> - scope.launch { - val ciphertextWrapper = - cryptographics.encryptData(password, cryptoObjectCipher) - preferences.saveUserCredentialsAndCipher( - serverUrl, - username, - ciphertextWrapper, - ) - loginRepository.updateBiometricsPermissions(granted) - if (continuation.isActive) { - continuation.resume(Result.success(Unit)) - } + val cipher = cryptographics.getInitializedCipherForEncryption() + biometrics.authenticate(cipher) { cryptoObjectCipher -> + scope.launch { + val ciphertextWrapper = + cryptographics.encryptData(password, cryptoObjectCipher) + preferences.saveUserCredentialsAndCipher( + serverUrl, + username, + ciphertextWrapper, + ) + loginRepository.updateBiometricsPermissions(granted) + if (continuation.isActive) { + continuation.resume(Result.success(Unit)) } } } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/CredentialsScreen.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/CredentialsScreen.kt index 8bd562f811c..09e10afd240 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/CredentialsScreen.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/CredentialsScreen.kt @@ -3,6 +3,7 @@ package org.dhis2.mobile.login.main.ui.screen import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Login import androidx.compose.material.icons.automirrored.outlined.ShowChart @@ -37,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles @@ -57,8 +60,8 @@ import org.dhis2.mobile.login.main.ui.state.CredentialsUpdate import org.dhis2.mobile.login.main.ui.state.LoginState import org.dhis2.mobile.login.main.ui.state.OidcInfo import org.dhis2.mobile.login.main.ui.viewmodel.CredentialsViewModel -import org.dhis2.mobile.login.pin.ui.components.PinBottomSheet -import org.dhis2.mobile.login.pin.ui.components.PinMode +import org.dhis2.mobile.login.pin.domain.model.PinMode +import org.dhis2.mobile.login.pin.ui.components.PinDialog import org.dhis2.mobile.login.resources.Res import org.dhis2.mobile.login.resources.action_log_in import org.dhis2.mobile.login.resources.action_manage_account @@ -97,11 +100,18 @@ import org.hisp.dhis.mobile.ui.designsystem.component.state.BottomSheetShellUISt import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberAdditionalInfoColumnState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberListCardState import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +const val CREDENTIALS_USERNAME_INPUT_TAG = "credentials_username_input" +const val CREDENTIALS_PASSWORD_INPUT_TAG = "credentials_password_input" +const val CREDENTIALS_LOGIN_BUTTON_TAG = "credentials_login_button" +const val CREDENTIALS_ERROR_INFO_BAR_TAG = "credentials_error_info_bar" +const val CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG = "credentials_manage_accounts_button" + @OptIn(ExperimentalSharedTransitionApi::class) @Composable context(sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope) @@ -113,6 +123,7 @@ fun CredentialsScreen( allowRecovery: Boolean, oidcInfo: OidcInfo?, fromHome: Boolean, + oAuthEnable: Boolean, ) { val context = LocalPlatformContext.current @@ -126,6 +137,7 @@ fun CredentialsScreen( oidcInfo, context, fromHome, + oAuthEnable, ) } @@ -183,25 +195,28 @@ fun CredentialsScreen( serverImageUrl = selectedServerFlag, ) } - CredentialsContainer( - availableUsernames = screenState.credentialsInfo.availableUsernames, - username = screenState.credentialsInfo.username, - password = screenState.credentialsInfo.password, - isUsernameEditable = screenState.credentialsInfo.usernameCanBeEdited, - isLoggingIn = isLoggingIn, - onCredentialsUpdate = { credentialsUpdate -> - when (credentialsUpdate) { - CredentialsUpdate.Complete -> - viewModel.onLoginClicked() - is CredentialsUpdate.Password -> - viewModel.updatePassword(credentialsUpdate.password) + if (!oAuthEnable) { + CredentialsContainer( + availableUsernames = screenState.credentialsInfo.availableUsernames, + username = screenState.credentialsInfo.username, + password = screenState.credentialsInfo.password, + isUsernameEditable = screenState.credentialsInfo.usernameCanBeEdited, + isLoggingIn = isLoggingIn, + onCredentialsUpdate = { credentialsUpdate -> + when (credentialsUpdate) { + CredentialsUpdate.Complete -> + viewModel.onLoginClicked() - is CredentialsUpdate.Username -> - viewModel.updateUsername(credentialsUpdate.username) - } - }, - ) + is CredentialsUpdate.Password -> + viewModel.updatePassword(credentialsUpdate.password) + + is CredentialsUpdate.Username -> + viewModel.updateUsername(credentialsUpdate.username) + } + }, + ) + } LoginStatus( isLoggingIn = isLoggingIn, loginErrorMessage = screenState.errorMessage, @@ -237,7 +252,7 @@ fun CredentialsScreen( } if (screenState.isSessionLocked) { - PinBottomSheet( + PinDialog( mode = PinMode.ASK, onSuccess = { viewModel.onPinUnlocked() @@ -262,11 +277,13 @@ private fun handleCredentialAction( CredentialsAction.OnLoginClicked -> viewModel.onLoginClicked() + CredentialsAction.OnManageAccounts -> viewModel.onManageAccountsClicked() CredentialsAction.OnOpenIdLogin -> viewModel.onOpenIdLogin() + CredentialsAction.OnRecoverAccount -> viewModel.onRecoverAccountClicked() } @@ -319,6 +336,11 @@ private fun ServerInfo( ), listAvatar = { Avatar( + modifier = + Modifier.background( + color = SurfaceColor.PrimaryContainer, + shape = CircleShape, + ), style = flag?.let { painter -> AvatarStyleData.Image(painter) @@ -384,7 +406,7 @@ private fun CredentialsContainer( ) { if (isUsernameEditable) { InputUser( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag(CREDENTIALS_USERNAME_INPUT_TAG), uiModel = InputUserModel( title = stringResource(Res.string.username_hint), @@ -417,7 +439,7 @@ private fun CredentialsContainer( ) } InputPassword( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag(CREDENTIALS_PASSWORD_INPUT_TAG), uiModel = InputPasswordModel( title = stringResource(Res.string.password_hint), @@ -471,7 +493,7 @@ private fun LoginStatus( ) } else if (loginErrorMessage != null) { InfoBar( - modifier = Modifier, + modifier = Modifier.testTag(CREDENTIALS_ERROR_INFO_BAR_TAG), text = loginErrorMessage, textColor = MaterialTheme.colorScheme.onErrorContainer, backgroundColor = MaterialTheme.colorScheme.errorContainer, @@ -501,7 +523,7 @@ private fun CredentialActions( verticalArrangement = spacedBy(Spacing.Spacing0), ) { Button( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag(CREDENTIALS_LOGIN_BUTTON_TAG), enabled = canLogin, text = stringResource(Res.string.action_log_in), style = ButtonStyle.FILLED, @@ -568,7 +590,9 @@ private fun CredentialActions( } Button( modifier = Modifier.fillMaxWidth(), - text = oidcInfo.buttonText ?: stringResource(Res.string.action_openid_log_in), + text = + oidcInfo.buttonText.takeIf { !it.isNullOrEmpty() } + ?: stringResource(Res.string.action_openid_log_in), icon = { Icon( imageVector = Icons.AutoMirrored.Outlined.Login, @@ -591,7 +615,10 @@ private fun CredentialActions( contentAlignment = Alignment.BottomCenter, ) { Button( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .testTag(CREDENTIALS_MANAGE_ACCOUNTS_BUTTON_TAG), text = stringResource(Res.string.action_manage_account), style = ButtonStyle.OUTLINED, onClick = { diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/LoginScreen.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/LoginScreen.kt index 7c8d14f739d..c5de1106f2a 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/LoginScreen.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/LoginScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -70,13 +71,11 @@ import org.hisp.dhis.mobile.ui.designsystem.component.menu.DropDownMenu import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuItemData import org.hisp.dhis.mobile.ui.designsystem.component.menu.MenuLeadingElement import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor -import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import org.hisp.dhis.mobile.ui.designsystem.theme.dropShadow import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -93,6 +92,7 @@ fun LoginScreen( ) { val context = LocalPlatformContext.current val viewModel = koinViewModel { parametersOf(context) } + val fixedOidcInfo = koinInject() var displayMoreActions by remember { mutableStateOf(false) } var displayBackArrow by remember { mutableStateOf(false) } val snackBarHostState = remember { SnackbarHostState() } @@ -137,8 +137,6 @@ fun LoginScreen( Snackbar( modifier = Modifier.dropShadow(shape = SnackbarDefaults.shape), snackbarData = data, - containerColor = SurfaceColor.SurfaceBright, - contentColor = TextColor.OnSurface, ) } }, @@ -201,8 +199,8 @@ fun LoginScreen( onManageAccounts = viewModel::onBackToManageAccounts, ) } - composable { - val arg = it.toRoute() + composable { + val arg = it.toRoute() displayMoreActions = arg.selectedServer.isEmpty() displayBackArrow = true CredentialsScreen( @@ -212,17 +210,21 @@ fun LoginScreen( selectedServerFlag = arg.selectedServerFlag, allowRecovery = arg.allowRecovery, oidcInfo = - fixedOpenIdProvider()?.takeIf { info -> + fixedOpenIdProvider(fixedOidcInfo).takeIf { info -> info.serverUrl == arg.selectedServer }, fromHome = fromHome, + oAuthEnable = arg.oAuthEnabled, ) } - composable { - val args = it.toRoute() - WebAuthenticator(url = args.selectedServer) { - viewModel.onOauthLoginCancelled() - } + composable { + val args = it.toRoute() + WebAuthenticator( + url = args.selectedServer, + onDismiss = { + viewModel.onOauthLoginCancelled() + }, + ) } composable { displayMoreActions = true @@ -245,14 +247,12 @@ fun LoginScreen( /** * OpenId Configuration - * Return either OidcInfo.Token or OidcInfo.Discovery classes to configure the login screen. - * Don't forget to add the RedirectUriReceiverActivity in the android manifest. Check the - * documentation for more info. + * Returns oidcInfo setup by environment variables, if you want to configure manually, + * replace it by either OidcInfo.Token or OidcInfo.Discovery classes. + * Don't forget to add the auth scheme in RedirectUriReceiverActivity in the android manifest. + * Check the documentation for more info. * */ -private fun fixedOpenIdProvider(): OidcInfo? { - // Change to the correct provider - return null -} +private fun fixedOpenIdProvider(oidcInfo: OidcInfo): OidcInfo = oidcInfo @Composable fun LoginTopBar( diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/ServerValidationContent.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/ServerValidationContent.kt index f8a34e422b3..31446065e0b 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/ServerValidationContent.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/screen/ServerValidationContent.kt @@ -131,7 +131,7 @@ internal fun ServerValidationContent( server = it ?: TextFieldValue() }, imeAction = ImeAction.Done, - onNextClicked = { + onImeActionClick = { onValidate(server.text) inputFocusState = InputShellState.UNFOCUSED }, diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/state/CredentialsUiState.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/state/CredentialsUiState.kt index b6e2c17fc2c..40602a77f60 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/state/CredentialsUiState.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/state/CredentialsUiState.kt @@ -14,6 +14,7 @@ data class CredentialsUiState( val displayBiometricsDialog: Boolean, val hasOtherAccounts: Boolean, val isSessionLocked: Boolean, + val oAuthEnable: Boolean, ) { fun username() = serverInfo.username ?: credentialsInfo.username } @@ -37,6 +38,7 @@ sealed class OidcInfo( val buttonText: String?, val oidcClientId: String, val oidcRedirectUri: String, + val userPrompt: String?, ) { @Serializable data class Discovery( @@ -45,7 +47,14 @@ sealed class OidcInfo( val clientId: String, val redirectUri: String, val discoveryUri: String, - ) : OidcInfo(server, loginButtonText, clientId, redirectUri) + val prompt: String?, + ) : OidcInfo( + serverUrl = server, + buttonText = loginButtonText, + oidcClientId = clientId, + oidcRedirectUri = redirectUri, + userPrompt = prompt, + ) @Serializable data class Token( @@ -55,7 +64,14 @@ sealed class OidcInfo( val redirectUri: String, val authorizationUrl: String, val tokenUrl: String, - ) : OidcInfo(server, loginLabel, clientId, redirectUri) + val prompt: String?, + ) : OidcInfo( + serverUrl = server, + buttonText = loginLabel, + oidcClientId = clientId, + oidcRedirectUri = redirectUri, + userPrompt = prompt, + ) fun discoveryUri() = when (this) { diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt index e61bb6a1f27..1b528d92bf2 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModel.kt @@ -13,17 +13,23 @@ import org.dhis2.mobile.commons.domain.invoke import org.dhis2.mobile.commons.extensions.launchUseCase import org.dhis2.mobile.commons.extensions.withMinimumDuration import org.dhis2.mobile.commons.network.NetworkStatusProvider +import org.dhis2.mobile.login.main.domain.model.DeviceEnrollmentInfo import org.dhis2.mobile.login.main.domain.model.LoginResult import org.dhis2.mobile.login.main.domain.model.LoginScreenState +import org.dhis2.mobile.login.main.domain.model.OpenIdLoginConfiguration import org.dhis2.mobile.login.main.domain.usecase.BiometricLogin import org.dhis2.mobile.login.main.domain.usecase.GetAvailableUsernames import org.dhis2.mobile.login.main.domain.usecase.GetBiometricInfo +import org.dhis2.mobile.login.main.domain.usecase.GetDeviceEnrollmentUrl import org.dhis2.mobile.login.main.domain.usecase.GetHasOtherAccounts import org.dhis2.mobile.login.main.domain.usecase.LogOutUser import org.dhis2.mobile.login.main.domain.usecase.LoginUser +import org.dhis2.mobile.login.main.domain.usecase.LoginUserWithOAuth import org.dhis2.mobile.login.main.domain.usecase.OpenIdLogin +import org.dhis2.mobile.login.main.domain.usecase.ProcessDeviceEnrollment import org.dhis2.mobile.login.main.domain.usecase.UpdateBiometricPermission import org.dhis2.mobile.login.main.domain.usecase.UpdateTrackingPermission +import org.dhis2.mobile.login.main.ui.navigation.AppLinkNavigation import org.dhis2.mobile.login.main.ui.navigation.Navigator import org.dhis2.mobile.login.main.ui.state.AfterLoginAction import org.dhis2.mobile.login.main.ui.state.CredentialsInfo @@ -43,8 +49,12 @@ class CredentialsViewModel( private val logOutUser: LogOutUser, private val biometricLogin: BiometricLogin, private val openIdLogin: OpenIdLogin, + private val loginUserWithOAuth: LoginUserWithOAuth, + private val getDeviceEnrollmentUrl: GetDeviceEnrollmentUrl, + private val processDeviceEnrollment: ProcessDeviceEnrollment, private val updateTrackingPermission: UpdateTrackingPermission, private val updateBiometricPermission: UpdateBiometricPermission, + private val appLinkNavigation: AppLinkNavigation, networkStatusProvider: NetworkStatusProvider, private val serverName: String?, private val serverUrl: String, @@ -54,6 +64,7 @@ class CredentialsViewModel( private val forgotPinUseCase: ForgotPinUseCase, private val oidcInfo: OidcInfo?, private val fromHome: Boolean, + private val oAuthEnable: Boolean, ) : ViewModel() { private val isNetworkOnline = networkStatusProvider.connectionStatus @@ -87,6 +98,7 @@ class CredentialsViewModel( hasOtherAccounts = false, isSessionLocked = false, displayBiometricsDialog = false, + oAuthEnable = oAuthEnable, ) private var loginJob: Job? = null @@ -102,6 +114,16 @@ class CredentialsViewModel( initialValue = initialState, ) + init { + launchUseCase { + appLinkNavigation.appLink.collect { urlString -> + if (credentialsScreenState.value.loginState is LoginState.Running) { + handleOAuthCallbacks(urlString) + } + } + } + } + private fun loadData() { launchUseCase { val biometricInfo = getBiometricInfo(serverUrl) @@ -121,7 +143,7 @@ class CredentialsViewModel( availableUsernames = getAvailableUsernames(), usernameCanBeEdited = username == null, ), - loginState = LoginState.Disabled, + loginState = if (oAuthEnable) LoginState.Enabled else LoginState.Disabled, errorMessage = null, allowRecovery = allowRecovery, canUseBiometrics = getBiometricInfo(serverUrl).canUseBiometrics, @@ -130,8 +152,116 @@ class CredentialsViewModel( hasOtherAccounts = getHasOtherAccounts(), isSessionLocked = getIsSessionLockedUseCase(), displayBiometricsDialog = biometricInfo.canUseBiometrics && !fromHome, + oAuthEnable = oAuthEnable, ), ) + + if (_credentialsScreenState.value.oAuthEnable && !fromHome) { + fetchOAuthEnrollmentUrl() + } + } + } + + private fun fetchOAuthEnrollmentUrl() { + _credentialsScreenState.update { + it.copy( + loginState = LoginState.Running, + ) + } + launchUseCase { + getDeviceEnrollmentUrl(serverUrl).fold( + onSuccess = { enrollmentURL -> + // First OAuth call (enrollment) - clear any previous OAuth sessions + navigator.navigate( + LoginScreenState.OauthAuthentication( + selectedServer = enrollmentURL, + ), + ) + }, + onFailure = { error -> + _credentialsScreenState.update { + it.copy( + loginState = LoginState.Enabled, + errorMessage = error.message, + ) + } + }, + ) + } + } + + private fun handleOAuthCallbacks(urlString: String) { + // First check if there is any error + val error = urlString.substringAfter("error=", "").substringBefore('&') + if (error.isNotEmpty()) { + _credentialsScreenState.update { + it.copy( + errorMessage = error, + loginState = LoginState.Enabled, + ) + } + return + } + + // Check if there is a device enrollment callback + val iat = urlString.substringAfter("iat=", "").substringBefore('&') + if (iat.isNotEmpty()) { + registerDevice(iat) + return + } + + // Check if there is a login callback with the authorization code + val code = urlString.substringAfter("code=", "").substringBefore('&') + if (code.isNotEmpty()) { + loginWithOAuthCode(code) + return + } + + _credentialsScreenState.update { + it.copy( + loginState = LoginState.Enabled, + ) + } + } + + private fun loginWithOAuthCode(code: String) { + startLoginJob { + loginUserWithOAuth( + serverUrl = serverUrl, + code = code, + ) + } + } + + private fun registerDevice(enrollmentIat: String) { + launchUseCase { + _credentialsScreenState.update { + it.copy(loginState = LoginState.Running) + } + + processDeviceEnrollment( + DeviceEnrollmentInfo( + iat = enrollmentIat, + serverURL = serverUrl, + ), + ).fold( + onSuccess = { consentUrl -> + // Second OAuth call (consent) - keep session from enrollment + navigator.navigate( + LoginScreenState.OauthAuthentication( + selectedServer = consentUrl, + ), + ) + }, + onFailure = { error -> + _credentialsScreenState.update { + it.copy( + errorMessage = error.message, + loginState = LoginState.Enabled, + ) + } + }, + ) } } @@ -176,26 +306,33 @@ class CredentialsViewModel( } fun onLoginClicked() { - startLoginJob { - loginUser( - serverUrl = _credentialsScreenState.value.serverInfo.serverUrl, - username = _credentialsScreenState.value.credentialsInfo.username, - password = _credentialsScreenState.value.credentialsInfo.password, - isNetworkAvailable = isNetworkOnline.value, - ) + if (_credentialsScreenState.value.oAuthEnable) { + fetchOAuthEnrollmentUrl() + } else { + startLoginJob { + loginUser( + serverUrl = _credentialsScreenState.value.serverInfo.serverUrl, + username = _credentialsScreenState.value.credentialsInfo.username, + password = _credentialsScreenState.value.credentialsInfo.password, + isNetworkAvailable = isNetworkOnline.value, + ) + } } } fun onOpenIdLogin() { startLoginJob { openIdLogin( - serverUrl = _credentialsScreenState.value.serverInfo.serverUrl, - isNetworkAvailable = isNetworkOnline.value, - clientId = _credentialsScreenState.value.oidcInfo?.oidcClientId ?: "", - redirectUri = _credentialsScreenState.value.oidcInfo?.oidcRedirectUri ?: "", - discoveryUri = _credentialsScreenState.value.oidcInfo?.discoveryUri(), - authorizationUri = _credentialsScreenState.value.oidcInfo?.authorizationUri(), - tokenUrl = _credentialsScreenState.value.oidcInfo?.tokenUrl(), + OpenIdLoginConfiguration( + serverUrl = _credentialsScreenState.value.serverInfo.serverUrl, + isNetworkAvailable = isNetworkOnline.value, + clientId = _credentialsScreenState.value.oidcInfo?.oidcClientId ?: "", + redirectUri = _credentialsScreenState.value.oidcInfo?.oidcRedirectUri ?: "", + discoveryUri = _credentialsScreenState.value.oidcInfo?.discoveryUri(), + authorizationUri = _credentialsScreenState.value.oidcInfo?.authorizationUri(), + tokenUrl = _credentialsScreenState.value.oidcInfo?.tokenUrl(), + prompt = _credentialsScreenState.value.oidcInfo?.userPrompt, + ), ) } } @@ -204,6 +341,7 @@ class CredentialsViewModel( _credentialsScreenState.update { it.copy( loginState = LoginState.Running, + errorMessage = null, ) } loginJob = diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModel.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModel.kt index b3295c1633c..dd3b7b90e67 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModel.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModel.kt @@ -1,24 +1,20 @@ package org.dhis2.mobile.login.main.ui.viewmodel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import org.dhis2.mobile.commons.extensions.launchUseCase import org.dhis2.mobile.commons.extensions.withMinimumDuration import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.login.main.domain.model.LoginScreenState -import org.dhis2.mobile.login.main.domain.model.LoginScreenState.LegacyLogin -import org.dhis2.mobile.login.main.domain.model.LoginScreenState.OauthLogin +import org.dhis2.mobile.login.main.domain.model.LoginScreenState.LoginCredentials import org.dhis2.mobile.login.main.domain.model.ServerValidationResult import org.dhis2.mobile.login.main.domain.usecase.GetInitialScreen import org.dhis2.mobile.login.main.domain.usecase.ImportDatabase import org.dhis2.mobile.login.main.domain.usecase.ValidateServer -import org.dhis2.mobile.login.main.ui.navigation.AppLinkNavigation import org.dhis2.mobile.login.main.ui.navigation.Navigator import org.dhis2.mobile.login.main.ui.state.DatabaseImportState import org.dhis2.mobile.login.main.ui.state.ServerValidationUiState @@ -28,17 +24,8 @@ class LoginViewModel( private val getInitialScreen: GetInitialScreen, private val importDatabase: ImportDatabase, private val validateServer: ValidateServer, - private val appLinkNavigation: AppLinkNavigation, - networkStatusProvider: NetworkStatusProvider, + private val networkStatusProvider: NetworkStatusProvider, ) : ViewModel() { - private val isNetworkOnline = - networkStatusProvider.connectionStatus - .stateIn( - viewModelScope, - SharingStarted.Eagerly, - false, - ) - private val _serverValidationState = MutableStateFlow(ServerValidationUiState()) val serverValidationState = _serverValidationState.asStateFlow() @@ -46,14 +33,8 @@ class LoginViewModel( val importDatabaseState = _importDatabaseState.asStateFlow() private var serverValidationJob: Job? = null - private val redirectUri = "https://vgarciabnz.github.io" init { - launchUseCase { - appLinkNavigation.appLink.collect { urlString -> - handleAppLink(urlString) - } - } goToInitialScreen() } @@ -81,8 +62,11 @@ class LoginViewModel( } serverValidationJob = launchUseCase { + val isOnline = networkStatusProvider.connectionStatus.firstOrNull() ?: false val result = - withMinimumDuration { validateServer(serverUrl, isNetworkOnline.value) } + withMinimumDuration { + validateServer(serverUrl, isOnline) + } when (result) { is ServerValidationResult.Error -> { _serverValidationState.update { @@ -94,24 +78,20 @@ class LoginViewModel( } } - is ServerValidationResult.Legacy -> { + is ServerValidationResult.Success -> { navigator.navigate( destination = - LegacyLogin( + LoginCredentials( serverName = result.serverName, allowRecovery = result.allowRecovery, selectedServer = serverUrl, selectedServerFlag = result.countryFlag, selectedUsername = null, + oAuthEnabled = result.oAuthEnabled, ), ) stopValidation() } - - ServerValidationResult.Oauth -> { - navigator.navigate(OauthLogin(serverUrl)) - stopValidation() - } } } } @@ -125,23 +105,6 @@ class LoginViewModel( _serverValidationState.update { it.copy(validationRunning = false) } } - private fun handleAppLink(urlString: String) { - if (urlString.startsWith(redirectUri)) { - val code = urlString.substringAfter("code=").substringBefore('&') - if (code.isNotEmpty()) { - // TODO "Use the authorization code to get a token and log in, then show statistics screen" - } else { - val error = urlString.substringAfter("error=").substringBefore('&') - _serverValidationState.update { - it.copy( - error = error, - validationRunning = false, - ) - } - } - } - } - fun onOauthLoginCancelled() { navigateUp() } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/di/PinModule.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/di/PinModule.kt index f5bcb514cb0..5f9529f4db6 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/di/PinModule.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/di/PinModule.kt @@ -1,15 +1,16 @@ package org.dhis2.mobile.login.pin.di +import org.dhis2.mobile.login.pin.domain.model.PinMode import org.dhis2.mobile.login.pin.domain.usecase.ForgotPinUseCase import org.dhis2.mobile.login.pin.domain.usecase.GetIsSessionLockedUseCase import org.dhis2.mobile.login.pin.domain.usecase.SavePinUseCase import org.dhis2.mobile.login.pin.domain.usecase.ValidatePinUseCase import org.dhis2.mobile.login.pin.ui.provider.PinResourceProvider +import org.dhis2.mobile.login.pin.ui.provider.PinResourceProviderImpl import org.dhis2.mobile.login.pin.ui.viewmodel.PinViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf -import org.koin.core.module.dsl.singleOf -import org.koin.core.module.dsl.viewModelOf +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module /** @@ -24,10 +25,10 @@ internal val pinModule = factoryOf(::GetIsSessionLockedUseCase) // PIN resource provider - singleOf(::PinResourceProvider) + single { PinResourceProviderImpl() } - // PIN ViewModel - viewModelOf(::PinViewModel) + // PIN ViewModel — receives PinMode as a runtime parameter via Koin parametersOf + viewModel { params -> PinViewModel(params.get(), get(), get(), get(), get()) } } /** diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/domain/model/PinMode.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/domain/model/PinMode.kt new file mode 100644 index 00000000000..e7b48108cf3 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/domain/model/PinMode.kt @@ -0,0 +1,16 @@ +package org.dhis2.mobile.login.pin.domain.model + +/** + * PIN mode enumeration for different bottom sheet behaviors. + */ +enum class PinMode { + /** + * SET mode - Used when creating/setting a new PIN. + */ + SET, + + /** + * ASK mode - Used when verifying/entering an existing PIN. + */ + ASK, +} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/domain/model/PinState.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/domain/model/PinState.kt deleted file mode 100644 index 57c49ff0cb2..00000000000 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/domain/model/PinState.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.dhis2.mobile.login.pin.domain.model - -/** - * Represents the different states of PIN operations in the UI. - */ -sealed class PinState { - /** - * Initial/Idle state - waiting for user interaction. - */ - data object Idle : PinState() - - /** - * PIN operation in progress. - */ - data object Loading : PinState() - - /** - * PIN validation or save was successful. - */ - data object Success : PinState() - - /** - * The PIN entry UI was dismissed. - */ - data object Dismissed : PinState() - - /** - * PIN validation failed with an error message. - * @param message The error message to display. - * @param remainingAttempts Number of remaining attempts before forced logout (null if not applicable). - */ - data class Error( - val message: String, - val remainingAttempts: Int? = null, - ) : PinState() - - /** - * Too many failed attempts - will trigger forgot PIN flow. - */ - data object TooManyAttempts : PinState() -} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/components/PinBottomSheet.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/components/PinBottomSheet.kt deleted file mode 100644 index 24f56f4246a..00000000000 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/components/PinBottomSheet.kt +++ /dev/null @@ -1,363 +0,0 @@ -package org.dhis2.mobile.login.pin.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.style.TextAlign -import org.dhis2.mobile.commons.extensions.getWindowSizeClass -import org.dhis2.mobile.login.pin.domain.model.PinState -import org.dhis2.mobile.login.pin.ui.viewmodel.PinViewModel -import org.dhis2.mobile.login.resources.Res -import org.dhis2.mobile.login.resources.create_pin -import org.dhis2.mobile.login.resources.create_pin_button -import org.dhis2.mobile.login.resources.create_pin_description -import org.dhis2.mobile.login.resources.enter_pin -import org.dhis2.mobile.login.resources.enter_pin_button -import org.dhis2.mobile.login.resources.enter_pin_description -import org.dhis2.mobile.login.resources.forgot_pin_button -import org.dhis2.mobile.login.resources.pin_error_remaining_attempts -import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell -import org.hisp.dhis.mobile.ui.designsystem.component.Button -import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle -import org.hisp.dhis.mobile.ui.designsystem.component.InputSegmentedShell -import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData -import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState -import org.hisp.dhis.mobile.ui.designsystem.component.model.SegmentedShellType -import org.hisp.dhis.mobile.ui.designsystem.component.state.BottomSheetShellDefaults -import org.hisp.dhis.mobile.ui.designsystem.component.state.BottomSheetShellUIState -import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel - -/** - * PIN mode enumeration for different bottom sheet behaviors. - */ -enum class PinMode { - /** - * SET mode - Used when creating/setting a new PIN. - */ - SET, - - /** - * ASK mode - Used when verifying/entering an existing PIN. - */ - ASK, -} - -/** - * DHIS2 PIN Bottom Sheet component with integrated ViewModel. - * - * A modal bottom sheet that displays a PIN input interface using DHIS2 Mobile UI components. - * Supports both SET (creating PIN) and ASK (verifying PIN) modes. - * Uses [BottomSheetShell] as container and [InputSegmentedShell] for PIN input. - * - * State management is handled internally by [PinViewModel], automatically injected via Koin, - * including PIN validation, attempt tracking, and error messages. - * - * @param mode The PIN mode (SET for creation, ASK for verification). - * @param onSuccess Callback invoked when PIN operation succeeds. - * @param onDismiss Callback invoked when the bottom sheet is dismissed. - * @param modifier Optional modifier for customization. - * @param windowSizeClass Window size class for layout adjustments. - */ -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) -@Composable -fun PinBottomSheet( - mode: PinMode, - onSuccess: () -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - windowSizeClass: WindowSizeClass = getWindowSizeClass(), -) { - val viewModel: PinViewModel = koinViewModel() - val uiState by viewModel.uiState.collectAsState() - var currentPin by remember { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } - - // Handle state changes from ViewModel - LaunchedEffect(uiState) { - when (uiState) { - is PinState.Success -> { - onSuccess() - } - - is PinState.TooManyAttempts -> { - viewModel.onForgotPin() - } - - is PinState.Dismissed -> { - viewModel.resetAttempts() - viewModel.resetState() - onDismiss() - } - - else -> { // Do nothing for Idle, Loading, Error - } - } - } - - // Reset attempts when dismissed - LaunchedEffect(Unit) { - return@LaunchedEffect - } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - val (title, subtitle) = - when (mode) { - PinMode.SET -> stringResource(Res.string.create_pin) to stringResource(Res.string.create_pin_description) - PinMode.ASK -> stringResource(Res.string.enter_pin) to stringResource(Res.string.enter_pin_description) - } - - val primaryButtonText = - when (mode) { - PinMode.SET -> stringResource(Res.string.create_pin_button) - PinMode.ASK -> stringResource(Res.string.enter_pin_button) - } - - val secondaryButtonText = - when (mode) { - PinMode.SET -> null - PinMode.ASK -> stringResource(Res.string.forgot_pin_button) - } - - // Get error message from ViewModel state - val errorMessage = - when (val state = uiState) { - is PinState.Error -> { - state.remainingAttempts?.let { attempts -> - "${state.message}. ${ - stringResource( - Res.string.pin_error_remaining_attempts, - attempts, - ) - }" - } ?: state.message - } - - else -> null - } - - val pinLength = 4 - val isLoading = uiState is PinState.Loading - - fun onPrimaryClick() { - viewModel.onPinComplete( - pin = currentPin.replace("-", ""), - mode = mode, - ) - } - - fun onSecondaryClick() { - viewModel.onForgotPin() - } - - val primaryButtonIsEnabled = - currentPin - .replace( - "-", - "", - ).length == pinLength && !isLoading - - val secondaryButtonIsEnabled = !isLoading - - BottomSheetShell( - uiState = - BottomSheetShellUIState( - title = title, - subtitle = null, - description = subtitle, - showTopSectionDivider = false, - showBottomSectionDivider = false, - headerTextAlignment = TextAlign.Center, - animateHeaderOnKeyboardAppearance = false, - scrollableContainerMinHeight = Spacing.Spacing40, - scrollableContainerMaxHeight = Spacing.Spacing840, - contentPadding = - PaddingValues( - horizontal = Spacing.Spacing24, - vertical = Spacing.Spacing0, - ), - ), - modifier = modifier, - content = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.Spacing16), - ) { - Spacer(modifier = Modifier.height(Spacing.Spacing8)) - - // PIN Input using InputSegmentedShell - InputSegmentedShell( - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - segmentCount = pinLength, - initialValue = null, - supportingTextData = - errorMessage?.let { - SupportingTextData( - text = it, - state = SupportingTextState.ERROR, - ) - }, - segmentedShellType = SegmentedShellType.Numeric, - onValueChanged = { newPin -> - currentPin = newPin - // Clear error when user starts typing - if (uiState is PinState.Error) { - viewModel.resetState() - } - }, - ) - - Spacer(modifier = Modifier.height(Spacing.Spacing8)) - } - }, - buttonBlock = { - when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> - VerticalButtonBlock( - primaryButton = { - PinPrimaryButton( - modifier = Modifier.fillMaxWidth(), - enabled = primaryButtonIsEnabled, - buttonText = primaryButtonText, - onClick = ::onPrimaryClick, - ) - }, - secondaryButton = - secondaryButtonText?.let { buttonText -> - { - PinSecondaryButton( - modifier = Modifier.fillMaxWidth(), - enabled = secondaryButtonIsEnabled, - buttonText = buttonText, - onClick = ::onSecondaryClick, - ) - } - }, - ) - - else -> - HorizontalButtonBlock( - primaryButton = { - PinPrimaryButton( - modifier = Modifier.weight(1f), - enabled = primaryButtonIsEnabled, - buttonText = primaryButtonText, - onClick = ::onPrimaryClick, - ) - }, - secondaryButton = - secondaryButtonText?.let { buttonText -> - { - PinSecondaryButton( - modifier = Modifier.weight(1f), - enabled = secondaryButtonIsEnabled, - buttonText = buttonText, - onClick = ::onSecondaryClick, - ) - } - }, - ) - } - }, - onDismiss = { - viewModel.resetAttempts() - viewModel.resetState() - onDismiss() - }, - ) -} - -@Composable -private fun VerticalButtonBlock( - primaryButton: @Composable () -> Unit, - secondaryButton: (@Composable () -> Unit)?, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(BottomSheetShellDefaults.buttonBlockPaddings()), - verticalArrangement = Arrangement.spacedBy(Spacing.Spacing8), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - primaryButton() - secondaryButton?.invoke() - } -} - -@Composable -private fun HorizontalButtonBlock( - primaryButton: @Composable RowScope.() -> Unit, - secondaryButton: (@Composable RowScope.() -> Unit)?, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(BottomSheetShellDefaults.buttonBlockPaddings()), - horizontalArrangement = Arrangement.spacedBy(Spacing.Spacing8), - verticalAlignment = Alignment.CenterVertically, - ) { - secondaryButton?.invoke(this) - primaryButton() - } -} - -@Composable -private fun PinPrimaryButton( - modifier: Modifier, - enabled: Boolean, - buttonText: String, - onClick: () -> Unit, -) { - Button( - modifier = modifier, - enabled = enabled, - style = ButtonStyle.FILLED, - text = buttonText, - onClick = onClick, - ) -} - -@Composable -private fun PinSecondaryButton( - modifier: Modifier, - enabled: Boolean, - buttonText: String, - onClick: () -> Unit, -) { - Button( - modifier = modifier, - enabled = enabled, - style = ButtonStyle.OUTLINED, - text = buttonText, - onClick = onClick, - ) -} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/components/PinDialog.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/components/PinDialog.kt new file mode 100644 index 00000000000..666ebd05609 --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/components/PinDialog.kt @@ -0,0 +1,511 @@ +package org.dhis2.mobile.login.pin.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Pin +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.dhis2.mobile.commons.extensions.deviceIsInLandscapeMode +import org.dhis2.mobile.commons.extensions.getWindowSizeClass +import org.dhis2.mobile.login.pin.domain.model.PinMode +import org.dhis2.mobile.login.pin.ui.state.PinUiState +import org.dhis2.mobile.login.pin.ui.viewmodel.PinViewModel +import org.hisp.dhis.mobile.ui.designsystem.component.Button +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle +import org.hisp.dhis.mobile.ui.designsystem.component.FullScreenDialog +import org.hisp.dhis.mobile.ui.designsystem.component.InputSegmentedShell +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState +import org.hisp.dhis.mobile.ui.designsystem.component.model.SegmentedShellType +import org.hisp.dhis.mobile.ui.designsystem.component.state.BottomSheetShellDefaults +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +/** + * DHIS2 PIN Bottom Sheet component with integrated ViewModel. + * + * A modal bottom sheet that displays a PIN input interface using DHIS2 Mobile UI components. + * Supports both SET (creating PIN) and ASK (verifying PIN) modes. + * Uses [FullScreenDialog] as container and [InputSegmentedShell] for PIN input. + * + * State management is handled internally by [PinViewModel], automatically injected via Koin, + * including PIN validation, attempt tracking, and error messages. + * + * @param mode The PIN mode (SET for creation, ASK for verification). + * @param onSuccess Callback invoked when PIN operation succeeds. + * @param onDismiss Callback invoked when the bottom sheet is dismissed. + * @param modifier Optional modifier for customization. + * @param windowSizeClass Window size class for layout adjustments. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun PinDialog( + mode: PinMode, + onSuccess: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + windowSizeClass: WindowSizeClass = getWindowSizeClass(), +) { + val viewModel: PinViewModel = koinViewModel { parametersOf(mode) } + val uiState by viewModel.uiState.collectAsState() + val isLandscape = deviceIsInLandscapeMode() + + LaunchedEffect(uiState.isSuccess, uiState.isDismissed, uiState.isTooManyAttempts) { + when { + uiState.isSuccess -> onSuccess() + uiState.isTooManyAttempts -> viewModel.onForgotPin() + uiState.isDismissed -> { + viewModel.resetAttempts() + viewModel.resetState() + onDismiss() + } + } + } + + PinContent( + uiState = uiState, + mode = mode, + isLandscape = isLandscape, + windowSizeClass = windowSizeClass, + onPinChanged = viewModel::onPinChanged, + onPrimaryClick = viewModel::onPinComplete, + onSecondaryClick = viewModel::onForgotPin, + onDismiss = { + viewModel.resetAttempts() + viewModel.resetState() + onDismiss() + }, + modifier = modifier, + ) +} + +/** + * Stateless UI content for the PIN bottom sheet. + * + * Renders the [FullScreenDialog] with either a portrait (stacked) or landscape (side-by-side) + * layout depending on [isLandscape]. This composable holds no business logic and is safe + * to use in Compose Previews. + * + * @param uiState The current UI state containing display content and button states. + * @param isLandscape Whether the device is in landscape orientation. + * @param windowSizeClass Window size class used to choose the button layout. + * @param onPinChanged Callback invoked on every PIN value change. + * @param onPrimaryClick Callback for the primary button click. + * @param onSecondaryClick Callback for the secondary button click. + * @param onDismiss Callback invoked when the dialog is dismissed. + * @param modifier Optional modifier for customization. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +internal fun PinContent( + uiState: PinUiState, + mode: PinMode, + isLandscape: Boolean, + windowSizeClass: WindowSizeClass, + onPinChanged: (String) -> Unit, + onPrimaryClick: () -> Unit, + onSecondaryClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + + FullScreenDialog( + onDismiss = onDismiss, + content = { + if (!isLandscape) { + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + PinHeader( + title = uiState.title, + subtitle = uiState.subtitle, + ) + + PinInputBlock( + uiState = uiState, + focusRequester = focusRequester, + windowSizeClass = windowSizeClass, + showPrimaryButtonIcon = mode == PinMode.SET, + onPinChanged = onPinChanged, + onPrimaryClick = onPrimaryClick, + onSecondaryClick = onSecondaryClick, + ) + } + } else { + Row( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .padding(horizontal = Spacing.Spacing56), + horizontalArrangement = Arrangement.spacedBy(Spacing.Spacing56), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(vertical = Spacing.Spacing32), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PinHeader( + title = uiState.title, + subtitle = uiState.subtitle, + ) + } + + VerticalDivider( + modifier = Modifier.padding(vertical = Spacing.Spacing32), + thickness = Spacing.Spacing1, + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Column( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(vertical = Spacing.Spacing32), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PinInputBlock( + uiState = uiState, + focusRequester = focusRequester, + windowSizeClass = windowSizeClass, + showPrimaryButtonIcon = mode == PinMode.SET, + onPinChanged = onPinChanged, + onPrimaryClick = onPrimaryClick, + onSecondaryClick = onSecondaryClick, + ) + } + } + } + }, + ) +} + +/** + * Displays the PIN screen header: a PIN icon, a title, and a subtitle. + * + * @param title The main heading text. + * @param subtitle The descriptive text shown below the title. + * @param modifier Optional modifier for customization. + */ +@Composable +private fun PinHeader( + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.Pin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(vertical = Spacing.Spacing16), + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = Spacing.Spacing16), + ) + } +} + +/** + * Displays the PIN input field and action buttons. + * + * Renders [InputSegmentedShell] for PIN entry and adapts the button layout + * (vertical vs horizontal) based on [windowSizeClass]. + * + * @param focusRequester Focus requester to autofocus the input field. + * @param windowSizeClass Window size class used to choose the button layout. + * @param onPinChanged Callback invoked on every PIN value change. + * @param onPrimaryClick Callback for the primary button click. + * @param onSecondaryClick Callback for the secondary button click. + * @param modifier Optional modifier for customization. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +private fun PinInputBlock( + uiState: PinUiState, + focusRequester: FocusRequester, + windowSizeClass: WindowSizeClass, + showPrimaryButtonIcon: Boolean, + onPinChanged: (String) -> Unit, + onPrimaryClick: () -> Unit, + onSecondaryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // Stabilize supportingTextData to prevent recomposition issues + val supportingTextData = + remember(uiState.errorMessage) { + uiState.errorMessage?.let { + SupportingTextData( + text = it, + state = SupportingTextState.ERROR, + ) + } + } + + // Request focus on initial composition only + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + InputSegmentedShell( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.Spacing16) + .focusRequester(focusRequester), + segmentCount = uiState.pinLength, + initialValue = null, + supportingTextData = supportingTextData, + segmentedShellType = SegmentedShellType.Numeric, + onValueChanged = onPinChanged, + ) + + Spacer(modifier = Modifier.height(Spacing.Spacing32)) + + when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> + VerticalButtonBlock( + primaryButton = { + PinPrimaryButton( + modifier = Modifier.fillMaxWidth(), + enabled = uiState.primaryButtonIsEnabled, + buttonText = uiState.primaryButtonText, + showIcon = showPrimaryButtonIcon, + onClick = onPrimaryClick, + ) + }, + secondaryButton = + uiState.secondaryButtonText?.let { buttonText -> + { + PinSecondaryButton( + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading, + buttonText = buttonText, + onClick = onSecondaryClick, + ) + } + }, + ) + + else -> + HorizontalButtonBlock( + primaryButton = { + PinPrimaryButton( + modifier = Modifier.weight(1f), + enabled = uiState.primaryButtonIsEnabled, + buttonText = uiState.primaryButtonText, + showIcon = showPrimaryButtonIcon, + onClick = onPrimaryClick, + ) + }, + secondaryButton = + uiState.secondaryButtonText?.let { buttonText -> + { + PinSecondaryButton( + modifier = Modifier.weight(1f), + enabled = !uiState.isLoading, + buttonText = buttonText, + onClick = onSecondaryClick, + ) + } + }, + ) + } + } +} + +@Composable +private fun VerticalButtonBlock( + primaryButton: @Composable () -> Unit, + secondaryButton: (@Composable () -> Unit)?, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(BottomSheetShellDefaults.buttonBlockPaddings()), + verticalArrangement = Arrangement.spacedBy(Spacing.Spacing8), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + primaryButton() + secondaryButton?.invoke() + } +} + +@Composable +private fun HorizontalButtonBlock( + primaryButton: @Composable RowScope.() -> Unit, + secondaryButton: (@Composable RowScope.() -> Unit)?, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(BottomSheetShellDefaults.buttonBlockPaddings()), + horizontalArrangement = Arrangement.spacedBy(Spacing.Spacing8), + verticalAlignment = Alignment.CenterVertically, + ) { + secondaryButton?.invoke(this) + primaryButton() + } +} + +@Composable +private fun PinPrimaryButton( + modifier: Modifier, + enabled: Boolean, + buttonText: String, + showIcon: Boolean, + onClick: () -> Unit, +) { + Button( + modifier = modifier, + enabled = enabled, + style = ButtonStyle.FILLED, + text = buttonText, + icon = + if (showIcon) { + { + Icon( + imageVector = Icons.Outlined.Key, + contentDescription = null, + ) + } + } else { + null + }, + onClick = onClick, + ) +} + +@Composable +private fun PinSecondaryButton( + modifier: Modifier, + enabled: Boolean, + buttonText: String, + onClick: () -> Unit, +) { + Button( + modifier = modifier, + enabled = enabled, + style = ButtonStyle.OUTLINED, + text = buttonText, + onClick = onClick, + ) +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Composable +fun PinAskPortraitPreview() { + val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(360.dp, 800.dp)) + DHIS2Theme { + PinContent( + uiState = + PinUiState( + title = "Enter your PIN", + subtitle = "Enter your 4-digit PIN to access your account.", + primaryButtonText = "Unlock", + secondaryButtonText = "Forgot your PIN?", + ), + mode = PinMode.ASK, + isLandscape = false, + windowSizeClass = windowSizeClass, + onPinChanged = {}, + onPrimaryClick = {}, + onSecondaryClick = {}, + onDismiss = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(showBackground = true, widthDp = 1280, heightDp = 800) +@Composable +fun PinAskLandscapePreview() { + val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(1280.dp, 800.dp)) + DHIS2Theme { + PinContent( + uiState = + PinUiState( + title = "Enter your PIN", + subtitle = "Enter your 4-digit PIN to access your account.", + primaryButtonText = "Unlock", + secondaryButtonText = "Forgot your PIN?", + ), + mode = PinMode.ASK, + isLandscape = true, + windowSizeClass = windowSizeClass, + onPinChanged = {}, + onPrimaryClick = {}, + onSecondaryClick = {}, + onDismiss = {}, + ) + } +} diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/provider/PinResourceProvider.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/provider/PinResourceProvider.kt index d31006d250c..4dd47b49af4 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/provider/PinResourceProvider.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/provider/PinResourceProvider.kt @@ -1,34 +1,86 @@ package org.dhis2.mobile.login.pin.ui.provider +import org.dhis2.mobile.login.pin.domain.model.PinMode import org.dhis2.mobile.login.resources.Res +import org.dhis2.mobile.login.resources.create_pin +import org.dhis2.mobile.login.resources.create_pin_button +import org.dhis2.mobile.login.resources.create_pin_description +import org.dhis2.mobile.login.resources.enter_pin +import org.dhis2.mobile.login.resources.enter_pin_button +import org.dhis2.mobile.login.resources.enter_pin_description +import org.dhis2.mobile.login.resources.forgot_pin_button import org.dhis2.mobile.login.resources.pin_error_incorrect import org.dhis2.mobile.login.resources.pin_error_no_pin +import org.dhis2.mobile.login.resources.pin_error_remaining_attempts import org.dhis2.mobile.login.resources.pin_error_reset_failed import org.dhis2.mobile.login.resources.pin_error_save_failed import org.jetbrains.compose.resources.getString /** * Provides string resources for PIN feature. - * This class decouples resource access from ViewModels, making testing easier. + * Extracted as an interface to allow test-friendly mocking. */ -class PinResourceProvider { - /** - * Returns the error message for incorrect PIN. - */ - suspend fun getPinErrorIncorrect(): String = getString(Res.string.pin_error_incorrect) - - /** - * Returns the error message when no PIN is stored. - */ - suspend fun getPinErrorNoPinStored(): String = getString(Res.string.pin_error_no_pin) - - /** - * Returns the error message when PIN save fails. - */ - suspend fun getPinErrorSaveFailed(): String = getString(Res.string.pin_error_save_failed) - - /** - * Returns the error message when PIN reset fails. - */ - suspend fun getPinErrorResetFailed(): String = getString(Res.string.pin_error_reset_failed) +interface PinResourceProvider { + suspend fun getPinTitle(mode: PinMode): String + + suspend fun getPinSubtitle(mode: PinMode): String + + suspend fun getPrimaryButtonText(mode: PinMode): String + + suspend fun getSecondaryButtonText(mode: PinMode): String? + + suspend fun getPinErrorIncorrect(): String + + suspend fun getPinErrorWithAttempts( + message: String, + attempts: Int, + ): String + + suspend fun getPinErrorNoPinStored(): String + + suspend fun getPinErrorSaveFailed(): String + + suspend fun getPinErrorResetFailed(): String +} + +/** + * Default implementation of [PinResourceProvider] using Compose Multiplatform string resources. + */ +class PinResourceProviderImpl : PinResourceProvider { + override suspend fun getPinTitle(mode: PinMode): String = + when (mode) { + PinMode.SET -> getString(Res.string.create_pin) + PinMode.ASK -> getString(Res.string.enter_pin) + } + + override suspend fun getPinSubtitle(mode: PinMode): String = + when (mode) { + PinMode.SET -> getString(Res.string.create_pin_description) + PinMode.ASK -> getString(Res.string.enter_pin_description) + } + + override suspend fun getPrimaryButtonText(mode: PinMode): String = + when (mode) { + PinMode.SET -> getString(Res.string.create_pin_button) + PinMode.ASK -> getString(Res.string.enter_pin_button) + } + + override suspend fun getSecondaryButtonText(mode: PinMode): String? = + when (mode) { + PinMode.SET -> null + PinMode.ASK -> getString(Res.string.forgot_pin_button) + } + + override suspend fun getPinErrorIncorrect(): String = getString(Res.string.pin_error_incorrect) + + override suspend fun getPinErrorWithAttempts( + message: String, + attempts: Int, + ): String = "$message. ${getString(Res.string.pin_error_remaining_attempts, attempts)}" + + override suspend fun getPinErrorNoPinStored(): String = getString(Res.string.pin_error_no_pin) + + override suspend fun getPinErrorSaveFailed(): String = getString(Res.string.pin_error_save_failed) + + override suspend fun getPinErrorResetFailed(): String = getString(Res.string.pin_error_reset_failed) } diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/state/PinUiState.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/state/PinUiState.kt new file mode 100644 index 00000000000..b35c311ab8a --- /dev/null +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/state/PinUiState.kt @@ -0,0 +1,31 @@ +package org.dhis2.mobile.login.pin.ui.state + +/** + * UI state for the PIN dialog content. + * Exposed by [org.dhis2.mobile.login.pin.ui.viewmodel.PinViewModel] and consumed by composables. + * + * @param title The main heading text. + * @param subtitle The descriptive text shown below the title. + * @param primaryButtonText Label for the primary action button. + * @param secondaryButtonText Optional label for the secondary action button. + * @param errorMessage Optional error message shown below the PIN input field. + * @param primaryButtonIsEnabled Whether the primary action button is enabled. + * @param isLoading Whether a PIN operation is in progress. + * @param isSuccess Whether the PIN operation completed successfully. + * @param isDismissed Whether the PIN dialog was dismissed (e.g. after forgot-PIN flow). + * @param isTooManyAttempts Whether the user exceeded the maximum number of PIN attempts. + * @param pinLength Number of PIN digits. + */ +data class PinUiState( + val title: String = "", + val subtitle: String = "", + val primaryButtonText: String = "", + val secondaryButtonText: String? = null, + val errorMessage: String? = null, + val primaryButtonIsEnabled: Boolean = false, + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val isDismissed: Boolean = false, + val isTooManyAttempts: Boolean = false, + val pinLength: Int = 4, +) diff --git a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModel.kt b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModel.kt index 6b848e02497..adc127c3872 100644 --- a/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModel.kt +++ b/login/src/commonMain/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModel.kt @@ -8,101 +8,131 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.dhis2.mobile.commons.domain.invoke import org.dhis2.mobile.login.pin.domain.model.PinError -import org.dhis2.mobile.login.pin.domain.model.PinState +import org.dhis2.mobile.login.pin.domain.model.PinMode import org.dhis2.mobile.login.pin.domain.model.ValidatePinInput import org.dhis2.mobile.login.pin.domain.usecase.ForgotPinUseCase import org.dhis2.mobile.login.pin.domain.usecase.SavePinUseCase import org.dhis2.mobile.login.pin.domain.usecase.ValidatePinUseCase -import org.dhis2.mobile.login.pin.ui.components.PinMode import org.dhis2.mobile.login.pin.ui.provider.PinResourceProvider +import org.dhis2.mobile.login.pin.ui.state.PinUiState /** * ViewModel for managing PIN operations. * Coordinates between the UI and use cases for PIN creation, validation, and recovery. + * + * @param mode The PIN mode (SET for creation, ASK for verification). */ class PinViewModel( + private val mode: PinMode, private val savePinUseCase: SavePinUseCase, private val validatePinUseCase: ValidatePinUseCase, private val forgotPinUseCase: ForgotPinUseCase, private val resourceProvider: PinResourceProvider, ) : ViewModel() { - private val _uiState = MutableStateFlow(PinState.Idle) - val uiState: StateFlow = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(PinUiState()) + val uiState: StateFlow = _uiState.asStateFlow() private var pinAttempts = 0 + private var currentPin = "" + + init { + viewModelScope.launch { + _uiState.value = + _uiState.value.copy( + title = resourceProvider.getPinTitle(mode), + subtitle = resourceProvider.getPinSubtitle(mode), + primaryButtonText = resourceProvider.getPrimaryButtonText(mode), + secondaryButtonText = resourceProvider.getSecondaryButtonText(mode), + ) + } + } + + /** + * Updates the current PIN value and recomputes button enablement. + * Clears any existing error message when the user starts typing. + * + * @param pin The current PIN value from the input field. + */ + fun onPinChanged(pin: String) { + currentPin = pin + _uiState.value = + _uiState.value.copy( + errorMessage = null, + primaryButtonIsEnabled = pin.replace("-", "").length == _uiState.value.pinLength, + ) + } /** * Handles PIN completion based on the current mode. - * @param pin The PIN entered by the user. - * @param mode The current PIN mode (SET or ASK). */ - fun onPinComplete( - pin: String, - mode: PinMode, - ) { + fun onPinComplete() { + val pin = currentPin.replace("-", "") when (mode) { PinMode.SET -> savePin(pin) PinMode.ASK -> validatePin(pin) } } - /** - * Saves a new PIN. - * @param pin The PIN to save. - */ private fun savePin(pin: String) { viewModelScope.launch { - _uiState.value = PinState.Loading + _uiState.value = _uiState.value.copy(isLoading = true, primaryButtonIsEnabled = false) savePinUseCase(pin) .onSuccess { - _uiState.value = PinState.Success + _uiState.value = _uiState.value.copy(isLoading = false, isSuccess = true) }.onFailure { error -> _uiState.value = - PinState.Error( - message = error.message ?: resourceProvider.getPinErrorSaveFailed(), + _uiState.value.copy( + isLoading = false, + errorMessage = error.message ?: resourceProvider.getPinErrorSaveFailed(), + primaryButtonIsEnabled = currentPin.replace("-", "").length == _uiState.value.pinLength, ) } } } - /** - * Validates the entered PIN against the stored PIN. - * @param pin The PIN to validate. - */ private fun validatePin(pin: String) { viewModelScope.launch { - _uiState.value = PinState.Loading + _uiState.value = _uiState.value.copy(isLoading = true, primaryButtonIsEnabled = false) validatePinUseCase(ValidatePinInput(pin, pinAttempts)).fold( onSuccess = { - _uiState.value = PinState.Success + _uiState.value = _uiState.value.copy(isLoading = false, isSuccess = true) pinAttempts = 0 }, - onFailure = { failure: Throwable -> + onFailure = { failure -> when (failure) { is PinError.Failed -> { pinAttempts++ + val message = resourceProvider.getPinErrorIncorrect() + val fullMessage = + resourceProvider.getPinErrorWithAttempts(message, failure.attemptsLeft) _uiState.value = - PinState.Error( - message = resourceProvider.getPinErrorIncorrect(), - remainingAttempts = failure.attemptsLeft, + _uiState.value.copy( + isLoading = false, + errorMessage = fullMessage, + primaryButtonIsEnabled = currentPin.replace("-", "").length == _uiState.value.pinLength, ) } is PinError.TooManyAttempts -> { - _uiState.value = PinState.TooManyAttempts + _uiState.value = + _uiState.value.copy(isLoading = false, isTooManyAttempts = true) } is PinError.NoPinStored -> { _uiState.value = - PinState.Error( - message = resourceProvider.getPinErrorNoPinStored(), + _uiState.value.copy( + isLoading = false, + errorMessage = resourceProvider.getPinErrorNoPinStored(), + primaryButtonIsEnabled = currentPin.replace("-", "").length == _uiState.value.pinLength, ) } else -> { _uiState.value = - PinState.Error( - message = failure.message ?: "Unknown error occurred", + _uiState.value.copy( + isLoading = false, + errorMessage = failure.message ?: "Unknown error occurred", + primaryButtonIsEnabled = currentPin.replace("-", "").length == _uiState.value.pinLength, ) } } @@ -116,26 +146,40 @@ class PinViewModel( * This will log out the user and clear the PIN. */ fun onForgotPin() { + if (_uiState.value.isLoading) return viewModelScope.launch { - _uiState.value = PinState.Loading + _uiState.value = _uiState.value.copy(isLoading = true, primaryButtonIsEnabled = false) forgotPinUseCase() .onSuccess { - _uiState.value = PinState.Dismissed + _uiState.value = + _uiState.value.copy( + isLoading = false, + isDismissed = true, + isTooManyAttempts = false, + ) }.onFailure { error -> _uiState.value = - PinState.Error( - message = error.message ?: resourceProvider.getPinErrorResetFailed(), + _uiState.value.copy( + isLoading = false, + errorMessage = error.message ?: resourceProvider.getPinErrorResetFailed(), + primaryButtonIsEnabled = currentPin.replace("-", "").length == _uiState.value.pinLength, ) } } } /** - * Resets the UI state to idle. - * Useful for clearing error messages. + * Resets transient UI state flags (error, success, dismissed, tooManyAttempts). + * Useful when the dialog is dismissed or re-shown. */ fun resetState() { - _uiState.value = PinState.Idle + _uiState.value = + _uiState.value.copy( + errorMessage = null, + isSuccess = false, + isDismissed = false, + isTooManyAttempts = false, + ) } /** diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/LoginScreenIntegrationTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/LoginScreenIntegrationTest.kt index 207a88957b0..0093f82cd53 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/LoginScreenIntegrationTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/LoginScreenIntegrationTest.kt @@ -2,7 +2,6 @@ package org.dhis2.mobile.login.main import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -15,8 +14,8 @@ import org.dhis2.mobile.login.main.data.LoginRepository import org.dhis2.mobile.login.main.domain.model.LoginScreenState import org.dhis2.mobile.login.main.domain.usecase.GetInitialScreen import org.dhis2.mobile.login.main.domain.usecase.ImportDatabase +import org.dhis2.mobile.login.main.domain.usecase.ProcessDeviceEnrollment import org.dhis2.mobile.login.main.domain.usecase.ValidateServer -import org.dhis2.mobile.login.main.ui.navigation.AppLinkNavigation import org.dhis2.mobile.login.main.ui.navigation.Navigator import org.dhis2.mobile.login.main.ui.viewmodel.LoginViewModel import org.dhis2.mobile.login.pin.data.SessionRepository @@ -35,30 +34,24 @@ class LoginScreenIntegrationTest { private val navigator: Navigator = mock() private val accountRepository: AccountRepository = mock() private val sessionRepository: SessionRepository = mock() - private val loginRepository: LoginRepository = mock() - private val appLinkNavigation: AppLinkNavigation = mock() private val networkStatusProvider: NetworkStatusProvider = mock() - private val testDispatcher = UnconfinedTestDispatcher() - private val mockAppLinkFlow = MutableSharedFlow() private val mockNetworkStatusFlow = MutableStateFlow(true) - private lateinit var getInitialScreen: GetInitialScreen - private lateinit var importDatabase: ImportDatabase - private lateinit var validateServer: ValidateServer + private lateinit var processDeviceEnrollment: ProcessDeviceEnrollment @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) - whenever(appLinkNavigation.appLink).thenReturn(mockAppLinkFlow) whenever(networkStatusProvider.connectionStatus).thenReturn(mockNetworkStatusFlow) importDatabase = ImportDatabase(repository = loginRepository) validateServer = ValidateServer(repository = loginRepository) getInitialScreen = GetInitialScreen(accountRepository, sessionRepository) + processDeviceEnrollment = ProcessDeviceEnrollment(repository = loginRepository) } @AfterTest @@ -70,6 +63,16 @@ class LoginScreenIntegrationTest { * * Test case: ANDROAPP-7220 * Scenario: Manage accounts screen + * Given the user is logged out + * And has accounts stored + * When opens the app + * Then goes to + * + * Examples: + * |number_of_accounts |destination_screen | + * | none |server configuration screen| + * | one |existing account screen | + * | two or more |manage accounts screen | * */ @@ -117,12 +120,13 @@ class LoginScreenIntegrationTest { // Then goes to existing account screen (LegacyLogin) verify(navigator).navigate( eq( - LoginScreenState.LegacyLogin( + LoginScreenState.LoginCredentials( selectedServer = singleAccount.serverUrl, selectedUsername = singleAccount.name, serverName = singleAccount.serverName, selectedServerFlag = singleAccount.serverFlag, allowRecovery = singleAccount.allowRecovery, + oAuthEnabled = false, ), ), any(), @@ -145,9 +149,18 @@ class LoginScreenIntegrationTest { // When opening the app initViewModel() - // Then goes to OAuth login screen + // Then goes to login screen with OAuth verify(navigator).navigate( - eq(LoginScreenState.OauthLogin(selectedServer = oauthAccount.serverUrl)), + eq( + LoginScreenState.LoginCredentials( + selectedServer = oauthAccount.serverUrl, + selectedUsername = oauthAccount.name, + serverName = oauthAccount.serverName, + selectedServerFlag = oauthAccount.serverFlag, + allowRecovery = false, + oAuthEnabled = true, + ), + ), any(), ) } @@ -213,7 +226,6 @@ class LoginScreenIntegrationTest { getInitialScreen = getInitialScreen, importDatabase = importDatabase, validateServer = validateServer, - appLinkNavigation = appLinkNavigation, networkStatusProvider = networkStatusProvider, ) } diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUserTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUserTest.kt index 5c23f58cb05..f4c67f554a4 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUserTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/domain/usecase/LoginUserTest.kt @@ -5,6 +5,7 @@ import org.dhis2.mobile.login.main.data.LoginRepository import org.dhis2.mobile.login.main.domain.model.LoginResult import org.junit.Before import org.junit.Test +import org.mockito.Mockito.reset import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -335,4 +336,29 @@ class LoginUserTest { assertIs(result) assertEquals(false, result.initialSyncDone) } + + @Test + fun `GIVEN username with surrounding whitespace WHEN user logs in THEN trimmed username is passed to repository`() = + runTest { + // GIVEN - Usernames with various whitespace patterns and their expected trimmed values + val usernameVariants = listOf(" testUser" to "testUser", "testUser " to "testUser", " testUser " to "testUser", " " to "") + + for ((input, trimmed) in usernameVariants) { + reset(repository) + whenever(repository.loginUser(serverUrl, trimmed, password, isNetworkAvailable)) doReturn + Result.success(Unit) + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, trimmed)) doReturn true + + // WHEN - User logs in with whitespace in username + val result = loginUser(serverUrl, input, password, isNetworkAvailable) + + // THEN - Repository receives the trimmed username + assertIs(result) + verify(repository).loginUser(serverUrl, trimmed, password, isNetworkAvailable) + verify(repository).updateAvailableUsers(trimmed) + verify(repository).initialSyncDone(serverUrl, trimmed) + } + } } diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/domain/usecase/OpenIdLoginTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/domain/usecase/OpenIdLoginTest.kt new file mode 100644 index 00000000000..5fcaae840af --- /dev/null +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/domain/usecase/OpenIdLoginTest.kt @@ -0,0 +1,324 @@ +package org.dhis2.mobile.login.main.domain.usecase + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.login.main.data.LoginRepository +import org.dhis2.mobile.login.main.domain.model.LoginResult +import org.dhis2.mobile.login.main.domain.model.OpenIdLoginConfiguration +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class OpenIdLoginTest { + private val repository: LoginRepository = mock() + private lateinit var openIdLogin: OpenIdLogin + + private val serverUrl = "https://test.server.org" + private val isNetworkAvailable = true + private val clientId = "test-client-id" + private val redirectUri = "dhis2://oauth" + private val discoveryUri = "https://test.server.org/.well-known/openid-configuration" + private val authorizationUri = "https://test.server.org/oauth/authorize" + private val tokenUrl = "https://test.server.org/oauth/token" + private val username = "openIdUser" + private val prompt = "select_account" + + private val defaultConfig + get() = + OpenIdLoginConfiguration( + serverUrl = serverUrl, + isNetworkAvailable = isNetworkAvailable, + clientId = clientId, + redirectUri = redirectUri, + discoveryUri = discoveryUri, + authorizationUri = authorizationUri, + tokenUrl = tokenUrl, + prompt = prompt, + ) + + @Before + fun setUp() { + openIdLogin = OpenIdLogin(repository) + } + + @Test + fun `GIVEN successful OpenID login with no existing accounts WHEN user logs in THEN biometric credentials are NOT deleted`() = + runTest { + // GIVEN - User has no other accounts (numberOfAccounts = 0) + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN - User logs in successfully with OpenID + val result = openIdLogin(defaultConfig) + + // THEN - Login is successful + assertIs(result) + verify(repository).unlockSession() + verify(repository).updateAvailableUsers(username) + verify(repository).updateServerUrls(serverUrl) + verify(repository).numberOfAccounts() + // Biometric credentials should NOT be deleted when numberOfAccounts < 1 + verify(repository, never()).deleteBiometricCredentials() + } + + @Test + fun `GIVEN successful OpenID login with multiple existing accounts WHEN user logs in THEN biometric credentials are deleted`() = + runTest { + // GIVEN - User has multiple existing accounts (numberOfAccounts = 3) + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 3 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN - User logs in successfully with OpenID + val result = openIdLogin(defaultConfig) + + // THEN - Login is successful and biometric credentials are deleted + assertIs(result) + verify(repository).unlockSession() + verify(repository).updateAvailableUsers(username) + verify(repository).updateServerUrls(serverUrl) + verify(repository).numberOfAccounts() + verify(repository).deleteBiometricCredentials() + } + + @Test + fun `GIVEN failed OpenID login WHEN user attempts to log in THEN biometric credentials are NOT deleted`() = + runTest { + // GIVEN - OpenID login will fail + val errorMessage = "OpenID authentication failed" + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.failure(Exception(errorMessage)) + + // WHEN - User attempts to log in with OpenID + val result = openIdLogin(defaultConfig) + + // THEN - Login fails and biometric credentials are NOT deleted + assertIs(result) + assertEquals(errorMessage, result.message) + verify(repository, never()).getUsername() + verify(repository, never()).unlockSession() + verify(repository, never()).updateAvailableUsers(any()) + verify(repository, never()).updateServerUrls(any()) + verify(repository, never()).numberOfAccounts() + verify(repository, never()).deleteBiometricCredentials() + } + + @Test + fun `GIVEN successful OpenID login WHEN repository operations are executed THEN they are called in correct order`() = + runTest { + // GIVEN + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 2 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN + openIdLogin(defaultConfig) + + // THEN - Verify the order of operations + val inOrder = org.mockito.kotlin.inOrder(repository) + inOrder.verify(repository).loginWithOpenId(defaultConfig) + inOrder.verify(repository).getUsername() + inOrder.verify(repository).unlockSession() + inOrder.verify(repository).updateAvailableUsers(username) + inOrder.verify(repository).updateServerUrls(serverUrl) + inOrder.verify(repository).numberOfAccounts() + inOrder.verify(repository).deleteBiometricCredentials() + inOrder.verify(repository).displayTrackingMessage() + inOrder.verify(repository).initialSyncDone(serverUrl, username) + } + + @Test + fun `GIVEN network is offline WHEN user logs in with OpenID THEN login is attempted with offline flag`() = + runTest { + // GIVEN - Network is unavailable + val isOffline = false + val offlineConfig = defaultConfig.copy(isNetworkAvailable = isOffline) + whenever(repository.loginWithOpenId(offlineConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN - User attempts to log in offline + val result = openIdLogin(offlineConfig) + + // THEN - Login is successful with offline flag + assertIs(result) + verify(repository).loginWithOpenId(offlineConfig) + } + + @Test + fun `GIVEN all success flags are true WHEN OpenID login succeeds THEN all flags are reflected in result`() = + runTest { + // GIVEN - Both tracking message and initial sync are required + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn true + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN - User logs in successfully + val result = openIdLogin(defaultConfig) + + // THEN - Both flags are true + assertIs(result) + assertEquals(true, result.displayTrackingMessage) + assertEquals(true, result.initialSyncDone) + } + + @Test + fun `GIVEN all success flags are false WHEN OpenID login succeeds THEN all flags are false in result`() = + runTest { + // GIVEN - Neither tracking message nor initial sync are required + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn false + + // WHEN - User logs in successfully + val result = openIdLogin(defaultConfig) + + // THEN - Both flags are false + assertIs(result) + assertEquals(false, result.displayTrackingMessage) + assertEquals(false, result.initialSyncDone) + } + + @Test + fun `GIVEN custom redirect URI scheme WHEN OpenID login succeeds THEN custom scheme is handled`() = + runTest { + // GIVEN - Custom app redirect URI + val customRedirectUri = "myapp://oauth/callback" + val customConfig = defaultConfig.copy(redirectUri = customRedirectUri) + whenever(repository.loginWithOpenId(customConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN - User logs in with custom redirect URI + val result = openIdLogin(customConfig) + + // THEN - Login succeeds with custom redirect URI + assertIs(result) + verify(repository).loginWithOpenId(customConfig) + } + + @Test + fun `GIVEN different server URL formats WHEN OpenID login succeeds THEN server URL is used correctly`() = + runTest { + // GIVEN - Server URL with path + val serverWithPath = "https://test.server.org/dhis" + val configWithPath = defaultConfig.copy(serverUrl = serverWithPath) + whenever(repository.loginWithOpenId(configWithPath)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverWithPath, username)) doReturn true + + // WHEN - User logs in with server URL containing path + val result = openIdLogin(configWithPath) + + // THEN - Server URL is passed correctly + assertIs(result) + verify(repository).updateServerUrls(serverWithPath) + verify(repository).initialSyncDone(serverWithPath, username) + } + + @Test + fun `GIVEN repository throws runtime exception WHEN OpenID login fails THEN exception message is returned`() = + runTest { + // GIVEN - Repository throws RuntimeException + val runtimeError = "OpenID provider unavailable" + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.failure(RuntimeException(runtimeError)) + + // WHEN - User attempts to log in + val result = openIdLogin(defaultConfig) + + // THEN - Error message is propagated + assertIs(result) + assertEquals(runtimeError, result.message) + } + + @Test + fun `GIVEN successful OpenID login WHEN unlockSession is called THEN it is always executed`() = + runTest { + // GIVEN - Successful OpenID login + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN - User logs in + openIdLogin(defaultConfig) + + // THEN - Session is always unlocked on success + verify(repository).unlockSession() + } + + @Test + fun `GIVEN failed OpenID login WHEN unlockSession would be called THEN it is never executed`() = + runTest { + // GIVEN - Failed OpenID login + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.failure(Exception("OpenID failed")) + + // WHEN - User attempts to log in + openIdLogin(defaultConfig) + + // THEN - Session is never unlocked on failure + verify(repository, never()).unlockSession() + } + + @Test + fun `GIVEN null prompt WHEN OpenID login succeeds THEN null prompt is passed to repository`() = + runTest { + // GIVEN - No prompt configured (SDK will use its default) + val configNullPrompt = defaultConfig.copy(prompt = null) + whenever(repository.loginWithOpenId(configNullPrompt)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn username + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, username)) doReturn true + + // WHEN - User logs in without a prompt override + val result = openIdLogin(configNullPrompt) + + // THEN - Login succeeds and null prompt is forwarded to the repository + assertIs(result) + verify(repository).loginWithOpenId(configNullPrompt) + } + + @Test + fun `GIVEN username with special characters WHEN OpenID login succeeds THEN username is handled correctly`() = + runTest { + // GIVEN - Username contains special characters + val specialUsername = "user@example.com" + whenever(repository.loginWithOpenId(defaultConfig)) doReturn Result.success(Unit) + whenever(repository.getUsername()) doReturn specialUsername + whenever(repository.numberOfAccounts()) doReturn 0 + whenever(repository.displayTrackingMessage()) doReturn false + whenever(repository.initialSyncDone(serverUrl, specialUsername)) doReturn true + + // WHEN - User logs in successfully + val result = openIdLogin(defaultConfig) + + // THEN - Special username is used correctly + assertIs(result) + verify(repository).updateAvailableUsers(specialUsername) + verify(repository).initialSyncDone(serverUrl, specialUsername) + } +} diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModelTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModelTest.kt index 5952605f521..c3bb5547f57 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModelTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/CredentialsViewModelTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import coil3.PlatformContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain @@ -13,29 +14,36 @@ import org.dhis2.mobile.commons.network.NetworkStatusProvider import org.dhis2.mobile.login.main.domain.model.BiometricsInfo import org.dhis2.mobile.login.main.domain.model.LoginResult import org.dhis2.mobile.login.main.domain.model.LoginScreenState +import org.dhis2.mobile.login.main.domain.model.OpenIdLoginConfiguration import org.dhis2.mobile.login.main.domain.usecase.BiometricLogin import org.dhis2.mobile.login.main.domain.usecase.GetAvailableUsernames import org.dhis2.mobile.login.main.domain.usecase.GetBiometricInfo +import org.dhis2.mobile.login.main.domain.usecase.GetDeviceEnrollmentUrl import org.dhis2.mobile.login.main.domain.usecase.GetHasOtherAccounts import org.dhis2.mobile.login.main.domain.usecase.LogOutUser import org.dhis2.mobile.login.main.domain.usecase.LoginUser +import org.dhis2.mobile.login.main.domain.usecase.LoginUserWithOAuth import org.dhis2.mobile.login.main.domain.usecase.OpenIdLogin +import org.dhis2.mobile.login.main.domain.usecase.ProcessDeviceEnrollment import org.dhis2.mobile.login.main.domain.usecase.UpdateBiometricPermission import org.dhis2.mobile.login.main.domain.usecase.UpdateTrackingPermission +import org.dhis2.mobile.login.main.ui.navigation.AppLinkNavigation import org.dhis2.mobile.login.main.ui.navigation.Navigator import org.dhis2.mobile.login.main.ui.state.LoginState +import org.dhis2.mobile.login.main.ui.state.OidcInfo import org.dhis2.mobile.login.pin.domain.usecase.ForgotPinUseCase import org.dhis2.mobile.login.pin.domain.usecase.GetIsSessionLockedUseCase -import org.junit.After -import org.junit.Before -import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq 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 +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -53,8 +61,12 @@ class CredentialsViewModelTest { private val loginOutUser: LogOutUser = mock() private val biometricLogin: BiometricLogin = mock() private val openIdLogin: OpenIdLogin = mock() + private val loginUserWithOAuth: LoginUserWithOAuth = mock() + private val getDeviceEnrollmentUrl: GetDeviceEnrollmentUrl = mock() + private val processDeviceEnrollment: ProcessDeviceEnrollment = mock() private val updateTrackingPermission: UpdateTrackingPermission = mock() private val updateBiometricPermission: UpdateBiometricPermission = mock() + private val appLinkNavigation: AppLinkNavigation = mock() private val networkStatusProvider: NetworkStatusProvider = mock() private val getIsSessionLockedUseCase: GetIsSessionLockedUseCase = mock() private val forgotPinUseCase: ForgotPinUseCase = mock() @@ -63,13 +75,14 @@ class CredentialsViewModelTest { private val turbineTimeout = 10.seconds - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) whenever(networkStatusProvider.connectionStatus) doReturn flowOf(true) + whenever(appLinkNavigation.appLink) doReturn MutableSharedFlow() } - @After + @AfterTest fun tearDown() { Dispatchers.resetMain() } @@ -532,11 +545,325 @@ class CredentialsViewModelTest { } } + @Test + fun `GIVEN app link with authorization code WHEN link arrives THEN OAuth login is triggered`() = + runTest { + // GIVEN + val serverUrl = "https://test.server.org" + val authCode = "auth_code_123" + val appLinkUrl = "https://vgarciabnz.github.io?code=$authCode&state=test" + val mockAppLinkFlow = MutableSharedFlow() + val enrollmentUrl = "https://test.server.org/oauth2/enrollment" + + whenever(getAvailableUsernames()) doReturn emptyList() + whenever(getBiometricInfo(any())) doReturn BiometricsInfo(false, false) + whenever(getHasOtherAccounts.invoke()) doReturn false + whenever(getIsSessionLockedUseCase()) doReturn false + whenever(appLinkNavigation.appLink) doReturn mockAppLinkFlow + whenever(getDeviceEnrollmentUrl(any())) doReturn Result.success(enrollmentUrl) + whenever( + loginUserWithOAuth.invoke(any(), any()), + ) doReturn LoginResult.Success(initialSyncDone = true, displayTrackingMessage = false) + + // Use oAuthEnable=true and fromHome=true to avoid auto-launch but enable OAuth flow + initViewModel(serverUrl = serverUrl, username = "testuser", oAuthEnable = true, fromHome = true) + + viewModel.credentialsScreenState.test(timeout = turbineTimeout) { + awaitItem() + awaitItem() + + // Trigger OAuth flow by clicking login + viewModel.onLoginClicked() + testDispatcher.scheduler.advanceUntilIdle() + + // Login state should be running after clicking login + val runningState = awaitItem() + assertEquals(LoginState.Running, runningState.loginState) + + // WHEN - Send app link with authorization code (simulates OAuth callback) + mockAppLinkFlow.emit(appLinkUrl) + testDispatcher.scheduler.advanceUntilIdle() + + // Advance time for login to complete + testDispatcher.scheduler.advanceTimeBy(4.seconds) + + // Login should succeed and show after login actions + val finalState = awaitItem() + assertEquals(LoginState.Enabled, finalState.loginState) + assertTrue(finalState.afterLoginActions.isNotEmpty()) + + // Verify OAuth login was called with the correct code + verify(loginUserWithOAuth).invoke( + serverUrl = serverUrl, + code = authCode, + ) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `GIVEN app link with IAT token WHEN link arrives THEN device enrollment is processed`() = + runTest { + // GIVEN + val serverUrl = "https://test.server.org" + val iat = "enrollment_iat_token" + val consentUrl = "https://test.server.org/oauth2/consent" + val enrollmentUrl = "https://test.server.org/oauth2/enrollment" + val appLinkUrl = "https://vgarciabnz.github.io?iat=$iat&state=test" + val mockAppLinkFlow = MutableSharedFlow() + + whenever(getAvailableUsernames()) doReturn emptyList() + whenever(getBiometricInfo(any())) doReturn BiometricsInfo(false, false) + whenever(getHasOtherAccounts.invoke()) doReturn false + whenever(getIsSessionLockedUseCase()) doReturn false + whenever(appLinkNavigation.appLink) doReturn mockAppLinkFlow + whenever(getDeviceEnrollmentUrl(any())) doReturn Result.success(enrollmentUrl) + whenever( + processDeviceEnrollment.invoke(any()), + ) doReturn Result.success(consentUrl) + + // Use oAuthEnable=true and fromHome=true to avoid auto-launch but enable OAuth flow + initViewModel(serverUrl = serverUrl, username = "testuser", oAuthEnable = true, fromHome = true) + + viewModel.credentialsScreenState.test(timeout = turbineTimeout) { + awaitItem() + awaitItem() + + // Trigger OAuth flow by clicking login + viewModel.onLoginClicked() + testDispatcher.scheduler.advanceUntilIdle() + + // Login state should be running after clicking login + val runningState = awaitItem() + assertEquals(LoginState.Running, runningState.loginState) + + // WHEN - Send app link with IAT token (simulates enrollment callback) + mockAppLinkFlow.emit(appLinkUrl) + testDispatcher.scheduler.advanceUntilIdle() + + // Verify device enrollment was called with the correct IAT + verify(processDeviceEnrollment).invoke(any()) + + // Verify navigation happened twice: once for enrollment URL, once for consent URL + verify(navigator, times(2)).navigate(any(), any()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `GIVEN app link with error WHEN link arrives THEN error message is displayed`() = + runTest { + // GIVEN + val serverUrl = "https://test.server.org" + val appLinkUrl = "https://vgarciabnz.github.io?error=access_denied&state=test" + val mockAppLinkFlow = MutableSharedFlow() + val enrollmentUrl = "https://test.server.org/oauth2/enrollment" + + whenever(getAvailableUsernames()) doReturn emptyList() + whenever(getBiometricInfo(any())) doReturn BiometricsInfo(false, false) + whenever(getHasOtherAccounts.invoke()) doReturn false + whenever(getIsSessionLockedUseCase()) doReturn false + whenever(appLinkNavigation.appLink) doReturn mockAppLinkFlow + whenever(getDeviceEnrollmentUrl(any())) doReturn Result.success(enrollmentUrl) + + // Use oAuthEnable=true and fromHome=true to avoid auto-launch but enable OAuth flow + initViewModel(serverUrl = serverUrl, username = "testuser", oAuthEnable = true, fromHome = true) + + viewModel.credentialsScreenState.test(timeout = turbineTimeout) { + awaitItem() + awaitItem() + + // Trigger OAuth flow by clicking login + viewModel.onLoginClicked() + testDispatcher.scheduler.advanceUntilIdle() + + // Login state should be running after clicking login + val runningState = awaitItem() + assertEquals(LoginState.Running, runningState.loginState) + + // WHEN - Send app link with error (simulates OAuth error callback) + mockAppLinkFlow.emit(appLinkUrl) + testDispatcher.scheduler.advanceUntilIdle() + + // THEN - Error message should be shown + val errorState = awaitItem() + assertEquals("access_denied", errorState.errorMessage) + assertEquals(LoginState.Enabled, errorState.loginState) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `GIVEN oidcInfo is Discovery with prompt WHEN onOpenIdLogin is called THEN discoveryUri and prompt are forwarded`() = + runTest { + val serverUrl = "https://test.server.org" + val prompt = "select_account" + val discoveryUri = "https://test.server.org/.well-known/openid-configuration" + val clientId = "client-123" + val redirectUri = "dhis2://oauth" + val oidcInfo = + OidcInfo.Discovery( + server = serverUrl, + loginButtonText = null, + clientId = clientId, + redirectUri = redirectUri, + discoveryUri = discoveryUri, + prompt = prompt, + ) + + whenever(getAvailableUsernames()) doReturn emptyList() + whenever(getBiometricInfo(any())) doReturn BiometricsInfo(false, false) + whenever(getHasOtherAccounts.invoke()) doReturn false + whenever(getIsSessionLockedUseCase()) doReturn false + whenever(openIdLogin.invoke(any())) doReturn LoginResult.Success(initialSyncDone = true, displayTrackingMessage = false) + + initViewModel(serverUrl = serverUrl, oidcInfo = oidcInfo) + + viewModel.credentialsScreenState.test(timeout = turbineTimeout) { + awaitItem() + awaitItem() + + viewModel.onOpenIdLogin() + + val runningState = awaitItem() + assertEquals(LoginState.Running, runningState.loginState) + + testDispatcher.scheduler.advanceUntilIdle() + + verify(openIdLogin).invoke( + OpenIdLoginConfiguration( + serverUrl = serverUrl, + isNetworkAvailable = true, + clientId = clientId, + redirectUri = redirectUri, + discoveryUri = discoveryUri, + authorizationUri = null, + tokenUrl = null, + prompt = prompt, + ), + ) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `GIVEN oidcInfo is Token with prompt WHEN onOpenIdLogin is called THEN tokenUrl, authorizationUri and prompt are forwarded`() = + runTest { + val serverUrl = "https://test.server.org" + val prompt = "login" + val authorizationUrl = "https://test.server.org/oauth/authorize" + val tokenUrl = "https://test.server.org/oauth/token" + val clientId = "client-456" + val redirectUri = "dhis2://oauth" + val oidcInfo = + OidcInfo.Token( + server = serverUrl, + loginLabel = null, + clientId = clientId, + redirectUri = redirectUri, + authorizationUrl = authorizationUrl, + tokenUrl = tokenUrl, + prompt = prompt, + ) + + whenever(getAvailableUsernames()) doReturn emptyList() + whenever(getBiometricInfo(any())) doReturn BiometricsInfo(false, false) + whenever(getHasOtherAccounts.invoke()) doReturn false + whenever(getIsSessionLockedUseCase()) doReturn false + whenever(openIdLogin.invoke(any())) doReturn LoginResult.Success(initialSyncDone = true, displayTrackingMessage = false) + + initViewModel(serverUrl = serverUrl, oidcInfo = oidcInfo) + + viewModel.credentialsScreenState.test(timeout = turbineTimeout) { + awaitItem() + awaitItem() + + viewModel.onOpenIdLogin() + + val runningState = awaitItem() + assertEquals(LoginState.Running, runningState.loginState) + + testDispatcher.scheduler.advanceUntilIdle() + + verify(openIdLogin).invoke( + OpenIdLoginConfiguration( + serverUrl = serverUrl, + isNetworkAvailable = true, + clientId = clientId, + redirectUri = redirectUri, + discoveryUri = null, + authorizationUri = authorizationUrl, + tokenUrl = tokenUrl, + prompt = prompt, + ), + ) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `GIVEN oidcInfo with null prompt WHEN onOpenIdLogin is called THEN null prompt is forwarded`() = + runTest { + val serverUrl = "https://test.server.org" + val oidcInfo = + OidcInfo.Discovery( + server = serverUrl, + loginButtonText = null, + clientId = "client-789", + redirectUri = "dhis2://oauth", + discoveryUri = "https://test.server.org/.well-known/openid-configuration", + prompt = null, + ) + + whenever(getAvailableUsernames()) doReturn emptyList() + whenever(getBiometricInfo(any())) doReturn BiometricsInfo(false, false) + whenever(getHasOtherAccounts.invoke()) doReturn false + whenever(getIsSessionLockedUseCase()) doReturn false + whenever(openIdLogin.invoke(any())) doReturn LoginResult.Success(initialSyncDone = true, displayTrackingMessage = false) + + initViewModel(serverUrl = serverUrl, oidcInfo = oidcInfo) + + viewModel.credentialsScreenState.test(timeout = turbineTimeout) { + awaitItem() + awaitItem() + + viewModel.onOpenIdLogin() + + val runningState = awaitItem() + assertEquals(LoginState.Running, runningState.loginState) + + testDispatcher.scheduler.advanceUntilIdle() + + verify(openIdLogin).invoke( + OpenIdLoginConfiguration( + serverUrl = serverUrl, + isNetworkAvailable = true, + clientId = "client-789", + redirectUri = "dhis2://oauth", + discoveryUri = "https://test.server.org/.well-known/openid-configuration", + authorizationUri = null, + tokenUrl = null, + prompt = null, + ), + ) + + cancelAndIgnoreRemainingEvents() + } + } + private fun initViewModel( serverName: String? = "Test Server", serverUrl: String = "https://test.server.org", username: String? = null, allowRecovery: Boolean = true, + oAuthEnable: Boolean = false, + fromHome: Boolean = false, + oidcInfo: OidcInfo? = null, ) { viewModel = CredentialsViewModel( @@ -548,8 +875,12 @@ class CredentialsViewModelTest { loginOutUser, biometricLogin, openIdLogin, + loginUserWithOAuth, + getDeviceEnrollmentUrl, + processDeviceEnrollment, updateTrackingPermission, updateBiometricPermission, + appLinkNavigation, networkStatusProvider, serverName, serverUrl, @@ -557,8 +888,9 @@ class CredentialsViewModelTest { allowRecovery, getIsSessionLockedUseCase, forgotPinUseCase, - oidcInfo = null, - false, + oidcInfo = oidcInfo, + fromHome = fromHome, + oAuthEnable = oAuthEnable, ) } } diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModelTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModelTest.kt index 8a7c39020c7..1e7d20baacb 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModelTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/main/ui/viewmodel/LoginViewModelTest.kt @@ -3,7 +3,6 @@ package org.dhis2.mobile.login.main.ui.viewmodel import app.cash.turbine.test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -14,16 +13,15 @@ import org.dhis2.mobile.login.main.domain.model.ServerValidationResult import org.dhis2.mobile.login.main.domain.usecase.GetInitialScreen import org.dhis2.mobile.login.main.domain.usecase.ImportDatabase import org.dhis2.mobile.login.main.domain.usecase.ValidateServer -import org.dhis2.mobile.login.main.ui.navigation.AppLinkNavigation import org.dhis2.mobile.login.main.ui.navigation.Navigator import org.dhis2.mobile.login.main.ui.state.DatabaseImportState import org.dhis2.mobile.login.main.ui.state.ServerValidationUiState -import org.junit.Before import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -36,16 +34,13 @@ class LoginViewModelTest { private val getInitialScreen: GetInitialScreen = mock() private val importDatabase: ImportDatabase = mock() private val validateServer: ValidateServer = mock() - private val appLinkNavigation: AppLinkNavigation = mock() private val testDispatcher = UnconfinedTestDispatcher() - private val mockAppLinkFlow = MutableSharedFlow() private val networkStatusProvider: NetworkStatusProvider = mock() private val mockNetworkStatusFlow = MutableStateFlow(true) - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) - whenever(appLinkNavigation.appLink).thenReturn(mockAppLinkFlow) whenever(networkStatusProvider.connectionStatus).thenReturn(mockNetworkStatusFlow) } @@ -67,7 +62,6 @@ class LoginViewModelTest { getInitialScreen = getInitialScreen, importDatabase = importDatabase, validateServer = validateServer, - appLinkNavigation = appLinkNavigation, networkStatusProvider = networkStatusProvider, ) @@ -102,7 +96,6 @@ class LoginViewModelTest { getInitialScreen = getInitialScreen, importDatabase = importDatabase, validateServer = validateServer, - appLinkNavigation = appLinkNavigation, networkStatusProvider = networkStatusProvider, ) @@ -146,7 +139,6 @@ class LoginViewModelTest { getInitialScreen = getInitialScreen, importDatabase = importDatabase, validateServer = validateServer, - appLinkNavigation = appLinkNavigation, networkStatusProvider = networkStatusProvider, ) @@ -167,44 +159,6 @@ class LoginViewModelTest { } } - @Test - fun `app link with valid code is handled correctly`() = - runTest { - val redirectUri = "https://vgarciabnz.github.io" - val code = "auth_code_123" - val appLinkUrl = "$redirectUri?code=$code&state=test" - - whenever(getInitialScreen()).thenReturn( - LoginScreenState.ServerValidation( - currentServer = "https://test.dhis2.org", - availableServers = listOf("https://test.dhis2.org"), - hasAccounts = false, - ), - ) - - viewModel = - LoginViewModel( - navigator = navigator, - getInitialScreen = getInitialScreen, - importDatabase = importDatabase, - validateServer = validateServer, - appLinkNavigation = appLinkNavigation, - networkStatusProvider = networkStatusProvider, - ) - - viewModel.serverValidationState.test { - assertEquals(ServerValidationUiState(), awaitItem()) - - // Send app link - mockAppLinkFlow.emit(appLinkUrl) - - // TODO: Verify that the code is processed correctly - // Currently the LoginViewModel has a TODO comment for this functionality - - expectNoEvents() - } - } - @Test fun `successfully import database`() = runTest { @@ -218,7 +172,6 @@ class LoginViewModelTest { getInitialScreen = getInitialScreen, importDatabase = importDatabase, validateServer = validateServer, - appLinkNavigation = appLinkNavigation, networkStatusProvider = networkStatusProvider, ) @@ -245,7 +198,6 @@ class LoginViewModelTest { getInitialScreen = getInitialScreen, importDatabase = importDatabase, validateServer = validateServer, - appLinkNavigation = appLinkNavigation, networkStatusProvider = networkStatusProvider, ) diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ForgotPinUseCaseTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ForgotPinUseCaseTest.kt index 27c27f93ed1..d631a0d9c0a 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ForgotPinUseCaseTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ForgotPinUseCaseTest.kt @@ -2,10 +2,10 @@ package org.dhis2.mobile.login.pin.domain.usecase import kotlinx.coroutines.test.runTest import org.dhis2.mobile.login.pin.data.SessionRepository -import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertTrue @@ -13,7 +13,7 @@ class ForgotPinUseCaseTest { private lateinit var useCase: ForgotPinUseCase private val repository: SessionRepository = mock() - @Before + @BeforeTest fun setUp() { useCase = ForgotPinUseCase(repository) } diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/GetInitialScreenUseCaseTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/GetInitialScreenUseCaseTest.kt index 953ed6bd72a..9acf4a84562 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/GetInitialScreenUseCaseTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/GetInitialScreenUseCaseTest.kt @@ -6,12 +6,12 @@ import org.dhis2.mobile.login.accounts.domain.model.AccountModel import org.dhis2.mobile.login.main.domain.model.LoginScreenState import org.dhis2.mobile.login.main.domain.usecase.GetInitialScreen import org.dhis2.mobile.login.pin.data.SessionRepository -import org.junit.Assert.assertEquals -import org.junit.Before import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue @@ -20,7 +20,7 @@ class GetInitialScreenUseCaseTest { private val accountRepository: AccountRepository = mock() private val sessionRepository: SessionRepository = mock() - @Before + @BeforeTest fun setUp() { useCase = GetInitialScreen(accountRepository, sessionRepository) } @@ -50,11 +50,11 @@ class GetInitialScreenUseCaseTest { val result = useCase() // Then - assertTrue(result is LoginScreenState.LegacyLogin) + assertTrue(result is LoginScreenState.LoginCredentials) } @Test - fun `go to oauth if there is 1 logged account with oauth`() = + fun `go to credentials screen with logout if there is 1 logged account with oauth`() = runTest { // Given whenever(accountRepository.getLoggedInAccounts()) doReturn @@ -64,7 +64,8 @@ class GetInitialScreenUseCaseTest { val result = useCase() // Then - assertTrue(result is LoginScreenState.OauthLogin) + assertTrue(result is LoginScreenState.LoginCredentials) + assertEquals(true, result.oAuthEnabled) // OAuth enabled, enrollment URL will be fetched by CredentialsViewModel } @Test @@ -83,7 +84,7 @@ class GetInitialScreenUseCaseTest { } @Test - fun `invoke returns OauthLogin when multiple accounts and session locked with OAuth account`() = + fun `invoke returns LegacyLogin when multiple accounts and session locked with OAuth account`() = runTest { // Given val activeAccount = @@ -105,8 +106,10 @@ class GetInitialScreenUseCaseTest { val result = useCase() // Then - assertIs(result) + assertIs(result) assertEquals("https://active.com", result.selectedServer) + assertEquals("active_user", result.selectedUsername) + assertEquals(true, result.oAuthEnabled) // OAuth enabled, enrollment URL will be fetched by CredentialsViewModel } @Test @@ -133,7 +136,7 @@ class GetInitialScreenUseCaseTest { val result = useCase() // Then - assertIs(result) + assertIs(result) assertEquals("https://active.com", result.selectedServer) assertEquals("active_user", result.selectedUsername) } @@ -182,7 +185,7 @@ class GetInitialScreenUseCaseTest { val result = useCase() // Then - assertIs(result) + assertIs(result) assertEquals("locked_user", result.selectedUsername) } diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/SavePinUseCaseTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/SavePinUseCaseTest.kt index ac82cc1f900..4fff47af996 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/SavePinUseCaseTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/SavePinUseCaseTest.kt @@ -2,10 +2,10 @@ package org.dhis2.mobile.login.pin.domain.usecase import kotlinx.coroutines.test.runTest import org.dhis2.mobile.login.pin.data.SessionRepository -import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertTrue @@ -13,7 +13,7 @@ class SavePinUseCaseTest { private lateinit var useCase: SavePinUseCase private val repository: SessionRepository = mock() - @Before + @BeforeTest fun setUp() { useCase = SavePinUseCase(repository) } diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ValidatePinUseCaseTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ValidatePinUseCaseTest.kt index d2cdf525284..e140b293109 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ValidatePinUseCaseTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/domain/usecase/ValidatePinUseCaseTest.kt @@ -4,9 +4,9 @@ import kotlinx.coroutines.test.runTest import org.dhis2.mobile.login.pin.data.SessionRepository import org.dhis2.mobile.login.pin.domain.model.PinError import org.dhis2.mobile.login.pin.domain.model.ValidatePinInput -import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -15,7 +15,7 @@ class ValidatePinUseCaseTest { private lateinit var useCase: ValidatePinUseCase private val repository: SessionRepository = mock() - @Before + @BeforeTest fun setUp() { useCase = ValidatePinUseCase(repository) } diff --git a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModelTest.kt b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModelTest.kt index ec708e71a05..1ef3fb35d45 100644 --- a/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModelTest.kt +++ b/login/src/commonTest/kotlin/org/dhis2/mobile/login/pin/ui/viewmodel/PinViewModelTest.kt @@ -1,25 +1,29 @@ package org.dhis2.mobile.login.pin.ui.viewmodel -import app.cash.turbine.test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.dhis2.mobile.login.pin.domain.model.PinError -import org.dhis2.mobile.login.pin.domain.model.PinState +import org.dhis2.mobile.login.pin.domain.model.PinMode import org.dhis2.mobile.login.pin.domain.model.ValidatePinInput import org.dhis2.mobile.login.pin.domain.usecase.ForgotPinUseCase import org.dhis2.mobile.login.pin.domain.usecase.SavePinUseCase import org.dhis2.mobile.login.pin.domain.usecase.ValidatePinUseCase -import org.dhis2.mobile.login.pin.ui.components.PinMode import org.dhis2.mobile.login.pin.ui.provider.PinResourceProvider -import org.junit.Before +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -31,283 +35,269 @@ class PinViewModelTest { private val resourceProvider: PinResourceProvider = mock() private val testDispatcher = StandardTestDispatcher() - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) + runBlocking { + whenever(resourceProvider.getPinTitle(any())).thenReturn("") + whenever(resourceProvider.getPinSubtitle(any())).thenReturn("") + whenever(resourceProvider.getPrimaryButtonText(any())).thenReturn("") + whenever(resourceProvider.getSecondaryButtonText(any())).thenReturn(null) + } + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() } @Test - fun `initial state is Idle`() = + fun `initial state has no error and no active flags`() = runTest { - // Given viewModel = createViewModel() - // Then - assertEquals(PinState.Idle, viewModel.uiState.value) + val state = viewModel.uiState.value + assertNull(state.errorMessage) + assertFalse(state.isLoading) + assertFalse(state.isSuccess) + assertFalse(state.isDismissed) + assertFalse(state.isTooManyAttempts) } @Test fun `onPinComplete with SET mode saves PIN successfully`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.SET) val pin = "1234" whenever(savePinUseCase(pin)).thenReturn(Result.success(Unit)) - // When - viewModel.onPinComplete(pin, PinMode.SET) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - assertTrue(viewModel.uiState.value is PinState.Success) + assertTrue(viewModel.uiState.value.isSuccess) } @Test fun `onPinComplete with SET mode shows error when save fails`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.SET) val pin = "1234" - val error = Exception("Save failed") - whenever(savePinUseCase(pin)).thenReturn(Result.failure(error)) + whenever(savePinUseCase(pin)).thenReturn(Result.failure(Exception("Save failed"))) - // When - viewModel.onPinComplete(pin, PinMode.SET) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - val state = viewModel.uiState.value - assertTrue(state is PinState.Error) - assertEquals("Save failed", state.message) + assertEquals("Save failed", viewModel.uiState.value.errorMessage) } @Test fun `onPinComplete with SET mode uses resource provider when error message is null`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.SET) val pin = "1234" - val error = Exception() - whenever(savePinUseCase(pin)).thenReturn(Result.failure(error)) + whenever(savePinUseCase(pin)).thenReturn(Result.failure(Exception())) whenever(resourceProvider.getPinErrorSaveFailed()).thenReturn("Failed to save PIN") - // When - viewModel.onPinComplete(pin, PinMode.SET) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - val state = viewModel.uiState.value - assertTrue(state is PinState.Error) - assertEquals("Failed to save PIN", state.message) + assertEquals("Failed to save PIN", viewModel.uiState.value.errorMessage) } @Test fun `onPinComplete with ASK mode validates PIN successfully`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.ASK) val pin = "1234" whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn(Result.success(Unit)) - // When - viewModel.onPinComplete(pin, PinMode.ASK) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - assertTrue(viewModel.uiState.value is PinState.Success) + assertTrue(viewModel.uiState.value.isSuccess) } @Test fun `onPinComplete with ASK mode shows error when PIN is incorrect`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.ASK) val pin = "0000" - whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn(Result.failure(PinError.Failed(attemptsLeft = 2))) + whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn( + Result.failure(PinError.Failed(attemptsLeft = 2)), + ) whenever(resourceProvider.getPinErrorIncorrect()).thenReturn("Incorrect PIN") + whenever(resourceProvider.getPinErrorWithAttempts("Incorrect PIN", 2)) + .thenReturn("Incorrect PIN. 2 attempts remaining") - // When - viewModel.onPinComplete(pin, PinMode.ASK) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - val state = viewModel.uiState.value - assertTrue(state is PinState.Error) - assertEquals("Incorrect PIN", state.message) - assertEquals(2, state.remainingAttempts) + assertEquals("Incorrect PIN. 2 attempts remaining", viewModel.uiState.value.errorMessage) } @Test fun `onPinComplete with ASK mode shows TooManyAttempts after 3 failed attempts`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.ASK) val pin = "0000" whenever(resourceProvider.getPinErrorIncorrect()).thenReturn("Incorrect PIN") - // First attempt - whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn(Result.failure(PinError.Failed(attemptsLeft = 2))) - viewModel.onPinComplete(pin, PinMode.ASK) + whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn( + Result.failure(PinError.Failed(attemptsLeft = 2)), + ) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Second attempt - whenever(validatePinUseCase(ValidatePinInput(pin, 1))).thenReturn(Result.failure(PinError.Failed(attemptsLeft = 1))) - viewModel.onPinComplete(pin, PinMode.ASK) + whenever(validatePinUseCase(ValidatePinInput(pin, 1))).thenReturn( + Result.failure(PinError.Failed(attemptsLeft = 1)), + ) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Third attempt - whenever(validatePinUseCase(ValidatePinInput(pin, 2))).thenReturn(Result.failure(PinError.TooManyAttempts)) - viewModel.onPinComplete(pin, PinMode.ASK) + whenever(validatePinUseCase(ValidatePinInput(pin, 2))).thenReturn( + Result.failure(PinError.TooManyAttempts), + ) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - assertTrue(viewModel.uiState.value is PinState.TooManyAttempts) + assertTrue(viewModel.uiState.value.isTooManyAttempts) } @Test fun `onPinComplete with ASK mode shows error when no PIN is stored`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.ASK) val pin = "1234" - whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn(Result.failure(PinError.NoPinStored)) + whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn( + Result.failure(PinError.NoPinStored), + ) whenever(resourceProvider.getPinErrorNoPinStored()).thenReturn("No PIN stored") - // When - viewModel.onPinComplete(pin, PinMode.ASK) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - val state = viewModel.uiState.value - assertTrue(state is PinState.Error) - assertEquals("No PIN stored", state.message) + assertEquals("No PIN stored", viewModel.uiState.value.errorMessage) } @Test fun `onForgotPin executes successfully`() = runTest { - // Given viewModel = createViewModel() whenever(forgotPinUseCase(Unit)).thenReturn(Result.success(Unit)) - // When viewModel.onForgotPin() advanceUntilIdle() - // Then - assertTrue(viewModel.uiState.value is PinState.Dismissed) + assertTrue(viewModel.uiState.value.isDismissed) } @Test fun `onForgotPin shows error when it fails`() = runTest { - // Given viewModel = createViewModel() - val error = Exception("Logout failed") - whenever(forgotPinUseCase(Unit)).thenReturn(Result.failure(error)) + whenever(forgotPinUseCase(Unit)).thenReturn(Result.failure(Exception("Logout failed"))) - // When viewModel.onForgotPin() advanceUntilIdle() - // Then - val state = viewModel.uiState.value - assertTrue(state is PinState.Error) - assertEquals("Logout failed", state.message) + assertEquals("Logout failed", viewModel.uiState.value.errorMessage) } @Test fun `onForgotPin uses resource provider when error message is null`() = runTest { - // Given viewModel = createViewModel() - val error = Exception() - whenever(forgotPinUseCase(Unit)).thenReturn(Result.failure(error)) + whenever(forgotPinUseCase(Unit)).thenReturn(Result.failure(Exception())) whenever(resourceProvider.getPinErrorResetFailed()).thenReturn("Failed to reset PIN") - // When viewModel.onForgotPin() advanceUntilIdle() - // Then - val state = viewModel.uiState.value - assertTrue(state is PinState.Error) - assertEquals("Failed to reset PIN", state.message) + assertEquals("Failed to reset PIN", viewModel.uiState.value.errorMessage) } @Test - fun `resetState changes state to Idle`() = + fun `resetState clears error and active flags`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.ASK) val pin = "0000" - whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn(Result.failure(PinError.Failed(attemptsLeft = 2))) + whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn( + Result.failure(PinError.Failed(attemptsLeft = 2)), + ) whenever(resourceProvider.getPinErrorIncorrect()).thenReturn("Incorrect PIN") - viewModel.onPinComplete(pin, PinMode.ASK) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // When viewModel.resetState() - // Then - assertEquals(PinState.Idle, viewModel.uiState.value) + val state = viewModel.uiState.value + assertNull(state.errorMessage) + assertFalse(state.isLoading) + assertFalse(state.isSuccess) + assertFalse(state.isDismissed) + assertFalse(state.isTooManyAttempts) } @Test fun `resetAttempts resets the attempt counter`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.ASK) val pin = "0000" whenever(resourceProvider.getPinErrorIncorrect()).thenReturn("Incorrect PIN") - - // Make a failed attempt - whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn(Result.failure(PinError.Failed(attemptsLeft = 2))) - viewModel.onPinComplete(pin, PinMode.ASK) + whenever(resourceProvider.getPinErrorWithAttempts("Incorrect PIN", 2)) + .thenReturn("Incorrect PIN. 2 attempts remaining") + + // First failed attempt (attempt index 0) + whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn( + Result.failure(PinError.Failed(attemptsLeft = 2)), + ) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // When + // Reset attempt counter viewModel.resetAttempts() - // Try again - should use attempts = 0 - whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn(Result.failure(PinError.Failed(attemptsLeft = 2))) - viewModel.onPinComplete(pin, PinMode.ASK) + // Second attempt should restart from index 0 + whenever(validatePinUseCase(ValidatePinInput(pin, 0))).thenReturn( + Result.failure(PinError.Failed(attemptsLeft = 2)), + ) + viewModel.onPinChanged(pin) + viewModel.onPinComplete() advanceUntilIdle() - // Then - should still have 2 attempts left - val state = viewModel.uiState.value - assertTrue(state is PinState.Error) - assertEquals(2, state.remainingAttempts) + assertEquals("Incorrect PIN. 2 attempts remaining", viewModel.uiState.value.errorMessage) } @Test - fun `state changes are emitted correctly`() = + fun `state transitions through loading to success`() = runTest { - // Given - viewModel = createViewModel() + viewModel = createViewModel(PinMode.SET) val pin = "1234" whenever(savePinUseCase(pin)).thenReturn(Result.success(Unit)) - // When/Then - viewModel.uiState.test { - // Initial state - assertEquals(PinState.Idle, awaitItem()) - - // Trigger save - viewModel.onPinComplete(pin, PinMode.SET) - - // Loading state - assertEquals(PinState.Loading, awaitItem()) - - advanceUntilIdle() + viewModel.onPinChanged(pin) + viewModel.onPinComplete() + advanceUntilIdle() - // Success state - assertEquals(PinState.Success, awaitItem()) - } + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertTrue(state.isSuccess) } - private fun createViewModel() = + private fun createViewModel(mode: PinMode = PinMode.ASK) = PinViewModel( + mode = mode, savePinUseCase = savePinUseCase, validatePinUseCase = validatePinUseCase, forgotPinUseCase = forgotPinUseCase, diff --git a/login/src/desktopMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.desktop.kt b/login/src/desktopMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.desktop.kt index 2a0dba63de3..ed7aa425c62 100644 --- a/login/src/desktopMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.desktop.kt +++ b/login/src/desktopMain/kotlin/org/dhis2/mobile/login/main/di/LoginModule.desktop.kt @@ -4,4 +4,6 @@ import org.koin.core.module.Module import org.koin.dsl.module internal actual val accountModule: Module - get() = module { } + get() = + module { + } diff --git a/run_tests.sh b/run_tests.sh index 1aaa083608d..1907444367b 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -6,6 +6,6 @@ echo "Running Ktlint check..." ./gradlew ktlintCheck echo "Running Unit Tests..." -./gradlew testDebugUnitTest testDhis2DebugUnitTest +./gradlew testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest echo "All tasks completed!" diff --git a/scripts/check_pr_size.sh b/scripts/check_pr_size.sh index d5a9f161367..f0b15e54fee 100644 --- a/scripts/check_pr_size.sh +++ b/scripts/check_pr_size.sh @@ -40,12 +40,15 @@ fi echo "Merge base: $MERGE_BASE" +# Define the files to exclude +EXCLUDE_PATTERN="**/verification-metadata.xml" + # Print the diff statistics from merge base to HEAD (only your changes) -echo "Diff statistics from merge base to HEAD (PR changes only):" -git diff --stat "$MERGE_BASE"..HEAD +echo "Diff statistics from merge base to HEAD (PR changes only), excluding $EXCLUDE_PATTERN:" +git diff --stat "$MERGE_BASE"..HEAD -- . ":(exclude)$EXCLUDE_PATTERN" # Get the total number of lines changed in the PR from the merge base -CHANGED_LINES=$(git diff --stat "$MERGE_BASE"..HEAD | tail -n1 | awk '{print $4 + $6}') +CHANGED_LINES=$(git diff --numstat "$MERGE_BASE"..HEAD -- . ":(exclude)$EXCLUDE_PATTERN" | awk '{s+=$1; s+=$2} END {print s}') # Handle cases where no changes are detected if [[ -z "$CHANGED_LINES" ]]; then @@ -59,4 +62,4 @@ if [[ "$CHANGED_LINES" -gt "$MAX_LINES" ]]; then fi echo "PR size is acceptable: $CHANGED_LINES lines changed." -exit 0 \ No newline at end of file +exit 0 diff --git a/settings.gradle.kts b/settings.gradle.kts index de9c03925ea..b2e05f67018 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,7 +26,7 @@ dependencyResolutionManagement { include( ":app", ":dhis_android_analytics", ":form", ":commons", - ":dhis2_android_maps", ":compose-table", ":ui-components", + ":dhis2_android_maps", ":compose-table", ":stock-usecase" ) include(":dhis2-mobile-program-rules") diff --git a/stock-usecase/build.gradle.kts b/stock-usecase/build.gradle.kts index 01555229985..bc413a81117 100644 --- a/stock-usecase/build.gradle.kts +++ b/stock-usecase/build.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") - kotlin("android") id("kotlin-parcelize") alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose.compiler) @@ -20,7 +19,6 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() testOptions.targetSdk = libs.versions.sdk.get().toInt() - multiDexEnabled = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -86,6 +84,8 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.bundles.stock.implementation) implementation(libs.androidx.compose) + implementation(libs.androidx.compose.constraintlayout) + testImplementation(project(":dhis_android_analytics")) coreLibraryDesugaring(libs.bundles.stock.core) testImplementation(libs.bundles.stock.test) diff --git a/stock-usecase/src/main/AndroidManifest.xml b/stock-usecase/src/main/AndroidManifest.xml index d4fd3a53e7c..3078b27ec3e 100644 --- a/stock-usecase/src/main/AndroidManifest.xml +++ b/stock-usecase/src/main/AndroidManifest.xml @@ -3,31 +3,10 @@ - - - - - - - - - - - - - - - - - - event.trackedEntityDataValues()?.forEach { dataValue -> - dataValue.dataElement().let { dv -> - if (dv.equals(stockOnHandUid)) { - return dataValue.value() - } - } - } + events.forEach { event -> + val dataValue = d2.trackedEntityModule().trackedEntityDataValues() + .value(event.uid(), stockOnHandUid).blockingGet() + dataValue?.let { + return it.value() } - + } return null } @@ -175,9 +167,9 @@ class StockManagerImpl( ): String { Timber.tag("EVENT_CREATION").i( "Enrollment: ${enrollment.uid()}\n" + - "Program: ${programUid}\n" + - "Stage: ${programStage.uid()}\n" + - "OU: ${facility.uid}\n", + "Program: ${programUid}\n" + + "Stage: ${programStage.uid()}\n" + + "OU: ${facility.uid}\n", ) return d2.eventModule().events().blockingAdd( EventCreateProjection @@ -209,7 +201,14 @@ class StockManagerImpl( items.forEach { entry -> getEnrollment(entry.item.id)?.let { enrollment -> - createEvent(entry, programStage, enrollment, transaction, stockUseCase, hasErrorOnComplete) + createEvent( + entry, + programStage, + enrollment, + transaction, + stockUseCase, + hasErrorOnComplete + ) } } return Single.just(Unit) @@ -309,7 +308,14 @@ class StockManagerImpl( try { Timber.i("event:$eventUid") - Timber.i("de:${getTransactionDataElement(transaction.transactionType, stockUseCase)}") + Timber.i( + "de:${ + getTransactionDataElement( + transaction.transactionType, + stockUseCase + ) + }" + ) Timber.i("data to save:${item.qty}") d2 .trackedEntityModule() @@ -354,7 +360,13 @@ class StockManagerImpl( } try { - updateStockOnHand(item, stockUseCase.programUid, transaction, eventUid, stockUseCase) + updateStockOnHand( + item, + stockUseCase.programUid, + transaction, + eventUid, + stockUseCase + ) } catch (e: Exception) { if (e is D2Error) { Timber.e(e.originalException()) diff --git a/stock-usecase/src/main/java/org/dhis2/android/rtsm/services/rules/RuleValidationHelperImpl.kt b/stock-usecase/src/main/java/org/dhis2/android/rtsm/services/rules/RuleValidationHelperImpl.kt index 5d2d9e75df6..ea3b5721372 100644 --- a/stock-usecase/src/main/java/org/dhis2/android/rtsm/services/rules/RuleValidationHelperImpl.kt +++ b/stock-usecase/src/main/java/org/dhis2/android/rtsm/services/rules/RuleValidationHelperImpl.kt @@ -2,8 +2,6 @@ package org.dhis2.android.rtsm.services.rules import io.reactivex.Flowable import io.reactivex.Single -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import org.apache.commons.lang3.math.NumberUtils import org.dhis2.android.rtsm.data.TransactionType import org.dhis2.android.rtsm.data.models.StockEntry @@ -15,6 +13,7 @@ import org.dhis2.commons.bindings.distributedTo import org.dhis2.commons.rules.RuleEngineContextData import org.dhis2.mobileProgramRules.toRuleDataValue import org.dhis2.mobileProgramRules.toRuleEngineInstant +import org.dhis2.mobileProgramRules.toRuleEngineInstantOrNow import org.dhis2.mobileProgramRules.toRuleEngineLocalDate import org.dhis2.mobileProgramRules.toRuleList import org.dhis2.mobileProgramRules.toRuleVariableList @@ -27,6 +26,7 @@ import org.hisp.dhis.android.core.program.ProgramRuleActionType import org.hisp.dhis.android.core.program.ProgramStage import org.hisp.dhis.android.core.usecase.stock.StockUseCase import org.hisp.dhis.rules.api.RuleEngine +import org.hisp.dhis.rules.api.RuleSupplementaryData import org.hisp.dhis.rules.models.RuleDataValue import org.hisp.dhis.rules.models.RuleEffect import org.hisp.dhis.rules.models.RuleEvent @@ -131,6 +131,7 @@ class RuleValidationHelperImpl( programStage.uid(), programStage.name()!!, RuleEventStatus.ACTIVE, + period.toRuleEngineLocalDate(), period.toRuleEngineInstant(), period.toRuleEngineInstant(), period.toRuleEngineLocalDate(), @@ -178,7 +179,7 @@ class RuleValidationHelperImpl( constantsMap } - private fun supplementaryData(): Single>> = Single.just(hashMapOf()) + private fun supplementaryData(): Single = Single.just(RuleSupplementaryData()) private fun ruleEngineData( teiUid: String, @@ -272,12 +273,9 @@ class RuleValidationHelperImpl( } else { RuleEventStatus.valueOf(event.status()!!.name) }, - eventDate = (event.eventDate() ?: Date()).toRuleEngineInstant(), - createdDate = - event - .created() - ?.let { Instant.fromEpochMilliseconds(it.time) } - ?: Clock.System.now(), + eventDate = (event.eventDate() ?: Date()).toRuleEngineLocalDate(), + createdDate = event.created().toRuleEngineInstantOrNow(), + createdAtClientDate = event.createdAtClient()?.toRuleEngineInstant(), dueDate = event.dueDate()?.toRuleEngineLocalDate(), completedDate = event.completedDate()?.toRuleEngineLocalDate(), organisationUnit = event.organisationUnit()!!, diff --git a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/HomeActivity.kt b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/HomeActivity.kt index c5814e344b9..f40ae195be6 100644 --- a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/HomeActivity.kt +++ b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/HomeActivity.kt @@ -4,22 +4,18 @@ import android.content.Intent import android.os.Bundle import android.util.TypedValue import android.view.WindowManager -import android.widget.Toast import androidx.activity.compose.setContent -import androidx.activity.result.ActivityResultLauncher +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.ScaffoldState -import androidx.compose.material.Surface +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.res.colorResource -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanIntentResult -import com.journeyapps.barcodescanner.ScanOptions +import androidx.core.content.ContextCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.dhis2.android.rtsm.R @@ -28,22 +24,18 @@ import org.dhis2.android.rtsm.ui.home.screens.HomeScreen import org.dhis2.android.rtsm.ui.managestock.ManageStockViewModel import org.dhis2.android.rtsm.utils.NetworkUtils import org.dhis2.commons.Constants -import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.sync.OnDismissListener import org.dhis2.commons.sync.OnSyncNavigationListener import org.dhis2.commons.sync.SyncContext import org.dhis2.commons.sync.SyncDialog import org.dhis2.commons.sync.SyncStatusItem -import org.dhis2.commons.ui.extensions.handleInsets +import org.dhis2.mobile.commons.extensions.toColor import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme import org.koin.androidx.viewmodel.ext.android.viewModel class HomeActivity : AppCompatActivity() { private val viewModel: HomeViewModel by viewModel() private val manageStockViewModel: ManageStockViewModel by viewModel() - private var themeColor = R.color.colorPrimary - private lateinit var filterManager: FilterManager - private lateinit var barcodeLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -53,46 +45,46 @@ class HomeActivity : AppCompatActivity() { onBackPressedDispatcher.onBackPressed() } - filterManager = FilterManager.getInstance() intent .getStringExtra(Constants.PROGRAM_UID) ?.let { manageStockViewModel.setConfig(it) } - handleInsets() + enableEdgeToEdge() setContent { val settingsUiState by viewModel.settingsUiState.collectAsState() + val themeColor by remember { + derivedStateOf { + updateTheme(settingsUiState.selectedTransactionItem.type) + } + } val helperText by viewModel.helperText.collectAsState() manageStockViewModel.setHelperText(helperText) - updateTheme(settingsUiState.selectedTransactionItem.type) - manageStockViewModel.setThemeColor(Color(colorResource(themeColor).toArgb())) DHIS2Theme { - Surface( - modifier = Modifier.fillMaxSize(), - color = Color(colorResource(themeColor).toArgb()), + CompositionLocalProvider( + LocalThemeColor provides themeColor ) { HomeScreen( - this, - viewModel, - manageStockViewModel, - Color(colorResource(themeColor).toArgb()), - supportFragmentManager, - barcodeLauncher, - { _, _ -> manageStockViewModel.onButtonClick() }, - ) { scope, scaffold -> - synchronizeData( - scope, - scaffold, - settingsUiState.programUid, - ) - } + viewModel = viewModel, + manageStockViewModel = manageStockViewModel, + supportFragmentManager = supportFragmentManager, + proceedAction = { _, _ -> manageStockViewModel.onButtonClick() }, + onFinish = { + finish() + }, + syncAction = { scope, scaffold -> + synchronizeData( + scope, + scaffold, + settingsUiState.programUid, + ) + } + ) } } } - - configureScanner() } - private fun updateTheme(type: TransactionType) { + private fun updateTheme(type: TransactionType): Color { val color: Int val theme: Int @@ -112,7 +104,7 @@ class HomeActivity : AppCompatActivity() { theme = R.style.correction } } - if (color != -1) { + return if (color != -1) { this.theme.applyStyle(theme, true) val window = window @@ -122,7 +114,9 @@ class HomeActivity : AppCompatActivity() { val colorToReturn = a.getColor(0, 0) a.recycle() window.statusBarColor = colorToReturn - themeColor = color + Color(ContextCompat.getColor(this, color)) + } else { + Color(ContextCompat.getColor(this, R.color.colorPrimary)) } } @@ -169,25 +163,6 @@ class HomeActivity : AppCompatActivity() { scaffoldState.snackbarHostState.showSnackbar(message) } } - - private fun configureScanner() { - val barcodeLauncher: ActivityResultLauncher = - registerForActivityResult( - ScanContract(), - ) { scanIntentResult -> - if (scanIntentResult.contents == null) { - Toast.makeText(this, "Scan cancelled!", Toast.LENGTH_SHORT).show() - } else { - onScanCompleted( - scanIntentResult, - ) - } - } - this.barcodeLauncher = barcodeLauncher - } - - private fun onScanCompleted(result: ScanIntentResult) { - val data = result.contents - manageStockViewModel.onSearchQueryChanged(data) - } } + +val LocalThemeColor = compositionLocalOf { "#FF007DEB".toColor() } diff --git a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/model/ButtonUiState.kt b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/model/ButtonUiState.kt index ea8f8ab34d0..63f672067b9 100644 --- a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/model/ButtonUiState.kt +++ b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/model/ButtonUiState.kt @@ -1,13 +1,9 @@ package org.dhis2.android.rtsm.ui.home.model -import androidx.compose.ui.graphics.Color import org.dhis2.android.rtsm.R data class ButtonUiState( val text: Int = R.string.review, val icon: Int = R.drawable.proceed_icon, - val color: Color = Color.White, - val contentColor: Color = Color.White, - val containerColor: Color = Color.Blue, val visible: Boolean = false, ) diff --git a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/AnalyticsScreen.kt b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/AnalyticsScreen.kt index 6b07f1ec2f0..2aeba8ad77d 100644 --- a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/AnalyticsScreen.kt +++ b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/AnalyticsScreen.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import dhis2.org.analytics.charts.ui.GroupAnalyticsFragment import kotlinx.coroutines.CoroutineScope import org.dhis2.android.rtsm.ui.home.HomeViewModel +import org.dhis2.android.rtsm.ui.home.LocalThemeColor import org.dhis2.android.rtsm.ui.home.screens.components.AnalyticsTopBar @OptIn(ExperimentalMaterialApi::class) @@ -28,7 +29,6 @@ fun AnalyticsScreen( modifier: Modifier = Modifier, viewModel: HomeViewModel = viewModel(), backAction: () -> Unit, - themeColor: Color, scaffoldState: ScaffoldState, syncAction: (scope: CoroutineScope, scaffoldState: ScaffoldState) -> Unit = { _, _ -> }, supportFragmentManager: FragmentManager, @@ -40,7 +40,7 @@ fun AnalyticsScreen( appBar = { AnalyticsTopBar( title = settingsUiState.programName, - themeColor = themeColor, + themeColor = LocalThemeColor.current, backAction = { backAction.invoke() }, @@ -48,7 +48,7 @@ fun AnalyticsScreen( syncAction = syncAction, ) }, - backLayerBackgroundColor = themeColor, + backLayerBackgroundColor = LocalThemeColor.current, backLayerContent = { }, frontLayerElevation = 0.dp, diff --git a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/HomeScreen.kt b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/HomeScreen.kt index e976a0bbf0e..416570f4db5 100644 --- a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/HomeScreen.kt +++ b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/HomeScreen.kt @@ -1,12 +1,13 @@ package org.dhis2.android.rtsm.ui.home.screens -import android.app.Activity -import androidx.activity.result.ActivityResultLauncher import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material.Icon import androidx.compose.material.Scaffold import androidx.compose.material.ScaffoldState @@ -25,7 +26,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewmodel.compose.viewModel -import com.journeyapps.barcodescanner.ScanOptions import kotlinx.coroutines.CoroutineScope import org.dhis2.android.rtsm.R import org.dhis2.android.rtsm.ui.home.HomeViewModel @@ -35,18 +35,15 @@ import org.dhis2.android.rtsm.ui.managestock.ManageStockViewModel import org.hisp.dhis.mobile.ui.designsystem.component.ExtendedFAB import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBar import org.hisp.dhis.mobile.ui.designsystem.component.navigationBar.NavigationBarItem -import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme @Composable fun HomeScreen( - activity: Activity, viewModel: HomeViewModel = viewModel(), manageStockViewModel: ManageStockViewModel = viewModel(), - themeColor: Color, supportFragmentManager: FragmentManager, - barcodeLauncher: ActivityResultLauncher, proceedAction: (scope: CoroutineScope, scaffoldState: ScaffoldState) -> Unit = { _, _ -> }, syncAction: (scope: CoroutineScope, scaffoldState: ScaffoldState) -> Unit = { _, _ -> }, + onFinish: () -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -58,15 +55,17 @@ fun HomeScreen( NavigationBarItem( id = BottomNavigation.DATA_ENTRY.id, icon = Icons.AutoMirrored.Outlined.List, - label = activity.getString(R.string.navigation_data_entry), + label = stringResource(R.string.navigation_data_entry), ), NavigationBarItem( id = BottomNavigation.ANALYTICS.id, icon = Icons.Outlined.BarChart, - label = activity.getString(R.string.section_charts), + label = stringResource(R.string.section_charts), ), ) Scaffold( + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.safeDrawing, scaffoldState = scaffoldState, floatingActionButton = { AnimatedVisibility( @@ -80,7 +79,7 @@ fun HomeScreen( Icon( painter = painterResource(id = dataEntryUiState.button.icon), contentDescription = stringResource(dataEntryUiState.button.text), - tint = dataEntryUiState.button.contentColor, + tint = Color.White, ) }, onClick = { @@ -112,31 +111,25 @@ fun HomeScreen( ) { targetIndex -> when (targetIndex) { BottomNavigation.ANALYTICS.id -> { - DHIS2Theme { - AnalyticsScreen( - viewModel = viewModel, - backAction = { manageStockViewModel.onHandleBackNavigation() }, - themeColor = themeColor, - modifier = Modifier.padding(paddingValues), - scaffoldState = scaffoldState, - supportFragmentManager = supportFragmentManager, - ) - } + AnalyticsScreen( + viewModel = viewModel, + backAction = { manageStockViewModel.onHandleBackNavigation() }, + modifier = Modifier.padding(paddingValues), + scaffoldState = scaffoldState, + supportFragmentManager = supportFragmentManager, + ) } BottomNavigation.DATA_ENTRY.id -> { Backdrop( - activity = activity, viewModel = viewModel, manageStockViewModel = manageStockViewModel, modifier = Modifier.padding(paddingValues), - themeColor = themeColor, supportFragmentManager = supportFragmentManager, - barcodeLauncher = barcodeLauncher, scaffoldState = scaffoldState, - ) { coroutineScope, scaffold -> - syncAction(coroutineScope, scaffold) - } + onFinish = onFinish, + syncAction = syncAction, + ) } } } diff --git a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/BackdropComponent.kt b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/BackdropComponent.kt index 2753ed3c2bf..15f6cfaa8eb 100644 --- a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/BackdropComponent.kt +++ b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/BackdropComponent.kt @@ -1,9 +1,7 @@ package org.dhis2.android.rtsm.ui.home.screens.components import android.annotation.SuppressLint -import android.app.Activity import androidx.activity.compose.BackHandler -import androidx.activity.result.ActivityResultLauncher import androidx.compose.material.BackdropScaffold import androidx.compose.material.BackdropValue import androidx.compose.material.ExperimentalMaterialApi @@ -22,15 +20,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import com.journeyapps.barcodescanner.ScanOptions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.dhis2.android.rtsm.R import org.dhis2.android.rtsm.data.TransactionType import org.dhis2.android.rtsm.ui.home.HomeViewModel +import org.dhis2.android.rtsm.ui.home.LocalThemeColor import org.dhis2.android.rtsm.ui.home.model.DataEntryStep import org.dhis2.android.rtsm.ui.home.model.EditionDialogResult import org.dhis2.android.rtsm.ui.home.model.SettingsUiState @@ -43,16 +43,16 @@ import org.dhis2.commons.dialogs.bottomsheet.DialogButtonStyle @OptIn(ExperimentalMaterialApi::class) @Composable fun Backdrop( - activity: Activity, viewModel: HomeViewModel, manageStockViewModel: ManageStockViewModel, modifier: Modifier = Modifier, - themeColor: Color, supportFragmentManager: FragmentManager, - barcodeLauncher: ActivityResultLauncher, scaffoldState: ScaffoldState, syncAction: (scope: CoroutineScope, scaffoldState: ScaffoldState) -> Unit = { _, _ -> }, + onFinish: () -> Unit ) { + val context = LocalContext.current + val backdropState = rememberBackdropScaffoldState(BackdropValue.Revealed) var isFrontLayerDisabled by remember { mutableStateOf(null) } val settingsUiState by viewModel.settingsUiState.collectAsState() @@ -61,15 +61,15 @@ fun Backdrop( val bottomSheetState = manageStockViewModel.bottomSheetState.collectAsState() if (bottomSheetState.value) { launchBottomSheet( - activity.getString(R.string.not_saved), - activity.getString(R.string.transaction_not_confirmed), + stringResource(R.string.not_saved), + stringResource(R.string.transaction_not_confirmed), supportFragmentManager, onKeepEdition = { manageStockViewModel.onBottomSheetClosed() }, onDiscard = { manageStockViewModel.onBottomSheetClosed() - activity.finish() + onFinish() }, ) } @@ -87,7 +87,7 @@ fun Backdrop( settingsUiState.selectedTransactionItem.label, settingsUiState.fromFacilitiesLabel().asString(), settingsUiState.deliverToLabel()?.asString(), - themeColor, + LocalThemeColor.current, launchBottomSheet = { manageStockViewModel.onHandleBackNavigation() }, @@ -98,17 +98,16 @@ fun Backdrop( settingsUiState.hasDestinationSelected(), ) }, - backLayerBackgroundColor = themeColor, + backLayerBackgroundColor = LocalThemeColor.current, backLayerContent = { FilterList( viewModel, dataEntryUiState, - themeColor, supportFragmentManager, launchDialog = { msg, result -> launchBottomSheet( - activity.getString(R.string.not_saved), - activity.getString(msg), + context.getString(R.string.not_saved), + context.getString(msg), supportFragmentManager, onKeepEdition = { result.invoke(EditionDialogResult.KEEP) @@ -135,10 +134,8 @@ fun Backdrop( MainContent( backdropState, isFrontLayerDisabled, - themeColor, viewModel, manageStockViewModel, - barcodeLauncher, ) }, scaffoldState = backdropState, diff --git a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/DropdownComponents.kt b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/DropdownComponents.kt index 5c690877b36..24d5a3cbf72 100644 --- a/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/DropdownComponents.kt +++ b/stock-usecase/src/main/java/org/dhis2/android/rtsm/ui/home/screens/components/DropdownComponents.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.zIndex import androidx.fragment.app.FragmentManager import org.dhis2.android.rtsm.R import org.dhis2.android.rtsm.data.models.TransactionItem +import org.dhis2.android.rtsm.ui.home.LocalThemeColor import org.dhis2.android.rtsm.ui.home.model.DataEntryStep import org.dhis2.android.rtsm.ui.home.model.DataEntryUiState import org.dhis2.android.rtsm.ui.home.model.EditionDialogResult @@ -61,7 +62,6 @@ fun DropdownComponentTransactions( settingsUiState: SettingsUiState, onTransitionSelected: (transition: TransactionItem) -> Unit, hasUnsavedData: Boolean, - themeColor: Color = colorResource(R.color.colorPrimary), launchDialog: (msg: Int, (result: EditionDialogResult) -> Unit) -> Unit, ) { var isExpanded by remember { mutableStateOf(false) } @@ -99,12 +99,14 @@ fun DropdownComponentTransactions( .fillMaxWidth() .onGloballyPositioned { coordinates -> textFieldSize = coordinates.size.toSize() - }.shadow( + } + .shadow( elevation = 8.dp, ambientColor = Color.Black.copy(alpha = 0.1f), shape = RoundedCornerShape(30.dp), clip = false, - ).offset(0.dp, 0.dp) + ) + .offset(0.dp, 0.dp) .background(color = Color.White, shape = RoundedCornerShape(30.dp)), readOnly = true, singleLine = true, @@ -115,7 +117,7 @@ fun DropdownComponentTransactions( Modifier .align(Alignment.CenterHorizontally) .padding(16.dp), - tint = themeColor, + tint = LocalThemeColor.current, ) }, trailingIcon = { @@ -124,7 +126,7 @@ fun DropdownComponentTransactions( isExpanded = !isExpanded }, icon = { - Icon(icon, contentDescription = null, tint = themeColor) + Icon(icon, contentDescription = null, tint = LocalThemeColor.current) }, ) }, @@ -188,7 +190,8 @@ fun DropdownComponentTransactions( Color.White }, shape = RoundedCornerShape(16.dp), - ).padding(paddingValue), + ) + .padding(paddingValue), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { @@ -196,7 +199,7 @@ fun DropdownComponentTransactions( painter = painterResource(item.icon), contentDescription = null, Modifier.padding(6.dp), - tint = themeColor, + tint = LocalThemeColor.current, ) Text(text = capitalizeText(item.label)) } @@ -212,7 +215,6 @@ fun DropdownComponentFacilities( settingsUiState: SettingsUiState, onFacilitySelected: (facility: OrganisationUnit) -> Unit, hasUnsavedData: Boolean, - themeColor: Color = colorResource(R.color.colorPrimary), supportFragmentManager: FragmentManager, data: List, launchDialog: (msg: Int, (result: EditionDialogResult) -> Unit) -> Unit, @@ -245,12 +247,14 @@ fun DropdownComponentFacilities( .fillMaxWidth() .onGloballyPositioned { coordinates -> textFieldSize = coordinates.size.toSize() - }.shadow( + } + .shadow( elevation = 8.dp, ambientColor = Color.Black.copy(alpha = 0.1f), shape = RoundedCornerShape(30.dp), clip = false, - ).offset(0.dp, 0.dp) + ) + .offset(0.dp, 0.dp) .background(color = Color.White, shape = RoundedCornerShape(30.dp)), readOnly = true, singleLine = true, @@ -261,7 +265,7 @@ fun DropdownComponentFacilities( Modifier .align(Alignment.CenterHorizontally) .padding(16.dp), - tint = themeColor, + tint = LocalThemeColor.current, ) }, trailingIcon = { @@ -279,7 +283,7 @@ fun DropdownComponentFacilities( Icon( painterResource(id = R.drawable.ic_arrow_drop_down), contentDescription = null, - tint = themeColor, + tint = LocalThemeColor.current, ) }, ) @@ -303,7 +307,6 @@ fun DropdownComponentFacilities( fun DropdownComponentDistributedTo( onDestinationSelected: (destination: Option) -> Unit, dataEntryUiState: DataEntryUiState, - themeColor: Color = colorResource(R.color.colorPrimary), data: List diff --git a/sync/src/commonMain/composeResources/values-vi/strings.xml b/sync/src/commonMain/composeResources/values-vi/strings.xml index 4c1c46011bd..05ec9b7e48c 100644 --- a/sync/src/commonMain/composeResources/values-vi/strings.xml +++ b/sync/src/commonMain/composeResources/values-vi/strings.xml @@ -1,5 +1,6 @@ - + Dhis2 + diff --git a/sync/src/commonMain/composeResources/values-zh-rCN/strings.xml b/sync/src/commonMain/composeResources/values-zh-rCN/strings.xml index 4c1c46011bd..91e4338bcb7 100644 --- a/sync/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/sync/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -1,5 +1,6 @@ - + DHIS2 + diff --git a/sync/src/commonMain/composeResources/values-zh/strings.xml b/sync/src/commonMain/composeResources/values-zh/strings.xml index 4c1c46011bd..f38f31555db 100644 --- a/sync/src/commonMain/composeResources/values-zh/strings.xml +++ b/sync/src/commonMain/composeResources/values-zh/strings.xml @@ -1,5 +1,8 @@ - + DHIS2 + 检查网络设置 + 同步配置 + diff --git a/sync/src/commonMain/composeResources/values/strings.xml b/sync/src/commonMain/composeResources/values/strings.xml index 4c1c46011bd..3ec3434da4f 100644 --- a/sync/src/commonMain/composeResources/values/strings.xml +++ b/sync/src/commonMain/composeResources/values/strings.xml @@ -1,5 +1,9 @@ + Dhis2 + Checking web settings + Syncing your configuration + Syncing your data diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncBackgroundJobAction.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncBackgroundJobAction.kt new file mode 100644 index 00000000000..3e9f8adc8ff --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncBackgroundJobAction.kt @@ -0,0 +1,30 @@ +package org.dhis2.mobile.sync.data + +import kotlinx.coroutines.flow.Flow +import org.dhis2.mobile.sync.model.SyncJobStatus + +interface SyncBackgroundJobAction { + fun launchMetadataSync(syncingPeriod: Long) + + fun launchDataSync(syncingPeriod: Long) + + fun launchSyncSettings() + + fun observeMetadataJob(): Flow> + + fun observeDataJob(): Flow> + + suspend fun cancelSyncSettings() + + suspend fun cancelMetadataSync() + + suspend fun cancelDataSync() + + suspend fun cancelAll() + + fun getNextMetadataSync(): Long? + + fun getNextDataSync(): Long? + + fun getNextSettingsSync(): Long? +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncRepository.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncRepository.kt new file mode 100644 index 00000000000..b9e4bb6a7fc --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/data/SyncRepository.kt @@ -0,0 +1,55 @@ +package org.dhis2.mobile.sync.data + +import org.dhis2.mobile.sync.model.DataSyncProgressStatus +import org.dhis2.mobile.sync.model.SMSConfigResult +import org.dhis2.mobile.sync.model.SyncPeriod + +interface SyncRepository { + suspend fun refreshSyncSettings(): Result + + suspend fun currentMetadataSyncPeriod(): SyncPeriod? + + suspend fun currentDataSyncPeriod(): SyncPeriod? + + suspend fun syncMetadata(onProgressUpdate: (Int) -> Unit): Result + + suspend fun updateProjectAnalytics(): Result + + suspend fun setUpSMS(): Result + + suspend fun downloadMapMetadata(): Result + + suspend fun downloadFileResources(): Result + + suspend fun saveMetadataSyncState(isSuccess: Boolean) + + suspend fun uploadEvents(): Result + + suspend fun downloadEvents(onProgressUpdate: suspend (progressData: Map) -> Unit): Result + + suspend fun uploadTEIs(): Result + + suspend fun downloadTEIs(onProgressUpdate: suspend (progressData: Map) -> Unit): Result + + suspend fun uploadDataValues(): Result + + suspend fun downloadDataValues(onProgressUpdate: (progressData: Map) -> Unit): Result + + suspend fun downloadDataFileResources(onProgressUpdate: (progress: Double?) -> Unit): Result + + suspend fun downloadReservedValues(onProgressUpdate: (progress: Double?) -> Unit): Result + + suspend fun saveDataSyncState(isSuccess: Boolean): Result + + suspend fun getAllProgramsInitialStatus(): Result> + + suspend fun getAllEventPrograms(): Result> + + suspend fun getAllTrackerPrograms(): Result> + + suspend fun getAllDataSets(): Result> + + suspend fun saveDataSyncError(stackTrace: String) + + suspend fun toggleSMS(enable: Boolean): Result +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.kt new file mode 100644 index 00000000000..9beaff8c03b --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.kt @@ -0,0 +1,5 @@ +package org.dhis2.mobile.sync.di + +import org.koin.core.module.Module + +expect val syncModule: Module diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncData.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncData.kt new file mode 100644 index 00000000000..82ada6462a8 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncData.kt @@ -0,0 +1,90 @@ +package org.dhis2.mobile.sync.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.sync.data.SyncRepository +import org.dhis2.mobile.sync.model.DataSyncProgress +import org.dhis2.mobile.sync.model.DataSyncTask + +class SyncData( + private val repository: SyncRepository, + private val syncStatusController: SyncStatusController, +) : UseCase<(progress: DataSyncProgress) -> Unit, Unit> { + override suspend fun invoke(input: (progress: DataSyncProgress) -> Unit): Result = + try { + syncStatusController.initDownloadProcess( + repository.getAllProgramsInitialStatus().getOrNull() ?: emptyMap(), + ) + input(DataSyncProgress(DataSyncTask.UploadEvent, null)) + val uploadEventResult = repository.uploadEvents() + syncStatusController.startDownloadingEvents() + val downloadEventResult = + repository.downloadEvents { progressData -> + val done = progressData.count { (_, value) -> value.isComplete() } + val progress = 100.0 * done / progressData.size + input(DataSyncProgress(DataSyncTask.DownloadEvent, progress)) + syncStatusController.updateDownloadProcess(progressData) + } + syncStatusController.finishDownloadingEvents( + repository.getAllEventPrograms().getOrNull() ?: emptyList(), + ) + + input(DataSyncProgress(DataSyncTask.UploadTEI, null)) + val uploadTEIResult = repository.uploadTEIs() + syncStatusController.startDownloadingTracker() + val downloadTEIResult = + repository.downloadTEIs { progressData -> + val done = progressData.count { (_, value) -> value.isComplete() } + val progress = 100.0 * done / progressData.size + input(DataSyncProgress(DataSyncTask.DownloadTEI, progress)) + syncStatusController.updateDownloadProcess(progressData) + } + syncStatusController.finishDownloadingTracker( + repository.getAllTrackerPrograms().getOrNull() ?: emptyList(), + ) + + input(DataSyncProgress(DataSyncTask.UploadDataValue, null)) + val uploadDataValueResult = repository.uploadDataValues() + syncStatusController.startDownloadingDataSets() + val downloadDataValueResult = + repository.downloadDataValues { progressData -> + val done = progressData.count { (_, value) -> value.isComplete() } + val progress = 100.0 * done / progressData.size + input(DataSyncProgress(DataSyncTask.DownloadDataValue, progress)) + } + syncStatusController.finishDownloadingDataSets( + repository.getAllDataSets().getOrNull() ?: emptyList(), + ) + + syncStatusController.initDownloadMedia() + repository.downloadDataFileResources { progress -> + input(DataSyncProgress(DataSyncTask.DownloadFileResource, progress)) + } + + repository.downloadReservedValues { progress -> + input(DataSyncProgress(DataSyncTask.SyncReservedValues, progress)) + } + + val isSuccess = + uploadEventResult.isSuccess && + downloadEventResult.isSuccess && + uploadTEIResult.isSuccess && + downloadTEIResult.isSuccess && + uploadDataValueResult.isSuccess && + downloadDataValueResult.isSuccess + + repository.saveDataSyncState(isSuccess) + + Result.success(Unit) + } catch (domainError: DomainError) { + if (domainError is DomainError.NetworkError) { + syncStatusController.onNetworkUnavailable() + } + Result.failure(domainError) + } catch (e: Exception) { + repository.saveDataSyncError(e.stackTraceToString()) + Result.failure(e) + } finally { + syncStatusController.finishSync() + } +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncMetadata.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncMetadata.kt new file mode 100644 index 00000000000..5e522ccb47a --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncMetadata.kt @@ -0,0 +1,107 @@ +package org.dhis2.mobile.sync.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.data.SyncRepository +import org.dhis2.mobile.sync.model.SMSConfigResult +import org.dhis2.mobile.sync.model.SyncPeriod + +class SyncMetadata( + private val repository: SyncRepository, + private val syncBackgroundJobAction: SyncBackgroundJobAction, +) : UseCase<(progress: Int) -> Unit, Unit> { + override suspend fun invoke(input: (progress: Int) -> Unit): Result = + try { + val initialMetadataSyncPeriod = repository.currentMetadataSyncPeriod() + val initialDataSyncPeriod = repository.currentDataSyncPeriod() + + val syncMetadataResult = + repository.syncMetadata { progress -> + val totalProgress = 30 * progress / 100 + input(totalProgress) + } + if (syncMetadataResult.isSuccess) { + repository.updateProjectAnalytics() + input(40) + val smsConfigResult = repository.setUpSMS().getOrDefault(SMSConfigResult.DoNothing) + when (smsConfigResult) { + SMSConfigResult.DisableModule -> repository.toggleSMS(false) + SMSConfigResult.EnableModule -> repository.toggleSMS(true) + SMSConfigResult.DoNothing -> { + // no-op + } + } + input(50) + repository.downloadMapMetadata() + input(60) + repository.downloadFileResources() + input(70) + repository.saveMetadataSyncState(true) + input(80) + + val finalMetadataSyncPeriod = repository.currentMetadataSyncPeriod() + val finalDataSyncPeriod = repository.currentDataSyncPeriod() + + handleMetadataPeriodChange(initialMetadataSyncPeriod, finalMetadataSyncPeriod) + input(90) + handleDataPeriodChange(initialDataSyncPeriod, finalDataSyncPeriod) + input(100) + Result.success(Unit) + } else { + repository.saveMetadataSyncState(false) + Result.failure(syncMetadataResult.exceptionOrNull()!!) + } + } catch (e: Exception) { + Result.failure(e) + } + + private suspend fun handleMetadataPeriodChange( + initialMetadataSyncPeriod: SyncPeriod?, + finalMetadataSyncPeriod: SyncPeriod?, + ) { + val metadataSyncPeriodChanged = + initialMetadataSyncPeriod != finalMetadataSyncPeriod + val metadataSyncPeriodChangedToManual = + initialMetadataSyncPeriod !is SyncPeriod.Manual && finalMetadataSyncPeriod is SyncPeriod.Manual + + val notScheduled = + syncBackgroundJobAction.getNextMetadataSync() == null && finalMetadataSyncPeriod !is SyncPeriod.Manual + + when { + metadataSyncPeriodChangedToManual -> { + syncBackgroundJobAction.cancelMetadataSync() + syncBackgroundJobAction.launchSyncSettings() + } + + metadataSyncPeriodChanged || notScheduled -> { + syncBackgroundJobAction.launchMetadataSync( + finalMetadataSyncPeriod?.toSeconds() ?: SyncPeriod.Every7Days.toSeconds(), + ) + } + } + } + + private suspend fun handleDataPeriodChange( + initialDataSyncPeriod: SyncPeriod?, + finalDataSyncPeriod: SyncPeriod?, + ) { + val dataSyncPeriodChanged = initialDataSyncPeriod != finalDataSyncPeriod + val dataSyncPeriodChangedToManual = + initialDataSyncPeriod !is SyncPeriod.Manual && finalDataSyncPeriod is SyncPeriod.Manual + + val notScheduled = + syncBackgroundJobAction.getNextDataSync() == null && finalDataSyncPeriod !is SyncPeriod.Manual + + when { + dataSyncPeriodChangedToManual -> { + syncBackgroundJobAction.cancelDataSync() + } + + dataSyncPeriodChanged || notScheduled -> { + syncBackgroundJobAction.launchDataSync( + finalDataSyncPeriod?.toSeconds() ?: SyncPeriod.Every24Hour.toSeconds(), + ) + } + } + } +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncSettings.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncSettings.kt new file mode 100644 index 00000000000..7ddf2666f1f --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncSettings.kt @@ -0,0 +1,45 @@ +package org.dhis2.mobile.sync.domain + +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.data.SyncRepository +import org.dhis2.mobile.sync.model.SyncPeriod + +class SyncSettings( + private val repository: SyncRepository, + private val syncBackgroundJobAction: SyncBackgroundJobAction, +) : UseCase { + override suspend fun invoke(input: Unit): Result = + try { + val previousMetadataSyncPeriod = repository.currentMetadataSyncPeriod() + val previousDataSyncPeriod = repository.currentDataSyncPeriod() + + val result = repository.refreshSyncSettings() + if (result.isSuccess) { + val currentMetadataSyncPeriod = repository.currentMetadataSyncPeriod() + val currentDataSyncPeriod = repository.currentDataSyncPeriod() + + val dataPeriodChangedFromManual = + (previousDataSyncPeriod is SyncPeriod.Manual || previousDataSyncPeriod == null) && + currentDataSyncPeriod !is SyncPeriod.Manual + + if (dataPeriodChangedFromManual) { + syncBackgroundJobAction.launchDataSync(currentDataSyncPeriod?.toSeconds() ?: 0L) + } + + val metadataPeriodChangedFromManual = + (previousMetadataSyncPeriod is SyncPeriod.Manual || previousMetadataSyncPeriod == null) && + currentMetadataSyncPeriod !is SyncPeriod.Manual + + if (metadataPeriodChangedFromManual) { + syncBackgroundJobAction.launchMetadataSync(currentMetadataSyncPeriod?.toSeconds() ?: 0L) + syncBackgroundJobAction.cancelSyncSettings() + } + } + + result + } catch (e: DomainError) { + Result.failure(e) + } +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncStatusController.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncStatusController.kt new file mode 100644 index 00000000000..273104e5424 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/domain/SyncStatusController.kt @@ -0,0 +1,163 @@ +package org.dhis2.mobile.sync.domain + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.dhis2.mobile.sync.model.DataSyncProgressStatus +import org.dhis2.mobile.sync.model.SyncStatusData + +class SyncStatusController { + private var progressStatusMap: Map = emptyMap() + private val downloadStatus = MutableStateFlow(SyncStatusData(isInitialSync = true)) + + fun observeDownloadProcess() = downloadStatus.asStateFlow() + + suspend fun initDownloadProcess(programDownload: Map) { + progressStatusMap = programDownload + downloadStatus.emit( + SyncStatusData( + running = true, + downloadingEvents = false, + downloadingTracker = false, + downloadingDataSetValues = false, + false, + progressStatusMap, + ), + ) + } + + suspend fun updateDownloadProcess(programDownload: Map) { + progressStatusMap = + progressStatusMap.toMutableMap().also { + it.putAll(programDownload) + } + downloadStatus.emit( + downloadStatus.value.copy(programSyncStatusMap = progressStatusMap), + ) + } + + suspend fun finishSync() { + progressStatusMap = progressStatusMap.toMutableMap() + downloadStatus.emit( + downloadStatus.value.copy( + running = false, + programSyncStatusMap = progressStatusMap, + ), + ) + } + + suspend fun startDownloadingEvents() { + downloadStatus.emit( + downloadStatus.value.copy(running = true, downloadingEvents = true), + ) + } + + suspend fun finishDownloadingEvents(eventProgramUids: List) { + progressStatusMap = + progressStatusMap.toMutableMap().mapValues { entry -> + if (!eventProgramUids.contains(entry.key) || entry.value.isComplete()) { + entry.value + } else { + DataSyncProgressStatus.Failed + } + } + downloadStatus.emit( + downloadStatus.value.copy( + downloadingEvents = false, + programSyncStatusMap = progressStatusMap, + ), + ) + } + + suspend fun startDownloadingTracker() { + downloadStatus.emit( + downloadStatus.value.copy(downloadingTracker = true), + ) + } + + suspend fun finishDownloadingTracker(trackerProgramUids: List) { + progressStatusMap = + progressStatusMap.toMutableMap().mapValues { entry -> + if (!trackerProgramUids.contains(entry.key) || entry.value.isComplete()) { + entry.value + } else { + DataSyncProgressStatus.Failed + } + } + downloadStatus.emit( + downloadStatus.value.copy( + downloadingTracker = false, + programSyncStatusMap = progressStatusMap, + ), + ) + } + + suspend fun startDownloadingDataSets() { + downloadStatus.emit( + downloadStatus.value.copy(downloadingDataSetValues = true), + ) + } + + suspend fun finishDownloadingDataSets(dataSetUids: List) { + progressStatusMap = + progressStatusMap.toMutableMap().mapValues { entry -> + if (!dataSetUids.contains(entry.key) || entry.value.isComplete()) { + entry.value + } else { + DataSyncProgressStatus.Failed + } + } + downloadStatus.emit( + downloadStatus.value.copy( + downloadingDataSetValues = false, + programSyncStatusMap = progressStatusMap, + ), + ) + } + + suspend fun initDownloadMedia() { + downloadStatus.emit( + downloadStatus.value.copy(downloadingMedia = true), + ) + } + + suspend fun restore() { + if (downloadStatus.value.running == true) { + downloadStatus.emit( + SyncStatusData( + isInitialSync = true, + running = true, + ), + ) + } else { + downloadStatus.emit(SyncStatusData()) + } + } + + suspend fun onNetworkUnavailable() { + progressStatusMap = + progressStatusMap.toMutableMap().mapValues { entry -> + if (entry.value.isComplete()) { + entry.value + } else { + DataSyncProgressStatus.Failed + } + } + downloadStatus.emit( + SyncStatusData(true, programSyncStatusMap = progressStatusMap), + ) + } + + suspend fun updateSingleProgramToSuccess(programUid: String) { + progressStatusMap = + progressStatusMap.toMutableMap().mapValues { entry -> + if (programUid != entry.key) { + entry.value + } else { + DataSyncProgressStatus.Success + } + } + downloadStatus.emit( + SyncStatusData(false, programSyncStatusMap = progressStatusMap), + ) + } +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncProgress.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncProgress.kt new file mode 100644 index 00000000000..1f5f61fafe4 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncProgress.kt @@ -0,0 +1,6 @@ +package org.dhis2.mobile.sync.model + +data class DataSyncProgress( + val dataSyncTask: DataSyncTask, + val progress: Double?, +) diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncProgressStatus.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncProgressStatus.kt new file mode 100644 index 00000000000..d233e0e5b51 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncProgressStatus.kt @@ -0,0 +1,15 @@ +package org.dhis2.mobile.sync.model + +sealed interface DataSyncProgressStatus { + data object InProgress : DataSyncProgressStatus + + data object Success : DataSyncProgressStatus + + data object Failed : DataSyncProgressStatus + + data object PartiallyFailed : DataSyncProgressStatus + + data object None : DataSyncProgressStatus + + fun isComplete() = this == Success || this == Failed || this == PartiallyFailed +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncTask.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncTask.kt new file mode 100644 index 00000000000..c7b4729747f --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/DataSyncTask.kt @@ -0,0 +1,19 @@ +package org.dhis2.mobile.sync.model + +sealed interface DataSyncTask { + data object UploadEvent : DataSyncTask + + data object DownloadEvent : DataSyncTask + + data object UploadTEI : DataSyncTask + + data object DownloadTEI : DataSyncTask + + data object UploadDataValue : DataSyncTask + + data object DownloadDataValue : DataSyncTask + + data object DownloadFileResource : DataSyncTask + + data object SyncReservedValues : DataSyncTask +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SMSConfigResult.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SMSConfigResult.kt new file mode 100644 index 00000000000..c5b4ec68b4b --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SMSConfigResult.kt @@ -0,0 +1,9 @@ +package org.dhis2.mobile.sync.model + +sealed interface SMSConfigResult { + data object EnableModule : SMSConfigResult + + data object DisableModule : SMSConfigResult + + data object DoNothing : SMSConfigResult +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncJobStatus.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncJobStatus.kt new file mode 100644 index 00000000000..71f6f4dea61 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncJobStatus.kt @@ -0,0 +1,21 @@ +package org.dhis2.mobile.sync.model + +data class SyncJobStatus( + val tags: List, + val status: SyncStatus, + val message: String?, +) + +sealed interface SyncStatus { + data object Enqueue : SyncStatus + + data object Running : SyncStatus + + data object Succeed : SyncStatus + + data object Failed : SyncStatus + + data object Blocked : SyncStatus + + data object Cancelled : SyncStatus +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncPeriod.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncPeriod.kt new file mode 100644 index 00000000000..437fe882394 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncPeriod.kt @@ -0,0 +1,48 @@ +package org.dhis2.mobile.sync.model + +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +sealed interface SyncPeriod { + object Every15Min : SyncPeriod + + object Every30Min : SyncPeriod + + object EveryHour : SyncPeriod + + object Every6Hour : SyncPeriod + + object Every12Hour : SyncPeriod + + object Every24Hour : SyncPeriod + + object Every7Days : SyncPeriod + + object Manual : SyncPeriod + + fun toSeconds() = + when (this) { + Every12Hour -> 12.hours.inWholeSeconds + Every15Min -> 15.minutes.inWholeSeconds + Every24Hour -> 1.days.inWholeSeconds + Every30Min -> 30.minutes.inWholeSeconds + Every6Hour -> 6.hours.inWholeSeconds + Every7Days -> 7.days.inWholeSeconds + EveryHour -> 1.hours.inWholeSeconds + Manual -> 0 + } +} + +fun Long.toSyncPeriod() = + when { + this == 0.toLong() -> SyncPeriod.Manual + this == 15.minutes.inWholeSeconds -> SyncPeriod.Every15Min + this == 30.minutes.inWholeSeconds -> SyncPeriod.Every30Min + this == 1.hours.inWholeSeconds -> SyncPeriod.EveryHour + this == 6.hours.inWholeSeconds -> SyncPeriod.Every6Hour + this == 12.hours.inWholeSeconds -> SyncPeriod.Every12Hour + this == 1.days.inWholeSeconds -> SyncPeriod.Every24Hour + this == 7.days.inWholeSeconds -> SyncPeriod.Every7Days + else -> throw IllegalArgumentException("Invalid sync period") + } diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncResult.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncResult.kt new file mode 100644 index 00000000000..1fc194ceec2 --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncResult.kt @@ -0,0 +1,11 @@ +package org.dhis2.mobile.sync.model + +sealed class SyncResult( + val name: String, +) { + data object Sync : SyncResult("SYNC") + + data object Incomplete : SyncResult("INCOMPLETE") + + data object Error : SyncResult("ERROR") +} diff --git a/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncStatusData.kt b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncStatusData.kt new file mode 100644 index 00000000000..bede24b66fb --- /dev/null +++ b/sync/src/commonMain/kotlin/org/dhis2/mobile/sync/model/SyncStatusData.kt @@ -0,0 +1,33 @@ +package org.dhis2.mobile.sync.model + +data class SyncStatusData( + val running: Boolean? = null, + val downloadingEvents: Boolean = false, + val downloadingTracker: Boolean = false, + val downloadingDataSetValues: Boolean = false, + val downloadingMedia: Boolean = false, + val programSyncStatusMap: Map = emptyMap(), + val isInitialSync: Boolean = false, +) { + fun isProgramDownloading(uid: String): Boolean = programSyncStatusMap.isNotEmpty() && programSyncStatusMap[uid]?.isComplete() == false + + fun hasDownloadError(uid: String): Boolean = + programSyncStatusMap.isNotEmpty() && + ( + programSyncStatusMap[uid] is DataSyncProgressStatus.Failed || + programSyncStatusMap[uid] is DataSyncProgressStatus.PartiallyFailed + ) + + fun isProgramDownloaded(uid: String): Boolean = programSyncStatusMap[uid]?.isComplete() == true && running == true + + fun canDisplayMessage() = + when { + running == false or + downloadingEvents or + downloadingTracker or + downloadingDataSetValues or + downloadingMedia -> true + + else -> false + } +} diff --git a/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncMetadataTest.kt b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncMetadataTest.kt new file mode 100644 index 00000000000..39bb092ba0d --- /dev/null +++ b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncMetadataTest.kt @@ -0,0 +1,126 @@ +package org.dhis2.mobile.sync.domain + +import kotlinx.coroutines.runBlocking +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.data.SyncRepository +import org.dhis2.mobile.sync.model.SMSConfigResult +import org.dhis2.mobile.sync.model.SyncPeriod +import org.junit.Test +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.whenever + +class SyncMetadataTest { + private val repository: SyncRepository = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() + + private val syncMetadata = + SyncMetadata( + repository, + syncBackgroundJobAction, + ) + + @Test + fun `Should not trigger background jobs if sync periods do not change`() = + runBlocking { + whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.currentDataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.syncMetadata(any())) doReturn Result.success(Unit) + + syncMetadata.invoke { } + + verify(syncBackgroundJobAction, never()).launchMetadataSync(any()) + verify(syncBackgroundJobAction, never()).cancelMetadataSync() + verify(syncBackgroundJobAction, never()).launchDataSync(any()) + verify(syncBackgroundJobAction, never()).cancelDataSync() + } + + @Test + fun `Should cancel metadata sync if period changes to manual`() = + runBlocking { + whenever(repository.currentMetadataSyncPeriod()).thenReturn( + SyncPeriod.Every24Hour, + SyncPeriod.Manual, + ) + whenever(repository.currentDataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.syncMetadata(any())) doReturn Result.success(Unit) + whenever(repository.setUpSMS()) doReturn Result.success(SMSConfigResult.DoNothing) + whenever(repository.toggleSMS(true)) doReturn Result.success(Unit) + + syncMetadata.invoke { } + + verify(syncBackgroundJobAction).cancelMetadataSync() + verify(syncBackgroundJobAction).launchSyncSettings() + } + + @Test + fun `Should re-launch metadata sync if period changes`() = + runBlocking { + whenever(repository.currentMetadataSyncPeriod()).thenReturn( + SyncPeriod.Every24Hour, + SyncPeriod.Every7Days, + ) + whenever(repository.currentDataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.syncMetadata(any())) doReturn Result.success(Unit) + whenever(repository.setUpSMS()) doReturn Result.success(SMSConfigResult.DoNothing) + whenever(repository.toggleSMS(true)) doReturn Result.success(Unit) + + syncMetadata.invoke { } + + verify(syncBackgroundJobAction).launchMetadataSync(SyncPeriod.Every7Days.toSeconds()) + } + + @Test + fun `Should cancel data sync if period changes to manual`() = + runBlocking { + whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.currentDataSyncPeriod()).thenReturn( + SyncPeriod.Every24Hour, + SyncPeriod.Manual, + ) + whenever(repository.syncMetadata(any())) doReturn Result.success(Unit) + whenever(repository.setUpSMS()) doReturn Result.success(SMSConfigResult.DoNothing) + whenever(repository.toggleSMS(true)) doReturn Result.success(Unit) + + syncMetadata.invoke { } + + verify(syncBackgroundJobAction).cancelDataSync() + } + + @Test + fun `Should re-launch data sync if period changes`() = + runBlocking { + whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.currentDataSyncPeriod()).thenReturn( + SyncPeriod.Every24Hour, + SyncPeriod.Every7Days, + ) + whenever(repository.syncMetadata(any())) doReturn Result.success(Unit) + whenever(repository.setUpSMS()) doReturn Result.success(SMSConfigResult.DoNothing) + whenever(repository.toggleSMS(true)) doReturn Result.success(Unit) + + syncMetadata.invoke { } + + verify(syncBackgroundJobAction).launchDataSync(SyncPeriod.Every7Days.toSeconds()) + } + + @Test + fun `Should return failure and save state when metadata sync fails`() = + runBlocking { + val exception = Exception("Sync failed") + whenever(repository.currentMetadataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.currentDataSyncPeriod()) doReturn SyncPeriod.Manual + whenever(repository.syncMetadata(any())) doReturn Result.failure(exception) + whenever(repository.setUpSMS()) doReturn Result.success(SMSConfigResult.DoNothing) + whenever(repository.toggleSMS(true)) doReturn Result.success(Unit) + + val result = syncMetadata.invoke { } + + verify(repository).saveMetadataSyncState(false) + assert(result.isFailure) + assert(result.exceptionOrNull() == exception) + } +} diff --git a/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncSettingsTest.kt b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncSettingsTest.kt new file mode 100644 index 00000000000..dbeff9413a3 --- /dev/null +++ b/sync/src/commonTest/kotlin/org/dhis2/mobile/sync/domain/SyncSettingsTest.kt @@ -0,0 +1,57 @@ +package org.dhis2.mobile.sync.domain + +import kotlinx.coroutines.runBlocking +import org.dhis2.mobile.commons.domain.invoke +import org.dhis2.mobile.sync.data.SyncBackgroundJobAction +import org.dhis2.mobile.sync.data.SyncRepository +import org.dhis2.mobile.sync.model.SyncPeriod +import org.junit.Test +import org.mockito.kotlin.doReturnConsecutively +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 + +class SyncSettingsTest { + private val syncRepository: SyncRepository = mock() + private val syncBackgroundJobAction: SyncBackgroundJobAction = mock() + + private val syncSettings = SyncSettings(syncRepository, syncBackgroundJobAction) + + @Test + fun `Should not cancel sync settings if period does not change to manual`() = + runBlocking { + whenever(syncRepository.currentMetadataSyncPeriod()) doReturnConsecutively + listOf( + SyncPeriod.Every24Hour, + SyncPeriod.Every12Hour, + ) + + syncSettings.invoke() + + verify( + syncBackgroundJobAction, + never(), + ).launchMetadataSync(SyncPeriod.Every12Hour.toSeconds()) + verify(syncBackgroundJobAction, never()).cancelSyncSettings() + } + + @Test + fun `Should cancel sync settings if period changes from manual`() = + runBlocking { + whenever(syncRepository.currentMetadataSyncPeriod()) doReturnConsecutively + listOf( + SyncPeriod.Manual, + SyncPeriod.Every12Hour, + ) + + syncSettings.invoke() + + verify( + syncBackgroundJobAction, + times(1), + ).launchMetadataSync(SyncPeriod.Every12Hour.toSeconds()) + verify(syncBackgroundJobAction, times(1)).cancelSyncSettings() + } +} diff --git a/sync/src/desktopMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.desktop.kt b/sync/src/desktopMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.desktop.kt new file mode 100644 index 00000000000..a4d7b8e2cb7 --- /dev/null +++ b/sync/src/desktopMain/kotlin/org/dhis2/mobile/sync/di/SyncModule.desktop.kt @@ -0,0 +1,9 @@ +package org.dhis2.mobile.sync.di + +import org.koin.core.module.Module +import org.koin.dsl.module + +actual val syncModule: Module + get() = + module { + } diff --git a/tracker/build.gradle.kts b/tracker/build.gradle.kts index 39bd0a7dd63..ae21c54eb6d 100644 --- a/tracker/build.gradle.kts +++ b/tracker/build.gradle.kts @@ -1,14 +1,22 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { - kotlin("multiplatform") + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) - id("com.android.library") + alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.kotlin.compose.compiler) } kotlin { - androidTarget { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + androidLibrary { + namespace = "org.dhis2.mobile.tracker" + compileSdk = libs.versions.sdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + androidResources { enable = true } + withHostTestBuilder {}.configure {} + withDeviceTestBuilder {}.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } @@ -24,18 +32,20 @@ kotlin { } commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.ui) - implementation(compose.material3) - api(compose.materialIconsExtended) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.compose.components.resources) + api(libs.compose.material3.iconsExtendend) val designSystem = libs.dhis2.mobile.designsystem implementation("${designSystem.get().group}:${designSystem.get().name}:${designSystem.get().version}") { isChanging = true } + implementation(libs.kotlinx.datetime) implementation(libs.compose.material3.window) - implementation(compose.components.resources) implementation(project(":commonskmm")) + implementation(libs.androidx.compose.paging) // Koin api(libs.koin.core) @@ -47,12 +57,10 @@ kotlin { implementation(kotlin("test")) // Koin Test features implementation(libs.koin.test) - implementation(libs.koin.test.junit5) - implementation(libs.koin.test.junit4) implementation(libs.test.turbine) implementation(libs.test.kotlinCoroutines) implementation(libs.test.mockitoKotlin) - implementation(compose.components.resources) + implementation(libs.compose.components.resources) } androidMain.dependencies { @@ -63,19 +71,22 @@ kotlin { implementation(libs.koin.androidx.compose) implementation(project(":commons")) implementation(project(":dhis2_android_maps")) + compileOnly(libs.androidx.compose.uitooling) } - androidUnitTest.dependencies { - implementation(kotlin("test")) - implementation(libs.junit.jupiter) - implementation(libs.test.turbine) - implementation(libs.test.kotlinCoroutines) - implementation(libs.test.mockitoKotlin) + getByName("androidHostTest") { + dependencies { + implementation(kotlin("test")) + implementation(libs.junit.jupiter) + implementation(libs.test.turbine) + implementation(libs.test.kotlinCoroutines) + implementation(libs.test.mockitoKotlin) + } } val desktopMain by getting { dependencies { - implementation(compose.desktop.common) + implementation(libs.compose.desktop.common) } } } @@ -87,35 +98,6 @@ compose.resources { generateResClass = always } -android { - namespace = "org.dhis2.mobile.tracker" - compileSdk = libs.versions.sdk.get().toInt() - - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - sourceSets["main"].res.srcDirs("src/androidMain/res") - - defaultConfig { - minSdk = libs.versions.minSdk.get().toInt() - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - compileOptions { - isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - dependencies { - coreLibraryDesugaring(libs.desugar) - } -} - - - dependencies { - testImplementation(libs.junit.jupiter) - debugImplementation(libs.androidx.compose.preview) - debugImplementation(libs.androidx.compose.uitooling) -} \ No newline at end of file + coreLibraryDesugaring(libs.desugar) +} diff --git a/tracker/src/androidUnitTest/kotlin/org/dhis2/tracker/relationships/AndroidRelationshipFakeModels.kt b/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/relationships/AndroidRelationshipFakeModels.kt similarity index 100% rename from tracker/src/androidUnitTest/kotlin/org/dhis2/tracker/relationships/AndroidRelationshipFakeModels.kt rename to tracker/src/androidHostTest/kotlin/org/dhis2/tracker/relationships/AndroidRelationshipFakeModels.kt diff --git a/tracker/src/androidUnitTest/kotlin/org/dhis2/tracker/relationships/data/TrackerRelationshipsRepositoryTest.kt b/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/relationships/data/TrackerRelationshipsRepositoryTest.kt similarity index 100% rename from tracker/src/androidUnitTest/kotlin/org/dhis2/tracker/relationships/data/TrackerRelationshipsRepositoryTest.kt rename to tracker/src/androidHostTest/kotlin/org/dhis2/tracker/relationships/data/TrackerRelationshipsRepositoryTest.kt diff --git a/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImplTest.kt b/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImplTest.kt new file mode 100644 index 00000000000..8fe4610f73f --- /dev/null +++ b/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImplTest.kt @@ -0,0 +1,470 @@ +package org.dhis2.tracker.search.data + +import kotlinx.coroutines.test.runTest +import org.dhis2.commons.filters.data.FilterPresenter +import org.dhis2.tracker.data.ProfilePictureProvider +import org.dhis2.tracker.search.model.SearchOperator +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl +import org.hisp.dhis.android.core.common.ObjectWithUid +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute +import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttribute +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchCollectionRepository +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +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.verify +import org.mockito.kotlin.whenever + +class SearchTrackedEntityRepositoryImplTest { + private lateinit var repository: SearchTrackedEntityRepositoryImpl + + private val d2: D2 = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + private val filterPresenter: FilterPresenter = mock() + private val profilePictureProvider: ProfilePictureProvider = mock() + private val teType = "personType" + private val programUid = "programUid" + + @Before + fun setup() { + repository = + SearchTrackedEntityRepositoryImpl( + d2 = d2, + filterPresenter = filterPresenter, + profilePictureProvider = profilePictureProvider, + ) + } + + @Test + fun `isTETypeAttribute should return true when attribute belongs to teType`() = + runTest { + // Given + val attributeUid = "attr1" + val mockOneRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType), + ) doReturn mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType) + .byTrackedEntityAttributeUid(), + ) doReturn mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType) + .byTrackedEntityAttributeUid() + .eq(attributeUid), + ) doReturn mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType) + .byTrackedEntityAttributeUid() + .eq(attributeUid) + .one(), + ) doReturn mockOneRepository + + whenever(mockOneRepository.blockingExists()) doReturn true + + // When + val result = repository.isTETypeAttribute(teType, attributeUid) + + // Then + assertTrue(result) + } + + @Test + fun `isTETypeAttribute should return false when attribute does not belong to teType`() = + runTest { + // Given + val attributeUid = "attr1" + val mockOneRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType), + ) doReturn mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType) + .byTrackedEntityAttributeUid(), + ) doReturn mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType) + .byTrackedEntityAttributeUid() + .eq(attributeUid), + ) doReturn mock() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType) + .byTrackedEntityAttributeUid() + .eq(attributeUid) + .one(), + ) doReturn mockOneRepository + + whenever(mockOneRepository.blockingExists()) doReturn false + + // When + val result = repository.isTETypeAttribute(teType, attributeUid) + + // Then + assertFalse(result) + } + + @Test + fun `getTEAttribute should return unique attribute properties`() = + runTest { + // Given + val attributeUid = "uniqueAttr" + val attribute = + TrackedEntityAttribute + .builder() + .uid(attributeUid) + .unique(true) + .optionSet(null) + .build() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid(attributeUid) + .blockingGet(), + ) doReturn attribute + + // When + val result = repository.getTEAttribute(attributeUid) + + // Then + assertTrue(result.isUnique) + assertFalse(result.isOptionSet) + } + + @Test + fun `getTEAttribute should return option set attribute properties`() = + runTest { + // Given + val attributeUid = "optionSetAttr" + + val attribute = + TrackedEntityAttribute + .builder() + .uid(attributeUid) + .unique(false) + .optionSet( + ObjectWithUid + .create("optionSetUid"), + ).build() + + whenever( + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid(attributeUid) + .blockingGet(), + ) doReturn attribute + + // When + val result = repository.getTEAttribute(attributeUid) + + // Then + assertFalse(result.isUnique) + assertTrue(result.isOptionSet) + } + + @Test + fun `addToQuery should add filter with multiple values using in`() = + runTest { + // Given + val dataId = "attr1" + val dataValues = listOf("value1", "value2", "value3") + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + // Initialize query first + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.addToQuery( + dataId = dataId, + dataValues = dataValues, + searchOperator = null, + ) + + // Then - verify the filter was applied + verify(mockQuery).byFilter(dataId) + } + + @Test + fun `addToQuery should add filter with exact match for unique attribute`() = + runTest { + // Given + val dataId = "uniqueAttr" + val dataValues = listOf("exactValue") + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + // Initialize query first + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.addToQuery( + dataId = dataId, + dataValues = dataValues, + searchOperator = SearchOperator.EQ, + ) + + // Then - verify the filter was applied + verify(mockQuery).byFilter(dataId) + } + + @Test + fun `addToQuery should add filter with exact match for option set attribute`() = + runTest { + // Given + val dataId = "optionSetAttr" + val dataValues = listOf("optionValue") + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + // Initialize query first + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.addToQuery( + dataId = dataId, + dataValues = dataValues, + searchOperator = SearchOperator.EQ, + ) + + // Then - verify the filter was applied + verify(mockQuery).byFilter(dataId) + } + + @Test + fun `addToQuery should add filter with like for regular attribute`() = + runTest { + // Given + val dataId = "regularAttr" + val dataValues = listOf("searchValue") + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + // Initialize query first + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.addToQuery( + dataId = dataId, + dataValues = dataValues, + searchOperator = SearchOperator.LIKE, + ) + + // Then - verify the filter was applied + verify(mockQuery).byFilter(dataId) + } + + @Test + fun `addToQuery should do nothing when dataValues is null`() = + runTest { + // Given + val dataId = "attr1" + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.addToQuery( + dataId = dataId, + dataValues = null, + searchOperator = null, + ) + + // Then - byFilter should never be called, query remains unchanged + verify(mockQuery, Mockito.never()).byFilter(any()) + } + + @Test + fun `addToQuery should do nothing when dataValues is empty`() = + runTest { + // Given + val dataId = "attr1" + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.addToQuery( + dataId = dataId, + dataValues = emptyList(), + searchOperator = null, + ) + + // Then - byFilter should never be called, query remains unchanged + verify(mockQuery, Mockito.never()).byFilter(any()) + } + + @Test + fun `addFiltersToQuery should initialize query with filter presenter`() = + runTest { + // Given + val mockQuery: TrackedEntitySearchCollectionRepository = mock() + whenever(filterPresenter.filteredTrackedEntityInstances(programUid, teType)) doReturn mockQuery + + // When + repository.addFiltersToQuery(programUid, teType) + + // Then + verify(filterPresenter).filteredTrackedEntityInstances(programUid, teType) + } + + @Test + fun `excludeValuesFromQuery should add exclude filter`() = + runTest { + // Given + val excludeValues = listOf("uid1", "uid2", "uid3") + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.excludeValuesFromQuery(excludeValues) + + // Then + verify(mockQuery).excludeUids() + } + + @Test + fun `fetchResults should return offline only when offline or has state filters`() = + runTest { + // Given + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + val result = + repository.fetchResults( + isOnline = false, + hasStateFilters = false, + allowCache = true, + selectedProgram = programUid, + ) + + // Then + assertNotNull(result) + verify(mockQuery).allowOnlineCache() + } + + @Test + fun `fetchResults should return offline first when online and no state filters`() = + runTest { + // Given + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + val result = + repository.fetchResults( + isOnline = true, + hasStateFilters = false, + allowCache = false, + selectedProgram = programUid, + ) + + // Then + assertNotNull(result) + verify(mockQuery).allowOnlineCache() + } + + @Test + fun `fetchResults should use allowCache parameter correctly`() = + runTest { + // Given + val mockQuery: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + + whenever(filterPresenter.filteredTrackedEntityInstances(any(), any())) doReturn mockQuery + + repository.addFiltersToQuery(programUid, teType) + + // When + repository.fetchResults( + isOnline = true, + hasStateFilters = false, + allowCache = true, + selectedProgram = programUid, + ) + + // Then + verify(mockQuery).allowOnlineCache() + } + + @Test(expected = IllegalStateException::class) + fun `fetchResults should throw exception when query not initialized`() = + runTest { + // Given - no query initialized + + // When + repository.fetchResults( + isOnline = false, + hasStateFilters = false, + allowCache = true, + selectedProgram = programUid, + ) + + // Then - exception is thrown + } +} diff --git a/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/OptionSetRepositoryImpl.kt b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/OptionSetRepositoryImpl.kt new file mode 100644 index 00000000000..533b3056919 --- /dev/null +++ b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/OptionSetRepositoryImpl.kt @@ -0,0 +1,73 @@ +package org.dhis2.tracker.search.data + +import androidx.paging.PagingData +import androidx.paging.map +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.commons.error.DomainErrorMapper +import org.dhis2.tracker.input.ui.state.TrackerOptionItem +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +import org.hisp.dhis.android.core.maintenance.D2Error + +/** + * Android implementation of OptionSetRepository using DHIS2 Android SDK. + */ +class OptionSetRepositoryImpl( + private val d2: D2, + private val domainErrorMapper: DomainErrorMapper, +) : OptionSetRepository { + /** + * Fetches paginated options for a given option set. + * @param optionSetUid The unique identifier of the option set + * @param pageSize Number of items per page + * @param searchQuery Optional search query to filter options + * @return Flow of paginated option items. The `flow { }` builder creates a cold (lazy) flow that + * will be executed when collected. The code inside runs in the collector's coroutine context. + */ + override fun getOptions( + optionSetUid: String, + pageSize: Int, + searchQuery: String?, + ): Flow> = + flow { + try { + var query = + d2 + .optionModule() + .options() + .byOptionSetUid() + .eq(optionSetUid) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) + + // Apply search filter if provided + searchQuery?.takeIf { it.isNotBlank() }?.let { search -> + query = query.byDisplayName().like("%$search%") + } + + val pagingFlow = + query + .getPagingData(pageSize) + .map { pagingData -> + pagingData.map { option -> + TrackerOptionItem( + code = option.code() ?: "", + displayName = option.displayName() ?: "", + ) + } + } + + emitAll(pagingFlow) + } catch (e: CancellationException) { + // Rethrow CancellationException to properly cancel the coroutine + } catch (d2Error: D2Error) { + throw domainErrorMapper.mapToDomainError(d2Error) + } catch (e: Exception) { + throw DomainError.UnexpectedError(e.message ?: "Unknown error fetching options") + } + } +} diff --git a/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchParametersRepositoryImpl.kt b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchParametersRepositoryImpl.kt new file mode 100644 index 00000000000..45b2145bed7 --- /dev/null +++ b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchParametersRepositoryImpl.kt @@ -0,0 +1,298 @@ +package org.dhis2.tracker.search.data + +import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.error.DomainErrorMapper +import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.search.model.SearchOperator +import org.dhis2.tracker.search.model.SearchParameterModel +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +import org.hisp.dhis.android.core.arch.repositories.scope.internal.TrackerSearchOperator +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.common.ValueTypeRenderingType +import org.hisp.dhis.android.core.maintenance.D2Error +import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute +import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttribute + +class SearchParametersRepositoryImpl( + val d2: D2, + val customIntentRepository: CustomIntentRepository, + val domainErrorMapper: DomainErrorMapper, +) : SearchParametersRepository { + override suspend fun getSearchParametersByProgram(programUid: String): List = + try { + // this is the attribute and its properties related to a specific program + val programTrackedEntityAttributes = + getSearchableProgramTrackedEntityAttributes(programUid) + programTrackedEntityAttributes.mapNotNull { programTrackedEntityAttribute -> + + // Now, we get the TrackedEntityAttribute with all the info + val trackedEntityAttribute = + getTrackedEntityAttributeByProgram(programTrackedEntityAttribute) + trackedEntityAttribute?.let { attribute -> + val renderingType = programTrackedEntityAttribute.renderType()?.mobile()?.type() + val customIntent = + customIntentRepository.getCustomIntent( + attribute.uid(), + orgUnitUid = null, + actionType = CustomIntentActionTypeModel.SEARCH, + ) + + SearchParameterModel( + uid = attribute.uid(), + label = attribute.displayFormName() ?: "", + inputType = + getInputType( + attribute = attribute, + isCustomIntent = customIntent != null, + valueTypeRenderingType = renderingType, + ), + optionSet = attribute.optionSet()?.uid(), + customIntentUid = customIntent?.uid, + minCharactersToSearch = attribute.minCharactersToSearch(), + searchOperator = getSearchOperator(attribute), + isUnique = attribute.unique() ?: false, + ) + } + } + } catch (d2Error: D2Error) { + throw domainErrorMapper.mapToDomainError(d2Error) + } + + override suspend fun getSearchParametersByTrackedEntityType(trackedEntityTypeUid: String): List = + try { + val trackedEntityAttributes = + getTrackedEntityAttributesByTrackedEntityType(trackedEntityTypeUid) + trackedEntityAttributes.mapNotNull { trackedEntityTypeAttribute -> + // Now, we get the TrackedEntityAttribute with all the info + val trackedEntityAttribute = + getTrackedEntityAttributeByTrackedEntityType(trackedEntityTypeAttribute) + trackedEntityAttribute?.let { attribute -> + SearchParameterModel( + uid = attribute.uid(), + label = attribute.displayFormName() ?: "", + inputType = + getInputType( + attribute = attribute, + isCustomIntent = false, + valueTypeRenderingType = null, + ), + optionSet = attribute.optionSet()?.uid(), + customIntentUid = null, + minCharactersToSearch = attribute.minCharactersToSearch(), + searchOperator = getSearchOperator(attribute), + isUnique = attribute.unique() ?: false, + ) + } + } + } catch (d2Error: D2Error) { + throw domainErrorMapper.mapToDomainError(d2Error) + } + + private fun getTrackedEntityAttributeByTrackedEntityType( + trackedEntityTypeAttribute: TrackedEntityTypeAttribute, + ): TrackedEntityAttribute? = + trackedEntityTypeAttribute.trackedEntityAttribute()?.uid()?.let { uid -> + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid(uid) + .blockingGet() + } + + private fun getTrackedEntityAttributesByTrackedEntityType(trackedEntityTypeUid: String): List = + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(trackedEntityTypeUid) + .bySearchable() + .isTrue + .blockingGet() + + private fun getTrackedEntityAttributeByProgram(programTrackedEntityAttribute: ProgramTrackedEntityAttribute): TrackedEntityAttribute? = + programTrackedEntityAttribute.trackedEntityAttribute()?.uid()?.let { uid -> + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid(uid) + .blockingGet() + } + + private fun getSearchableProgramTrackedEntityAttributes(programUid: String): List = + d2 + .programModule() + .programTrackedEntityAttributes() + .withRenderType() + .byProgram() + .eq(programUid) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) + .blockingGet() + .filter { programAttribute -> + val isSearchable = programAttribute.searchable()!! + val isUnique = + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid(programAttribute.trackedEntityAttribute()!!.uid()) + .blockingGet() + ?.unique() == true + isSearchable || isUnique + } + + private fun getInputType( + attribute: TrackedEntityAttribute, + isCustomIntent: Boolean, + valueTypeRenderingType: ValueTypeRenderingType?, + ): TrackerInputType = + when { + attribute.optionSet()?.uid() != null && attribute.valueType() != ValueType.MULTI_TEXT + -> getInputTypeForOptionSetByRenderingType(valueTypeRenderingType) + + isCustomIntent -> TrackerInputType.CUSTOM_INTENT + + else -> getInputTypeByValueType(attribute.valueType(), valueTypeRenderingType) + } + + private fun getInputTypeByValueType( + valueType: ValueType?, + valueTypeRenderingType: ValueTypeRenderingType?, + ): TrackerInputType = + when (valueType) { + ValueType.TEXT -> { + when (valueTypeRenderingType) { + ValueTypeRenderingType.QR_CODE, ValueTypeRenderingType.GS1_DATAMATRIX -> TrackerInputType.QR_CODE + ValueTypeRenderingType.BAR_CODE -> TrackerInputType.BAR_CODE + else -> TrackerInputType.TEXT + } + } + + ValueType.INTEGER_POSITIVE -> TrackerInputType.INTEGER_POSITIVE + ValueType.INTEGER_ZERO_OR_POSITIVE -> TrackerInputType.INTEGER_ZERO_OR_POSITIVE + ValueType.PERCENTAGE -> TrackerInputType.PERCENTAGE + ValueType.NUMBER -> TrackerInputType.NUMBER + ValueType.INTEGER_NEGATIVE -> TrackerInputType.INTEGER_NEGATIVE + ValueType.LONG_TEXT -> TrackerInputType.LONG_TEXT + ValueType.INTEGER -> TrackerInputType.INTEGER + ValueType.ORGANISATION_UNIT -> TrackerInputType.ORGANISATION_UNIT + ValueType.EMAIL -> TrackerInputType.EMAIL + ValueType.BOOLEAN -> { + when (valueTypeRenderingType) { + ValueTypeRenderingType.HORIZONTAL_CHECKBOXES -> TrackerInputType.HORIZONTAL_CHECKBOXES + ValueTypeRenderingType.VERTICAL_CHECKBOXES -> TrackerInputType.VERTICAL_CHECKBOXES + ValueTypeRenderingType.HORIZONTAL_RADIOBUTTONS -> TrackerInputType.HORIZONTAL_RADIOBUTTONS + else -> TrackerInputType.VERTICAL_RADIOBUTTONS + } + } + + ValueType.TRUE_ONLY -> { + when (valueTypeRenderingType) { + ValueTypeRenderingType.TOGGLE -> TrackerInputType.YES_ONLY_SWITCH + else -> TrackerInputType.YES_ONLY_CHECKBOX + } + } + + ValueType.PHONE_NUMBER -> TrackerInputType.PHONE_NUMBER + ValueType.DATE -> TrackerInputType.DATE + ValueType.DATETIME -> TrackerInputType.DATE_TIME + ValueType.TIME -> TrackerInputType.TIME + + ValueType.AGE -> TrackerInputType.AGE + ValueType.MULTI_TEXT -> TrackerInputType.MULTI_SELECTION + + ValueType.USERNAME, + ValueType.LETTER, + ValueType.UNIT_INTERVAL, + ValueType.TRACKER_ASSOCIATE, + ValueType.REFERENCE, + ValueType.FILE_RESOURCE, + ValueType.GEOJSON, + ValueType.URL, + null, + -> TrackerInputType.NOT_SUPPORTED + + ValueType.COORDINATE -> TrackerInputType.COORDINATES + ValueType.IMAGE -> TrackerInputType.IMAGE + } + + private fun getInputTypeForOptionSetByRenderingType(valueTypeRenderingType: ValueTypeRenderingType?): TrackerInputType = + when (valueTypeRenderingType) { + ValueTypeRenderingType.HORIZONTAL_RADIOBUTTONS -> TrackerInputType.HORIZONTAL_RADIOBUTTONS + ValueTypeRenderingType.VERTICAL_RADIOBUTTONS -> TrackerInputType.VERTICAL_RADIOBUTTONS + ValueTypeRenderingType.HORIZONTAL_CHECKBOXES -> TrackerInputType.HORIZONTAL_CHECKBOXES + ValueTypeRenderingType.VERTICAL_CHECKBOXES -> TrackerInputType.VERTICAL_CHECKBOXES + else -> TrackerInputType.DROPDOWN + } + + private fun getSearchOperator(attribute: TrackedEntityAttribute): SearchOperator? { + val mainOperators = listOf(SearchOperator.LIKE, SearchOperator.SW, SearchOperator.EQ) + val blockedOperators = + attribute + .blockedSearchOperators() + ?.mapNotNull { sdkOperator -> + sdkOperator.toSearchOperator() + } ?: emptyList() + val preferredOperator: SearchOperator? = + attribute.preferredSearchOperator()?.toSearchOperator() + val valueType = attribute.valueType() + val hasOptionSet = attribute.optionSet() != null + val isUnique = attribute.unique() == true + + val alwaysEqValueTypes = + listOf( + ValueType.BOOLEAN, + ValueType.TRUE_ONLY, + ValueType.AGE, + ValueType.ORGANISATION_UNIT, + ) + + val preferredOperatorValueTypes = + listOf( + ValueType.NUMBER, + ValueType.INTEGER, + ValueType.INTEGER_POSITIVE, + ValueType.INTEGER_NEGATIVE, + ValueType.INTEGER_ZERO_OR_POSITIVE, + ValueType.DATE, + ValueType.DATETIME, + ValueType.TIME, + ValueType.TEXT, + ValueType.LONG_TEXT, + ValueType.EMAIL, + ValueType.PHONE_NUMBER, + ValueType.PERCENTAGE, + ) + + return when { + isUnique || + (hasOptionSet && valueType != ValueType.MULTI_TEXT) || + valueType in alwaysEqValueTypes -> { + SearchOperator.EQ + } + + valueType == ValueType.MULTI_TEXT -> { + mainOperators.firstOrNull { it !in blockedOperators } + } + + valueType in preferredOperatorValueTypes -> { + if (preferredOperator != null && preferredOperator !in blockedOperators) { + preferredOperator + } else { + mainOperators.firstOrNull { it !in blockedOperators } + } + } + + else -> null + } + } + + private fun TrackerSearchOperator.toSearchOperator() = + when (this) { + TrackerSearchOperator.LIKE -> SearchOperator.LIKE + TrackerSearchOperator.SW -> SearchOperator.SW + TrackerSearchOperator.EW -> SearchOperator.EW + TrackerSearchOperator.EQ -> SearchOperator.EQ + } +} diff --git a/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImpl.kt b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImpl.kt new file mode 100644 index 00000000000..0c1306a445f --- /dev/null +++ b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImpl.kt @@ -0,0 +1,599 @@ +package org.dhis2.tracker.search.data + +import androidx.paging.PagingData +import androidx.paging.map +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.dhis2.commons.filters.data.FilterPresenter +import org.dhis2.mobile.commons.extensions.getTodayAsInstant +import org.dhis2.mobile.commons.extensions.toKtxInstant +import org.dhis2.tracker.data.ProfilePictureProvider +import org.dhis2.tracker.relationships.model.RelationshipDirection +import org.dhis2.tracker.relationships.model.RelationshipGeometry +import org.dhis2.tracker.relationships.model.RelationshipModel +import org.dhis2.tracker.relationships.model.RelationshipOwnerType +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.SearchOperator +import org.dhis2.tracker.search.model.SearchTrackedEntityAttribute +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +import org.hisp.dhis.android.core.common.State +import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.hisp.dhis.android.core.event.EventStatus +import org.hisp.dhis.android.core.relationship.RelationshipItem +import org.hisp.dhis.android.core.relationship.RelationshipItemTrackedEntityInstance +import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue +import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchCollectionRepository +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemHelper.toTrackedEntityInstance +import kotlin.time.Instant + +class SearchTrackedEntityRepositoryImpl( + private val d2: D2, + private val filterPresenter: FilterPresenter, + private val profilePictureProvider: ProfilePictureProvider, +) : SearchTrackedEntityRepository { + private var trackedEntityInstanceQuery: TrackedEntitySearchCollectionRepository? = null + + // Checks whether the dataId is an attribute of the teType + override suspend fun isTETypeAttribute( + teType: String, + dataId: String, + ): Boolean = + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teType) + .byTrackedEntityAttributeUid() + .eq(dataId) + .one() + .blockingExists() + + override suspend fun getTEAttribute(dataId: String): SearchTrackedEntityAttribute { + val attribute = + d2 + .trackedEntityModule() + .trackedEntityAttributes() + .uid(dataId) + .blockingGet() + return SearchTrackedEntityAttribute( + isUnique = attribute?.unique() == true, + isOptionSet = (attribute?.optionSet() != null), + ) + } + + override suspend fun addToQuery( + dataId: String, + dataValues: List?, + searchOperator: SearchOperator?, + ) { + if (dataValues.isNullOrEmpty()) return + + trackedEntityInstanceQuery = + if (dataValues.size > 1) { + // return any tracked entities with attributes that match the values in the list + trackedEntityInstanceQuery?.byFilter(dataId)?.`in`(dataValues) + } else { + when (searchOperator) { + SearchOperator.LIKE -> { + trackedEntityInstanceQuery?.byFilter(dataId)?.like(dataValues[0]) + } + + SearchOperator.SW -> { + trackedEntityInstanceQuery?.byFilter(dataId)?.sw(dataValues[0]) + } + + SearchOperator.EW -> { + trackedEntityInstanceQuery?.byFilter(dataId)?.ew(dataValues[0]) + } + + SearchOperator.EQ -> { + trackedEntityInstanceQuery?.byFilter(dataId)?.eq(dataValues[0]) + } + + else -> trackedEntityInstanceQuery?.byFilter(dataId)?.like(dataValues[0]) + } + } + } + + override suspend fun addFiltersToQuery( + program: String?, + teType: String, + ) { + trackedEntityInstanceQuery = + filterPresenter.filteredTrackedEntityInstances( + program, + teType, + ) + } + + override suspend fun excludeValuesFromQuery(excludeValues: List) { + trackedEntityInstanceQuery = trackedEntityInstanceQuery?.excludeUids()?.`in`(excludeValues) + } + + override fun fetchResults( + isOnline: Boolean, + hasStateFilters: Boolean, + allowCache: Boolean, + selectedProgram: String?, + ): Flow> { + // if the device is online and there are no state filters, we can use online cache + val pagerFlow = + if (isOnline && !hasStateFilters) { + trackedEntityInstanceQuery?.allowOnlineCache()?.eq(allowCache)?.offlineFirst() + } else { + // otherwise we use offline only + trackedEntityInstanceQuery?.allowOnlineCache()?.eq(allowCache)?.offlineOnly() + } + val displayOrgUnit = shouldDisplayOrgUnit(selectedProgram) + // map the paging data to TrackedEntitySearchItemResult + return pagerFlow?.getPagingData(10)?.map { pagingData -> + pagingData.map { item -> + mapItemToDomainResult(item, selectedProgram, hasStateFilters, displayOrgUnit, isOnline) + } + } ?: throw IllegalStateException("TrackedEntityInstanceQuery is not initialized") + } + + override suspend fun fetchImmediateResults( + isOnline: Boolean, + hasStateFilters: Boolean, + selectedProgram: String?, + ): List { + // if the device is online and there are no state filters, we can use online cache + val results = + if (isOnline && !hasStateFilters) { + trackedEntityInstanceQuery?.offlineFirst()?.blockingGet() + } else { + // otherwise we use offline only + trackedEntityInstanceQuery?.offlineOnly()?.blockingGet() + } + val displayOrgUnit = shouldDisplayOrgUnit(selectedProgram) + return results?.map { item -> + mapItemToDomainResult(item, selectedProgram, hasStateFilters, displayOrgUnit, isOnline) + } ?: throw IllegalStateException("TrackedEntityInstanceQuery is not initialized") + } + + private fun mapItemToDomainResult( + item: TrackedEntitySearchItem, + selectedProgram: String?, + hasStateFilters: Boolean, + displayOrgUnit: Boolean, + deviceIsOnline: Boolean, + ): TrackedEntitySearchItemResult { + val dbTei = getDatabaseTei(item) + val selectedEnrollment = getSelectedEnrollment(dbTei, selectedProgram) + val isOnline = + if (!deviceIsOnline) { + false + } else { + !( + !item.isOnline && !hasStateFilters && + (dbTei.deleted() == false || dbTei.deleted() == null) + ) + } + val enrollments = getTeiEnrollments(item.uid) + val overDueDate: Instant? = getOverdueDate(enrollments, selectedProgram) + val relationships = + if (dbTei.aggregatedSyncState() != State.RELATIONSHIP && selectedProgram?.isNotEmpty() == true) { + getRelationShips(item.uid, selectedProgram, item.type.uid()) + } else { + null + } + + val enrolledOrgUnit = + if (selectedEnrollment != null) { + orgUnitName(selectedEnrollment.orgUnit) + } else { + orgUnitName(item.organisationUnit) + } + + val ownerOrgUnit = item.programOwners?.firstOrNull()?.ownerOrgUnit + val ownerOrgUnitName = + if (ownerOrgUnit != selectedEnrollment?.orgUnit) { + orgUnitName(ownerOrgUnit) + } else { + null + } + + val profilePicture = profilePictureProvider.invoke(dbTei, selectedProgram) + val enrolledPrograms = getEnrolledPrograms(enrollments) + return item.toTrackedEntitySearchItemResult( + selectedEnrollment = selectedEnrollment, + isOnline = isOnline, + overDueDate = overDueDate, + ownerOrgUnit = ownerOrgUnitName, + enrollmentOrgUnit = enrolledOrgUnit, + shouldDisplayOrgUnit = displayOrgUnit, + profilePicture = profilePicture, + enrollments = enrollments, + enrolledPrograms = enrolledPrograms, + relationships = relationships, + ) + } + + private fun getEnrolledPrograms(enrollments: List): List? { + val programUids = + enrollments + .mapNotNull { it.program } + .distinct() + if (programUids.isEmpty()) { + return null + } + + val programs = + d2 + .programModule() + .programs() + .byUid() + .`in`(programUids) + .blockingGet() + if (programs.isEmpty()) { + return null + } + + val programsByUid = programs.associateBy { it.uid() } + val enrolledPrograms = + enrollments.mapNotNull { enrollment -> + val programUid = enrollment.program ?: return@mapNotNull null + val program = programsByUid[programUid] ?: return@mapNotNull null + DomainProgram( + uid = program.uid(), + displayName = program.displayName() ?: "", + style = + DomainObjectStyle( + icon = program.style()?.icon(), + color = program.style()?.color(), + ), + ) + } + return enrolledPrograms.ifEmpty { null } + } + + private fun shouldDisplayOrgUnit(selectedProgram: String?): Boolean = + if (selectedProgram != null) { + d2 + .organisationUnitModule() + .organisationUnits() + .byProgramUids(listOf(selectedProgram)) + .blockingCount() > 1 + } else { + false + } + + private fun orgUnitName(orgUnit: String?): String? = + orgUnit?.let { + d2 + .organisationUnitModule() + .organisationUnits() + .uid(it) + .blockingGet() + ?.displayName() + } + + private fun getRelationShips( + teiUid: String, + selectedProgram: String, + teType: String, + ): List { + val relationships = + d2.relationshipModule().relationships().getByItem( + RelationshipItem + .builder() + .trackedEntityInstance( + RelationshipItemTrackedEntityInstance + .builder() + .trackedEntityInstance(teiUid) + .build(), + ).build(), + ) + return relationships.mapNotNull { relationship -> + relationship + .from() + ?.trackedEntityInstance() + ?.let { trackedEntityInstanceFromRelationship -> + var relationshipTeiUid: String + + var direction: RelationshipDirection + if (trackedEntityInstanceFromRelationship.trackedEntityInstance() == teiUid) { + relationshipTeiUid = + trackedEntityInstanceFromRelationship.trackedEntityInstance() + direction = RelationshipDirection.FROM + } else { + relationshipTeiUid = + relationship.to()?.trackedEntityInstance()?.trackedEntityInstance() + ?: "" + direction = RelationshipDirection.TO + } + + val fromTeiUid = + relationship.from()?.trackedEntityInstance()?.trackedEntityInstance() + val toTeiUid = + relationship.to()?.trackedEntityInstance()?.trackedEntityInstance() + + val fromTei = + d2 + .trackedEntityModule() + .trackedEntityInstances() + .uid(fromTeiUid) + .blockingGet() + val toTei = + d2 + .trackedEntityModule() + .trackedEntityInstances() + .uid(toTeiUid) + .blockingGet() + val fromAttributes: List> = + getTrackedEntityAttributesForRelationship( + fromTeiUid, + selectedProgram, + teType, + ).mapNotNull { trackedEntityAttributeValue -> + val attribute = trackedEntityAttributeValue.trackedEntityAttribute() + val value = trackedEntityAttributeValue.value() + attribute?.let { attribute -> + value?.let { v -> + Pair(attribute, v) + } + } + } + val toAttributes: List> = + getTrackedEntityAttributesForRelationship( + toTeiUid, + selectedProgram, + teType, + ).mapNotNull { trackedEntityAttributeValue -> + val attribute = trackedEntityAttributeValue.trackedEntityAttribute() + val value = trackedEntityAttributeValue.value() + if (attribute != null && value != null) { + Pair(attribute, value) + } else { + null + } + } + + val fromGeometry = + fromTei?.geometry()?.let { geometry -> + RelationshipGeometry( + featureType = geometry.type()?.name, + coordinates = geometry.coordinates(), + ) + } + val toGeometry = + toTei?.geometry()?.let { geometry -> + RelationshipGeometry( + featureType = geometry.type()?.name, + coordinates = geometry.coordinates(), + ) + } + RelationshipModel( + relationshipUid = relationship.uid()!!, + relationshipState = relationship.syncState()?.name ?: State.TO_POST.name, + fromGeometry = fromGeometry, + toGeometry = toGeometry, + direction = direction, + ownerUid = relationshipTeiUid, + ownerType = RelationshipOwnerType.TEI, + fromValues = fromAttributes, + toValues = toAttributes, + fromImage = null, + toImage = null, + fromDefaultImageResource = -1, + toDefaultImageResource = -1, + ownerStyleIcon = null, + ownerStyleColor = null, + canBeOpened = true, + toLastUpdated = null, + fromLastUpdated = null, + toDescription = null, + fromDescription = null, + ) + } + } + } + + private fun getTrackedEntityAttributesForRelationship( + uid: String?, + selectedProgram: String, + teType: String, + ): List { + val attributeValues = + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .byTrackedEntityInstance() + .eq(uid) + .byTrackedEntityAttribute() + .`in`(getProgramTrackedEntityAttributeUids(selectedProgram)) + .blockingGet() + return attributeValues.ifEmpty { + d2 + .trackedEntityModule() + .trackedEntityAttributeValues() + .byTrackedEntityInstance() + .eq(uid) + .byTrackedEntityAttribute() + .`in`(getTeTypeTrackedEntityAttributeUids(teType)) + .blockingGet() + } + } + + private fun getProgramTrackedEntityAttributeUids(selectedProgram: String): List = + d2 + .programModule() + .programTrackedEntityAttributes() + .byProgram() + .eq(selectedProgram) + .byDisplayInList() + .isTrue + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) + .blockingGet() + .map { it.uid() } + + private fun getTeTypeTrackedEntityAttributeUids(teTypeUid: String): List = + d2 + .trackedEntityModule() + .trackedEntityTypeAttributes() + .byTrackedEntityTypeUid() + .eq(teTypeUid) + .byDisplayInList() + .isTrue + .blockingGet() + .mapNotNull { + it.trackedEntityAttribute()?.uid() + } + + private fun getTeiEnrollments(teiUid: String): List { + val enrollments = + d2 + .enrollmentModule() + .enrollments() + .byTrackedEntityInstance() + .eq(teiUid) + .byDeleted() + .eq(false) + .orderByEnrollmentDate(RepositoryScope.OrderByDirection.DESC) + .blockingGet() + return enrollments.map { it.toDomainEnrollment() } + } + + /** + * Returns the most recent overdue date from events in the given enrollments. + * Returns null if there are no overdue events. + * + * This method checks: + * 1. Events with OVERDUE status + * 2. Events with SCHEDULE status that have passed their due date + * + * Uses kotlinx.datetime exclusively for date comparison (no java.util.Date operations) + */ + private fun getOverdueDate( + enrollments: List, + selectedProgram: String?, + ): Instant? { + if (enrollments.isEmpty()) return null + + val enrollmentUids = enrollments.map { it.uid } + + // Query for OVERDUE events + var overdueEventsQuery = + d2 + .eventModule() + .events() + .byEnrollmentUid() + .`in`(enrollmentUids) + .byStatus() + .eq(EventStatus.OVERDUE) + + // Query for SCHEDULE events that may be overdue + var scheduledEventsQuery = + d2 + .eventModule() + .events() + .byEnrollmentUid() + .`in`(enrollmentUids) + .byStatus() + .eq(EventStatus.SCHEDULE) + + // Filter by program if specified + if (selectedProgram != null) { + overdueEventsQuery = overdueEventsQuery.byProgramUid().eq(selectedProgram) + scheduledEventsQuery = scheduledEventsQuery.byProgramUid().eq(selectedProgram) + } + + // Get events ordered by due date (most recent first) + val overdueEvents = + overdueEventsQuery + .orderByDueDate(RepositoryScope.OrderByDirection.DESC) + .blockingGet() + + val scheduledEvents = + scheduledEventsQuery + .orderByDueDate(RepositoryScope.OrderByDirection.DESC) + .blockingGet() + + // Get today's date using kotlinx.datetime (non-deprecated way) + val today = getTodayAsInstant() + + // Filter events that are actually overdue (due date is before today) + val allOverdueEvents = + buildList { + // Add all OVERDUE events that have a due date before today + overdueEvents.forEach { event -> + event.dueDate()?.let { dueDate -> + val dueDateLocal = dueDate.toKtxInstant() + // Event is overdue if due date is before today (not equal to today) + if (dueDateLocal < today) { + add(event) + } + } + } + + // Add SCHEDULE events that have passed their due date + scheduledEvents.forEach { event -> + event.dueDate()?.let { dueDate -> + val dueDateLocal = dueDate.toKtxInstant() + // Event is overdue if due date is before today (not equal to today) + if (dueDateLocal < today) { + add(event) + } + } + } + } + + // Return the most recent overdue date as Instant, or null if no overdue events + return allOverdueEvents + .mapNotNull { it.dueDate()?.toKtxInstant() } + .maxOrNull() + } + + private fun getDatabaseTei(item: TrackedEntitySearchItem): TrackedEntityInstance { + val teiFromItem = toTrackedEntityInstance(item) + + return if (item.isOnline) { + d2 + .trackedEntityModule() + .trackedEntityInstances() + .uid(item.uid()) + .blockingGet() ?: teiFromItem + } else { + teiFromItem + } + } + + private fun getSelectedEnrollment( + dbTei: TrackedEntityInstance?, + selectedProgram: String?, + ): DomainEnrollment? { + var selectedEnrollment: DomainEnrollment? = null + // SDK mapping from SearchTrackedEntityItem to TrackedEntityInstance, this might not be needed + if (dbTei != null && dbTei.aggregatedSyncState() != State.RELATIONSHIP) { + // if dbTei is not null we will map the db tei instead of the online tei + + var enrollmentRepository = + d2 + .enrollmentModule() + .enrollments() + .byTrackedEntityInstance() + .eq(dbTei.uid()) + if (selectedProgram != null) { + enrollmentRepository = enrollmentRepository.byProgram().eq(selectedProgram) + } + val enrollmentsInProgram = + enrollmentRepository + .orderByEnrollmentDate(RepositoryScope.OrderByDirection.DESC) + .blockingGet() + if (selectedProgram != null && enrollmentsInProgram.isNotEmpty()) { + selectedEnrollment = + enrollmentsInProgram + .firstOrNull { it.status() == EnrollmentStatus.ACTIVE } + ?.toDomainEnrollment() ?: enrollmentsInProgram.firstOrNull()?.toDomainEnrollment() + } + } + return selectedEnrollment + } +} diff --git a/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/TrackedEntitySearchItemMapper.kt b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/TrackedEntitySearchItemMapper.kt new file mode 100644 index 00000000000..7047a9cda58 --- /dev/null +++ b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/TrackedEntitySearchItemMapper.kt @@ -0,0 +1,188 @@ +package org.dhis2.tracker.search.data + +import org.dhis2.mobile.commons.extensions.toKtxInstant +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.relationships.model.RelationshipModel +import org.dhis2.tracker.search.model.DomainEnrollment +import org.dhis2.tracker.search.model.DomainProgram +import org.dhis2.tracker.search.model.EnrollmentStatus +import org.dhis2.tracker.search.model.GeometryFeatureType +import org.dhis2.tracker.search.model.SyncState +import org.dhis2.tracker.search.model.TrackedEntityGeometry +import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.dhis2.tracker.search.model.TrackedEntityTypeAttributeDomain +import org.dhis2.tracker.search.model.TrackedEntityTypeDomain +import org.hisp.dhis.android.core.common.FeatureType +import org.hisp.dhis.android.core.common.Geometry +import org.hisp.dhis.android.core.common.State +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.trackedentity.TrackedEntityType +import org.hisp.dhis.android.core.trackedentity.TrackedEntityTypeAttribute +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItem +import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchItemAttribute +import kotlin.time.Instant +import org.hisp.dhis.android.core.enrollment.EnrollmentStatus as SDKEnrollmentStatus + +fun TrackedEntitySearchItem.toTrackedEntitySearchItemResult( + selectedEnrollment: DomainEnrollment?, + isOnline: Boolean, + overDueDate: Instant?, + ownerOrgUnit: String?, + enrollmentOrgUnit: String?, + shouldDisplayOrgUnit: Boolean, + profilePicture: String?, + enrollments: List?, + enrolledPrograms: List?, + relationships: List?, +): TrackedEntitySearchItemResult = + TrackedEntitySearchItemResult( + uid = this.uid, + created = this.created?.toKtxInstant(), + lastUpdated = this.lastUpdated?.toKtxInstant(), + createdAtClient = this.createdAtClient?.toKtxInstant(), + lastUpdatedAtClient = this.lastUpdatedAtClient?.toKtxInstant(), + ownerOrgUnit = ownerOrgUnit, + enrollmentOrgUnit = enrollmentOrgUnit, + shouldDisplayOrgUnit = shouldDisplayOrgUnit, + geometry = this.geometry.toDomainGeometry(), + syncState = this.syncState?.toSyncState(), + aggregatedSyncState = this.aggregatedSyncState?.toSyncState(), + deleted = this.deleted, + isOnline = isOnline, + teTypeName = this.type.displayName(), + type = this.type.toTrackedEntityType(), + header = this.header, + overDueDate = overDueDate, + selectedEnrollment = selectedEnrollment, + profilePicture = profilePicture, + enrolledPrograms = enrolledPrograms, + defaultTypeIcon = this.type.style()?.icon(), + enrollments = enrollments, + relationships = relationships, + attributeValues = this.attributeValues?.map { it.toTrackedEntitySearchItemAttribute() } ?: emptyList(), + ) + +fun Enrollment.toDomainEnrollment(): DomainEnrollment = + DomainEnrollment( + uid = this.uid(), + orgUnit = this.organisationUnit(), + program = this.program(), + enrollmentDate = this.enrollmentDate()?.toKtxInstant(), + incidentDate = this.incidentDate()?.toKtxInstant(), + completedDate = this.completedDate()?.toKtxInstant(), + followUp = this.followUp() ?: false, + status = + when (this.status()) { + SDKEnrollmentStatus.ACTIVE -> EnrollmentStatus.ACTIVE + SDKEnrollmentStatus.COMPLETED -> EnrollmentStatus.COMPLETED + SDKEnrollmentStatus.CANCELLED -> EnrollmentStatus.CANCELLED + null -> EnrollmentStatus.ACTIVE + }, + trackedEntityInstance = this.trackedEntityInstance(), + ) + +private fun Geometry?.toDomainGeometry(): TrackedEntityGeometry? = + this?.let { + TrackedEntityGeometry( + geometryFeatureType = getGeometryType(it.type()), + coordinates = it.coordinates(), + ) + } + +private fun getGeometryType(type: FeatureType?): GeometryFeatureType = + when (type) { + FeatureType.POINT -> GeometryFeatureType.POINT + FeatureType.MULTI_POLYGON -> GeometryFeatureType.MULTI_POLYGON + FeatureType.POLYGON -> GeometryFeatureType.POLYGON + FeatureType.NONE -> GeometryFeatureType.NONE + FeatureType.SYMBOL -> GeometryFeatureType.SYMBOL + else -> GeometryFeatureType.NONE + } + +private fun State?.toSyncState(): SyncState? = + when (this) { + State.TO_POST -> SyncState.TO_POST + State.TO_UPDATE -> SyncState.TO_UPDATE + State.ERROR -> SyncState.ERROR + State.SYNCED -> SyncState.SYNCED + State.WARNING -> SyncState.WARNING + State.UPLOADING -> SyncState.UPLOADING + State.RELATIONSHIP -> SyncState.RELATIONSHIP + State.SENT_VIA_SMS -> SyncState.SENT_VIA_SMS + State.SYNCED_VIA_SMS -> SyncState.SYNCED_VIA_SMS + null -> null + } + +private fun TrackedEntityType.toTrackedEntityType(): TrackedEntityTypeDomain = + TrackedEntityTypeDomain( + trackedEntityTypeAttributeDomains = this.trackedEntityTypeAttributes()?.map { it.toTrackedEntityTypeAttribute() } ?: emptyList(), + featureType = getGeometryType(this.featureType()), + ) + +private fun TrackedEntityTypeAttribute.toTrackedEntityTypeAttribute(): TrackedEntityTypeAttributeDomain = + TrackedEntityTypeAttributeDomain( + trackedEntityTypeUid = this.trackedEntityType()?.uid(), + trackedEntityAttributeUid = this.trackedEntityAttribute()?.uid(), + displayInList = this.displayInList() ?: false, + mandatory = this.mandatory() ?: false, + searchable = this.searchable() ?: false, + sortOrder = this.sortOrder() ?: 0, + ) + +private fun TrackedEntitySearchItemAttribute.toTrackedEntitySearchItemAttribute(): TrackedEntitySearchItemAttributeDomain = + TrackedEntitySearchItemAttributeDomain( + attribute = this.attribute, + displayName = this.displayName, + displayFormName = this.displayFormName, + value = this.value, + valueType = this.valueType.toTrackerInputType(), + displayInList = this.displayInList, + optionSet = this.optionSet, + ) + +private fun ValueType.toTrackerInputType(): TrackerInputType = + when (this) { + ValueType.TEXT -> TrackerInputType.TEXT + ValueType.LONG_TEXT -> TrackerInputType.LONG_TEXT + ValueType.NUMBER -> TrackerInputType.NUMBER + ValueType.INTEGER -> TrackerInputType.INTEGER + ValueType.INTEGER_POSITIVE -> TrackerInputType.INTEGER_POSITIVE + ValueType.INTEGER_NEGATIVE -> TrackerInputType.INTEGER_NEGATIVE + ValueType.INTEGER_ZERO_OR_POSITIVE -> TrackerInputType.INTEGER_ZERO_OR_POSITIVE + ValueType.BOOLEAN -> TrackerInputType.HORIZONTAL_RADIOBUTTONS + ValueType.TRUE_ONLY -> TrackerInputType.YES_ONLY_CHECKBOX + ValueType.DATE -> TrackerInputType.DATE + ValueType.DATETIME -> TrackerInputType.DATE_TIME + ValueType.TIME -> TrackerInputType.TIME + ValueType.EMAIL -> TrackerInputType.EMAIL + ValueType.PHONE_NUMBER -> TrackerInputType.PHONE_NUMBER + ValueType.PERCENTAGE -> TrackerInputType.PERCENTAGE + ValueType.URL -> TrackerInputType.URL + ValueType.COORDINATE -> TrackerInputType.NOT_SUPPORTED + ValueType.AGE -> TrackerInputType.AGE + ValueType.ORGANISATION_UNIT -> TrackerInputType.ORGANISATION_UNIT + ValueType.UNIT_INTERVAL -> TrackerInputType.UNIT_INTERVAL + ValueType.FILE_RESOURCE -> TrackerInputType.NOT_SUPPORTED + ValueType.IMAGE -> TrackerInputType.NOT_SUPPORTED + ValueType.LETTER -> TrackerInputType.LETTER + ValueType.USERNAME -> TrackerInputType.TEXT + ValueType.TRACKER_ASSOCIATE -> TrackerInputType.NOT_SUPPORTED + ValueType.REFERENCE -> TrackerInputType.NOT_SUPPORTED + ValueType.GEOJSON -> TrackerInputType.NOT_SUPPORTED + ValueType.MULTI_TEXT -> TrackerInputType.MULTI_SELECTION + } + +fun SyncState.toSDKState(): State = + when (this) { + SyncState.SYNCED -> State.SYNCED + SyncState.TO_POST -> State.TO_POST + SyncState.TO_UPDATE -> State.TO_UPDATE + SyncState.ERROR -> State.ERROR + SyncState.WARNING -> State.WARNING + SyncState.UPLOADING -> State.UPLOADING + SyncState.RELATIONSHIP -> State.RELATIONSHIP + SyncState.SENT_VIA_SMS -> State.SENT_VIA_SMS + SyncState.SYNCED_VIA_SMS -> State.SYNCED_VIA_SMS + } diff --git a/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.android.kt b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.android.kt new file mode 100644 index 00000000000..b9cb0aaa938 --- /dev/null +++ b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.android.kt @@ -0,0 +1,42 @@ +package org.dhis2.tracker.search.di + +import org.dhis2.tracker.search.data.OptionSetRepository +import org.dhis2.tracker.search.data.OptionSetRepositoryImpl +import org.dhis2.tracker.search.data.SearchParametersRepository +import org.dhis2.tracker.search.data.SearchParametersRepositoryImpl +import org.dhis2.tracker.search.data.SearchTrackedEntityRepository +import org.dhis2.tracker.search.data.SearchTrackedEntityRepositoryImpl +import org.koin.core.module.Module +import org.koin.dsl.module + +/** + * Android-specific implementation of the tracker search repository module. + */ +internal actual val trackerSearchRepositoryModule: Module = + module { + // SearchTrackedEntityRepository - factory implementation + factory { + SearchTrackedEntityRepositoryImpl( + d2 = get(), + filterPresenter = get(), + profilePictureProvider = get(), + ) + } + + // SearchParametersRepository - interface implementation + single { + SearchParametersRepositoryImpl( + d2 = get(), + customIntentRepository = get(), + domainErrorMapper = get(), + ) + } + + // OptionSetRepository - interface implementation + single { + OptionSetRepositoryImpl( + d2 = get(), + domainErrorMapper = get(), + ) + } + } diff --git a/tracker/src/commonMain/composeResources/values-ar/strings.xml b/tracker/src/commonMain/composeResources/values-ar/strings.xml index 8845cfac49a..16ef803b527 100644 --- a/tracker/src/commonMain/composeResources/values-ar/strings.xml +++ b/tracker/src/commonMain/composeResources/values-ar/strings.xml @@ -10,5 +10,6 @@ إزلة بحث اختياري - - \ No newline at end of file + نعم + لا + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-ckb/strings.xml b/tracker/src/commonMain/composeResources/values-ckb/strings.xml index 194e179ced2..cc9373697a2 100644 --- a/tracker/src/commonMain/composeResources/values-ckb/strings.xml +++ b/tracker/src/commonMain/composeResources/values-ckb/strings.xml @@ -3,4 +3,6 @@ لابردن لابردن گەڕان + بەڵێ + نەخێر \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-cs/strings.xml b/tracker/src/commonMain/composeResources/values-cs/strings.xml index 0283111c0af..d005c9bff96 100644 --- a/tracker/src/commonMain/composeResources/values-cs/strings.xml +++ b/tracker/src/commonMain/composeResources/values-cs/strings.xml @@ -18,5 +18,7 @@ Vymazat hledání Hledat Volitelný - - \ No newline at end of file + Spustit + ano + ne + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-da/strings.xml b/tracker/src/commonMain/composeResources/values-da/strings.xml index 60ff2b43248..755406527e6 100644 --- a/tracker/src/commonMain/composeResources/values-da/strings.xml +++ b/tracker/src/commonMain/composeResources/values-da/strings.xml @@ -3,5 +3,4 @@ Cancel Remove Optional - - \ No newline at end of file + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-es-rES/strings.xml b/tracker/src/commonMain/composeResources/values-es-rES/strings.xml index 9bd242cba0f..70c2bef51df 100644 --- a/tracker/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/tracker/src/commonMain/composeResources/values-es-rES/strings.xml @@ -7,4 +7,6 @@ Cancelar Eliminar Buscar + + no \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-es/strings.xml b/tracker/src/commonMain/composeResources/values-es/strings.xml index 3cbf7e26cca..302f58c87a9 100644 --- a/tracker/src/commonMain/composeResources/values-es/strings.xml +++ b/tracker/src/commonMain/composeResources/values-es/strings.xml @@ -17,5 +17,7 @@ Borrar búsqueda Buscar Opcional - - \ No newline at end of file + Solo coincidencias exactas + + no + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-fr/strings.xml b/tracker/src/commonMain/composeResources/values-fr/strings.xml index 2580d750017..f4f756a26d1 100644 --- a/tracker/src/commonMain/composeResources/values-fr/strings.xml +++ b/tracker/src/commonMain/composeResources/values-fr/strings.xml @@ -15,5 +15,7 @@ Supprimer Rechercher Optional - - \ No newline at end of file + Correspondances exactes uniquement + oui + non + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-hi-rIN/strings.xml b/tracker/src/commonMain/composeResources/values-hi-rIN/strings.xml index 6d9b2a97a32..e696bac7412 100644 --- a/tracker/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/tracker/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -2,9 +2,9 @@ कोई संबंध नहीं है, नया जोड़ने के लिए “+” पर क्लिक करें संबंध बनाया गया - हटाएँ %1$s\? + हटाएँ %1$s? %1$s हटा दिया जाएगा। यह कार्रवाई वापस नहीं की जा सकती - %1$d संबंध हटाएँ\? + %1$d संबंध हटाएँ? %1$d हटा दिया जाएगा। यह कार्रवाई वापस नहीं की जा सकती %1$d और दिखाएँ संबंध @@ -18,5 +18,5 @@ खोज साफ़ करें सर्च करें / खोजें वैकल्पिक - - \ No newline at end of file + लॉन्च + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-id/strings.xml b/tracker/src/commonMain/composeResources/values-id/strings.xml index 7b577896372..cba81ebfdd6 100644 --- a/tracker/src/commonMain/composeResources/values-id/strings.xml +++ b/tracker/src/commonMain/composeResources/values-id/strings.xml @@ -9,4 +9,6 @@ Batal Hapus Pencarian + ya + tidak \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-km/strings.xml b/tracker/src/commonMain/composeResources/values-km/strings.xml index 354a52217c5..e7b153c7e3d 100644 --- a/tracker/src/commonMain/composeResources/values-km/strings.xml +++ b/tracker/src/commonMain/composeResources/values-km/strings.xml @@ -6,5 +6,6 @@ លុបចេញ ស្វែងរក មិនមានក៏បាន - - \ No newline at end of file + យល់ព្រម + មិនព្រម + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-lo/strings.xml b/tracker/src/commonMain/composeResources/values-lo/strings.xml index 47d3b8dadb9..fd5c7b8566f 100644 --- a/tracker/src/commonMain/composeResources/values-lo/strings.xml +++ b/tracker/src/commonMain/composeResources/values-lo/strings.xml @@ -10,5 +10,6 @@ ລົບອອກ ຄົ້ນຫາ ​ຢ່າງ​ຕ່ຳ ມີ​ ທັງ​ໝົດ 8 ຄຳ ໃນ​ນັ້ນ​ຕ້ອງ​ມີ​ໂຕ​ໃຫ​ຍ່ 1 ໂຕ ແລະ ໂຕ​ເລກ 1 ໂຕ - - \ No newline at end of file + ແມ່ນ + ບໍ່ແມ່ນ + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-my/strings.xml b/tracker/src/commonMain/composeResources/values-my/strings.xml index 84af54c4bb4..68f2b75e23b 100644 --- a/tracker/src/commonMain/composeResources/values-my/strings.xml +++ b/tracker/src/commonMain/composeResources/values-my/strings.xml @@ -4,5 +4,6 @@ ပယ်ဖျက်သည် ဖယ်ရှားခြင်း မဖြစ်မနေလုပ်ရန်မလိုပါ။ - - \ No newline at end of file + ဟုတ်ပါသည် + မဟုတ်ပါ + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-nb/strings.xml b/tracker/src/commonMain/composeResources/values-nb/strings.xml index f5613ecc3f3..be6710e566e 100644 --- a/tracker/src/commonMain/composeResources/values-nb/strings.xml +++ b/tracker/src/commonMain/composeResources/values-nb/strings.xml @@ -10,5 +10,6 @@ Fjern Søk Valgfri - - \ No newline at end of file + ja + nei + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-or/strings.xml b/tracker/src/commonMain/composeResources/values-or/strings.xml new file mode 100644 index 00000000000..a3a33ce7773 --- /dev/null +++ b/tracker/src/commonMain/composeResources/values-or/strings.xml @@ -0,0 +1,5 @@ + + + ହଁ + ନାହିଁ + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-prs/strings.xml b/tracker/src/commonMain/composeResources/values-prs/strings.xml index 3b5c15f66fb..a9146b48628 100644 --- a/tracker/src/commonMain/composeResources/values-prs/strings.xml +++ b/tracker/src/commonMain/composeResources/values-prs/strings.xml @@ -8,5 +8,4 @@ حذف جستجو گزینه ها - - \ No newline at end of file + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-ps/strings.xml b/tracker/src/commonMain/composeResources/values-ps/strings.xml index 1db8a3aef10..54b359fe55e 100644 --- a/tracker/src/commonMain/composeResources/values-ps/strings.xml +++ b/tracker/src/commonMain/composeResources/values-ps/strings.xml @@ -8,5 +8,4 @@ لرې کول جستجو اختیاري دی - - \ No newline at end of file + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-pt-rBR/strings.xml b/tracker/src/commonMain/composeResources/values-pt-rBR/strings.xml index 1b8678fea74..158f9908b30 100644 --- a/tracker/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/tracker/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -6,5 +6,6 @@ Remover Pesquisar Opcional - - \ No newline at end of file + sim + não + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-pt/strings.xml b/tracker/src/commonMain/composeResources/values-pt/strings.xml index 3ca362513aa..69ff5bb063e 100644 --- a/tracker/src/commonMain/composeResources/values-pt/strings.xml +++ b/tracker/src/commonMain/composeResources/values-pt/strings.xml @@ -17,5 +17,6 @@ Limpar pesquisa Procurar Opcional - - \ No newline at end of file + sim + não + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-ru/strings.xml b/tracker/src/commonMain/composeResources/values-ru/strings.xml index 1479e8dbc9c..d633f802f9c 100644 --- a/tracker/src/commonMain/composeResources/values-ru/strings.xml +++ b/tracker/src/commonMain/composeResources/values-ru/strings.xml @@ -17,5 +17,6 @@ Очистить поиск Поиск Опционно - - \ No newline at end of file + Да + нет + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-sv/strings.xml b/tracker/src/commonMain/composeResources/values-sv/strings.xml index e546e37a096..111a92dc1c3 100644 --- a/tracker/src/commonMain/composeResources/values-sv/strings.xml +++ b/tracker/src/commonMain/composeResources/values-sv/strings.xml @@ -6,5 +6,6 @@ Ta bort Sök Frivillig - - \ No newline at end of file + ja + nej + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-tet/strings.xml b/tracker/src/commonMain/composeResources/values-tet/strings.xml index e45c3dc35e3..8e01704e5a1 100644 --- a/tracker/src/commonMain/composeResources/values-tet/strings.xml +++ b/tracker/src/commonMain/composeResources/values-tet/strings.xml @@ -4,5 +4,4 @@ Kansela Hasai Opsional - - \ No newline at end of file + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-tg/strings.xml b/tracker/src/commonMain/composeResources/values-tg/strings.xml index 5a698c31b13..e59af9a3757 100644 --- a/tracker/src/commonMain/composeResources/values-tg/strings.xml +++ b/tracker/src/commonMain/composeResources/values-tg/strings.xml @@ -5,5 +5,4 @@ Тоза кардан Ҷустуҷӯ Ихтиёрӣ - - \ No newline at end of file + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-uk/strings.xml b/tracker/src/commonMain/composeResources/values-uk/strings.xml index 632bd42d7d7..00580e190b5 100644 --- a/tracker/src/commonMain/composeResources/values-uk/strings.xml +++ b/tracker/src/commonMain/composeResources/values-uk/strings.xml @@ -11,5 +11,7 @@ Очистити пошук Пошук Необов\'язково - - \ No newline at end of file + Тільки точне збіги + так + ні + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-ur/strings.xml b/tracker/src/commonMain/composeResources/values-ur/strings.xml index 5458cc5374b..9d20049aa25 100644 --- a/tracker/src/commonMain/composeResources/values-ur/strings.xml +++ b/tracker/src/commonMain/composeResources/values-ur/strings.xml @@ -8,5 +8,4 @@ حذف تلاش کریں اختیاری - - \ No newline at end of file + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/tracker/src/commonMain/composeResources/values-uz-rUZ/strings.xml index a472d228bbb..54847a12554 100644 --- a/tracker/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/tracker/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -10,5 +10,4 @@ Олиб ташлаш Izlash Ixtiyoriy - - \ No newline at end of file + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-uz/strings.xml b/tracker/src/commonMain/composeResources/values-uz/strings.xml index 743386bbbd9..01af292fd63 100644 --- a/tracker/src/commonMain/composeResources/values-uz/strings.xml +++ b/tracker/src/commonMain/composeResources/values-uz/strings.xml @@ -11,5 +11,6 @@ Олиб ташлаш Излаш Ихтиёрий - - \ No newline at end of file + ҳа + йўқ + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-vi/strings.xml b/tracker/src/commonMain/composeResources/values-vi/strings.xml index 9efa1f3f84d..fe6852f210a 100644 --- a/tracker/src/commonMain/composeResources/values-vi/strings.xml +++ b/tracker/src/commonMain/composeResources/values-vi/strings.xml @@ -17,5 +17,6 @@ Bỏ tìm kiếm Tìm kiếm Không bắt buộc - - \ No newline at end of file + + không + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-zh-rCN/strings.xml b/tracker/src/commonMain/composeResources/values-zh-rCN/strings.xml index e9bd51827e4..44faee14fa9 100644 --- a/tracker/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/tracker/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -17,5 +17,6 @@ 清除搜索 搜索 可选项描述 - - \ No newline at end of file + + + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values-zh/strings.xml b/tracker/src/commonMain/composeResources/values-zh/strings.xml index d52d25c7c99..07ff4461bcc 100644 --- a/tracker/src/commonMain/composeResources/values-zh/strings.xml +++ b/tracker/src/commonMain/composeResources/values-zh/strings.xml @@ -19,5 +19,10 @@ 清除搜索 搜索 可选项描述 - - \ No newline at end of file + 启动 + 仅限完全匹配 + 必须与值的起始部分相匹配 + 必须与数值的末尾一致 + + + \ No newline at end of file diff --git a/tracker/src/commonMain/composeResources/values/strings.xml b/tracker/src/commonMain/composeResources/values/strings.xml index a716e26fc62..bacd371734f 100644 --- a/tracker/src/commonMain/composeResources/values/strings.xml +++ b/tracker/src/commonMain/composeResources/values/strings.xml @@ -1,6 +1,6 @@ - There are no relationships, click "+" to add a new one. + There are no relationships, click \"+\" to add a new one. Relation created on Remove %1$s? The %1$s will be deleted. This action cannot be undone. @@ -18,5 +18,11 @@ Clear search Search Optional - + Launch + Exact matches only + Must match the start of the value + Must match the end of the value + yes + no + Enter at least %1$d characters \ No newline at end of file diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/model/TrackerInputType.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/model/TrackerInputType.kt new file mode 100644 index 00000000000..baf77aeb8c1 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/model/TrackerInputType.kt @@ -0,0 +1,39 @@ +package org.dhis2.tracker.input.model + +enum class TrackerInputType { + TEXT, + LONG_TEXT, + LETTER, + PHONE_NUMBER, + EMAIL, + URL, + NUMBER, + INTEGER, + INTEGER_POSITIVE, + INTEGER_NEGATIVE, + INTEGER_ZERO_OR_POSITIVE, + PERCENTAGE, + UNIT_INTERVAL, + AGE, + ORGANISATION_UNIT, + MULTI_SELECTION, + QR_CODE, + BAR_CODE, + HORIZONTAL_CHECKBOXES, + VERTICAL_CHECKBOXES, + HORIZONTAL_RADIOBUTTONS, + VERTICAL_RADIOBUTTONS, + YES_ONLY_SWITCH, + YES_ONLY_CHECKBOX, + DATE_TIME, + DATE, + TIME, + PERIOD_SELECTOR, + MATRIX, + SEQUENTIAL, + DROPDOWN, + NOT_SUPPORTED, + COORDINATES, + IMAGE, + CUSTOM_INTENT, +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/action/TrackerInputAction.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/action/TrackerInputAction.kt new file mode 100644 index 00000000000..d368c574868 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/action/TrackerInputAction.kt @@ -0,0 +1,25 @@ +package org.dhis2.tracker.input.ui.action + +import org.dhis2.mobile.commons.model.CustomIntentModel +import org.dhis2.tracker.input.model.TrackerInputType + +typealias CustomIntentUid = String +typealias FieldUid = String + +sealed interface TrackerInputAction { + data class LaunchCustomIntent( + val fieldUid: FieldUid, + val customIntentModel: CustomIntentModel, + ) : TrackerInputAction + + data class Scan( + val fieldUid: FieldUid, + val optionSet: String?, + val renderType: TrackerInputType, + ) : TrackerInputAction + + data class ValueChanged( + val fieldUid: FieldUid, + val value: String?, + ) : TrackerInputAction +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/action/TrackerInputUiEvent.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/action/TrackerInputUiEvent.kt new file mode 100644 index 00000000000..afcb2632da3 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/action/TrackerInputUiEvent.kt @@ -0,0 +1,33 @@ +package org.dhis2.tracker.input.ui.action + +import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope +import org.dhis2.tracker.input.model.TrackerInputType + +sealed interface TrackerInputUiEvent { + data class OnScanButtonClicked( + val uid: String, + val optionSet: String?, + val renderType: TrackerInputType, + ) : TrackerInputUiEvent + + data class OnOrgUnitButtonClicked( + val uid: String, + val label: String, + val value: String?, + val orgUnitSelectorScope: OrgUnitSelectorScope?, + ) : TrackerInputUiEvent + + data class OnLaunchCustomIntent( + val uid: String, + val customIntentUid: String, + ) : TrackerInputUiEvent + + data class OnItemClick( + val uid: String, + ) : TrackerInputUiEvent + + data class OnValueChange( + val uid: String, + val value: String?, + ) : TrackerInputUiEvent +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/mapper/TrackerInputUiStateMapper.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/mapper/TrackerInputUiStateMapper.kt new file mode 100644 index 00000000000..ed9ea3431b0 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/mapper/TrackerInputUiStateMapper.kt @@ -0,0 +1,38 @@ +package org.dhis2.tracker.input.ui.mapper + +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.search.model.SearchParameterModel +import org.hisp.dhis.mobile.ui.designsystem.component.Orientation + +fun SearchParameterModel.toTrackerInputUiState() = + TrackerInputUiState( + uid = uid, + label = label, + value = null, + focused = false, + valueType = inputType, + optionSet = optionSet, + error = null, + warning = null, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = inputType.getOrientation(), + optionSetConfiguration = null, + customIntentUid = customIntentUid, + displayName = null, + orgUnitSelectorScope = null, + searchOperator = searchOperator, + minCharactersToSearch = minCharactersToSearch, + ) + +private fun TrackerInputType.getOrientation(): Orientation = + when (this) { + TrackerInputType.HORIZONTAL_CHECKBOXES, + TrackerInputType.HORIZONTAL_RADIOBUTTONS, + -> Orientation.HORIZONTAL + + else -> Orientation.VERTICAL + } diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerAgeInputProvider.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerAgeInputProvider.kt new file mode 100644 index 00000000000..edd05b75d9d --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerAgeInputProvider.kt @@ -0,0 +1,138 @@ +package org.dhis2.tracker.input.ui.provider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import org.dhis2.mobile.commons.dates.calculateAgeFromDate +import org.dhis2.mobile.commons.dates.calculateDateFromAge +import org.dhis2.mobile.commons.resources.Res +import org.dhis2.mobile.commons.resources.age +import org.dhis2.mobile.commons.resources.age_or +import org.dhis2.mobile.commons.resources.cancel +import org.dhis2.mobile.commons.resources.date_of_birth +import org.dhis2.mobile.commons.resources.ok +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.input.ui.state.inputState +import org.dhis2.tracker.input.ui.state.supportingText +import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType +import org.hisp.dhis.mobile.ui.designsystem.component.InputAge +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ProvideTrackerAgeInput( + model: TrackerInputUiState, + inputStyle: InputStyle, + onNextClicked: () -> Unit, + modifier: Modifier, + onValueChange: (String?) -> Unit, +) { + var inputType by remember { + mutableStateOf( + if (!model.value.isNullOrEmpty()) { + model.value.let { + AgeInputType.DateOfBirth(TextFieldValue(it, TextRange(it.length))) + } + } else { + AgeInputType.None + }, + ) + } + + DisposableEffect(model.value) { + when (inputType) { + is AgeInputType.Age -> + if (!model.value.isNullOrEmpty()) { + calculateAgeFromDate( + model.value, + (inputType as AgeInputType.Age).unit.name, + )?.let { + (inputType as AgeInputType.Age).copy( + value = + TextFieldValue( + it, + TextRange(it.length), + ), + ) + } ?: AgeInputType.None + } + + is AgeInputType.DateOfBirth -> + if (!model.value.isNullOrEmpty()) { + model.value.let { + (inputType as AgeInputType.DateOfBirth).copy( + value = + TextFieldValue( + it, + TextRange(it.length), + ), + ) + } + } + + AgeInputType.None -> { + // no-op + } + } + + onDispose { } + } + + InputAge( + state = + rememberInputAgeState( + inputAgeData = + InputAgeData( + title = model.label, + inputStyle = inputStyle, + isRequired = model.mandatory, + dateOfBirthLabel = stringResource(Res.string.date_of_birth), + orLabel = stringResource(Res.string.age_or), + ageLabel = stringResource(Res.string.age), + cancelText = stringResource(Res.string.cancel), + acceptText = stringResource(Res.string.ok), + ), + inputType = inputType, + inputState = model.inputState(), + legendData = model.legend, + supportingText = model.supportingText(), + ), + onValueChanged = { ageInputType -> + if (ageInputType != null) { + inputType = ageInputType + } + + when (val type = inputType) { + is AgeInputType.Age -> { + calculateDateFromAge(type.value.text, type.unit.name)?.let { calculatedDate -> + onValueChange(calculatedDate) + } + } + + is AgeInputType.DateOfBirth -> { + type.value.text + .takeIf { it != model.value } + ?.let { + onValueChange(it) + } + } + + AgeInputType.None -> { + onValueChange(null) + } + } + }, + onImeActionClick = { _ -> + onNextClicked() + }, + modifier = modifier, + ) +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerCheckboxInputProvider.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerCheckboxInputProvider.kt new file mode 100644 index 00000000000..58001abd402 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerCheckboxInputProvider.kt @@ -0,0 +1,56 @@ +package org.dhis2.tracker.input.ui.provider + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.input.ui.state.inputState +import org.dhis2.tracker.input.ui.state.supportingText +import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData +import org.hisp.dhis.mobile.ui.designsystem.component.InputCheckBox +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle + +@Composable +fun TrackerCheckboxInputProvider( + model: TrackerInputUiState, + inputStyle: InputStyle, + modifier: Modifier, + onValueChange: (String?) -> Unit, +) { + val dataMap = + buildMap { + model.optionSetConfiguration?.options.let { optionDataList -> + optionDataList?.forEach { optionData -> + put( + optionData.code, + CheckBoxData( + uid = optionData.code, + checked = model.value == optionData.code, + enabled = model.editable, + textInput = optionData.displayName, + ), + ) + } + } + } + + val (codeList, data) = dataMap.toList().unzip() + + InputCheckBox( + modifier = modifier, + inputStyle = inputStyle, + title = model.label, + checkBoxData = data, + orientation = model.orientation, + state = model.inputState(), + supportingText = model.supportingText(), + legendData = model.legend, + isRequired = model.mandatory, + onItemChange = { item -> + val selectedIndex = data.indexOf(item) + onValueChange(if (item.checked) null else codeList[selectedIndex]) + }, + onClearSelection = { + onValueChange(null) + }, + ) +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerDateTimeProvider.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerDateTimeProvider.kt new file mode 100644 index 00000000000..d1025c92fc7 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerDateTimeProvider.kt @@ -0,0 +1,108 @@ +package org.dhis2.tracker.input.ui.provider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.input.ui.state.inputState +import org.dhis2.tracker.input.ui.state.supportingText +import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType +import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle +import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates +import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTimeTransformation +import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTransformation +import org.hisp.dhis.mobile.ui.designsystem.component.model.TimeTransformation +import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeData +import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputDateTimeState + +@Composable +fun ProvideTrackerDateTimeInput( + model: TrackerInputUiState, + inputStyle: InputStyle, + onNextClicked: () -> Unit, + modifier: Modifier, + onValueChange: (String?) -> Unit, +) { + val (actionType, visualTransformation) = + when (model.valueType) { + TrackerInputType.DATE_TIME -> DateTimeActionType.DATE_TIME to DateTimeTransformation() + TrackerInputType.TIME -> DateTimeActionType.TIME to TimeTransformation() + else -> DateTimeActionType.DATE to DateTransformation() + } + val textSelection = + TextRange( + model.value?.length ?: 0, + ) + + val yearIntRange = getYearRange() + val selectableDates = + SelectableDates( + initialDate = DEFAULT_MIN_DATE, + endDate = DEFAULT_MAX_DATE, + ) + + var value by remember(model.value) { + mutableStateOf( + model.value?.let { value -> + TextFieldValue( + value, + textSelection, + ) + } ?: TextFieldValue(), + ) + } + + val inputState = + rememberInputDateTimeState( + InputDateTimeData( + title = model.label, + actionType = actionType, + visualTransformation = visualTransformation, + isRequired = model.mandatory, + selectableDates = selectableDates, + yearRange = yearIntRange, + inputStyle = inputStyle, + ), + inputTextFieldValue = value, + inputState = model.inputState(), + legendData = model.legend, + supportingText = model.supportingText(), + ) + + InputDateTime( + state = inputState, + modifier = + modifier.semantics { + contentDescription = value.text + }, + onValueChanged = { + value = it ?: TextFieldValue() + onValueChange(value.text.ifEmpty { null }) + }, + onImeActionClick = { _ -> + onNextClicked() + }, + ) +} + +private fun getYearRange(): IntRange = + IntRange( + DEFAULT_MIN_DATE + .substring(4, 8) + .toInt(), + DEFAULT_MAX_DATE + .substring(4, 8) + .toInt(), + ) + +const val DEFAULT_MIN_DATE = "12111924" +const val DEFAULT_MAX_DATE = "12112124" diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerInputProvider.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerInputProvider.kt new file mode 100644 index 00000000000..0965f46d189 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerInputProvider.kt @@ -0,0 +1,793 @@ +package org.dhis2.tracker.input.ui.provider + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope +import org.dhis2.mobile.tracker.resources.Res +import org.dhis2.mobile.tracker.resources.custom_intent_launch +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.TrackerInputUiEvent +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.input.ui.state.inputState +import org.dhis2.tracker.input.ui.state.supportingText +import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData +import org.hisp.dhis.mobile.ui.designsystem.component.CustomIntentState +import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem +import org.hisp.dhis.mobile.ui.designsystem.component.InputBarCode +import org.hisp.dhis.mobile.ui.designsystem.component.InputCustomIntent +import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown +import org.hisp.dhis.mobile.ui.designsystem.component.InputEmail +import org.hisp.dhis.mobile.ui.designsystem.component.InputInteger +import org.hisp.dhis.mobile.ui.designsystem.component.InputLetter +import org.hisp.dhis.mobile.ui.designsystem.component.InputLink +import org.hisp.dhis.mobile.ui.designsystem.component.InputLongText +import org.hisp.dhis.mobile.ui.designsystem.component.InputMultiSelection +import org.hisp.dhis.mobile.ui.designsystem.component.InputNegativeInteger +import org.hisp.dhis.mobile.ui.designsystem.component.InputNumber +import org.hisp.dhis.mobile.ui.designsystem.component.InputOrgUnit +import org.hisp.dhis.mobile.ui.designsystem.component.InputPercentage +import org.hisp.dhis.mobile.ui.designsystem.component.InputPhoneNumber +import org.hisp.dhis.mobile.ui.designsystem.component.InputPositiveInteger +import org.hisp.dhis.mobile.ui.designsystem.component.InputPositiveIntegerOrZero +import org.hisp.dhis.mobile.ui.designsystem.component.InputQRCode +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle +import org.hisp.dhis.mobile.ui.designsystem.component.InputText +import org.hisp.dhis.mobile.ui.designsystem.component.InputUnitInterval +import org.hisp.dhis.mobile.ui.designsystem.component.InputYesOnlyCheckBox +import org.hisp.dhis.mobile.ui.designsystem.component.InputYesOnlySwitch +import org.hisp.dhis.mobile.ui.designsystem.component.model.RegExValidations +import org.jetbrains.compose.resources.stringResource + +@Composable +fun TrackerInputProvider( + modifier: Modifier = Modifier, + inputModel: TrackerInputUiState, + inputStyle: InputStyle, + onNextClicked: () -> Unit, + onUiEvent: (TrackerInputUiEvent) -> Unit, +) { + var savedTextSelection by remember(inputModel.uid) { + mutableStateOf(TextRange(inputModel.value?.length ?: 0)) + } + + var textValue by remember(inputModel.uid, inputModel.value) { + mutableStateOf( + TextFieldValue( + text = inputModel.value ?: "", + selection = savedTextSelection, + ), + ) + } + + val focusRequester = remember { FocusRequester() } + val modifierWithFocus = modifier.focusRequester(focusRequester) + + LaunchedEffect(inputModel.focused) { + if (inputModel.focused) { + focusRequester.requestFocus() + } + } + + val onTextValueChanged: (TextFieldValue?) -> Unit = { newValue -> + textValue = newValue ?: TextFieldValue() + savedTextSelection = newValue?.selection ?: TextRange.Zero + manageOnValueChange( + newValue = newValue?.text, + inputModel = inputModel, + onUiEvent = onUiEvent, + ) + } + + val onImeActionClick: (ImeAction) -> Unit = { imeAction -> + if (imeAction == ImeAction.Next) { + onNextClicked() + } + } + + when (inputModel.valueType) { + TrackerInputType.TEXT -> { + InputText( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.LONG_TEXT -> { + InputLongText( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.LETTER -> { + InputLetter( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.EMAIL -> { + InputEmail( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onEmailActionCLicked = {}, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.PHONE_NUMBER -> { + InputPhoneNumber( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onCallActionClicked = {}, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.URL -> { + InputLink( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onLinkActionCLicked = {}, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.NUMBER -> { + InputNumber( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + notation = RegExValidations.BRITISH_DECIMAL_NOTATION, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.INTEGER -> { + InputInteger( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.INTEGER_POSITIVE -> { + InputPositiveInteger( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.INTEGER_NEGATIVE -> { + InputNegativeInteger( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.INTEGER_ZERO_OR_POSITIVE -> { + InputPositiveIntegerOrZero( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.PERCENTAGE -> { + InputPercentage( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + onFocusChanged = { isFocused -> + handleOnFocusChange( + isFocused = isFocused, + inputModelId = inputModel.uid, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.UNIT_INTERVAL -> { + InputUnitInterval( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + ) + } + + TrackerInputType.AGE -> { + ProvideTrackerAgeInput( + model = inputModel, + inputStyle = inputStyle, + onNextClicked = onNextClicked, + modifier = modifierWithFocus, + onValueChange = { newValue -> + manageOnValueChange( + newValue = newValue, + inputModel = inputModel, + onUiEvent = onUiEvent, + ) + }, + ) + } + + TrackerInputType.DATE_TIME, TrackerInputType.DATE, TrackerInputType.TIME -> { + ProvideTrackerDateTimeInput( + model = inputModel, + inputStyle = inputStyle, + onNextClicked = onNextClicked, + modifier = modifierWithFocus, + onValueChange = { newValue -> + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = newValue, + ), + ) + }, + ) + } + + TrackerInputType.ORGANISATION_UNIT -> { + InputOrgUnit( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputText = inputModel.displayName, + onValueChanged = { newValue -> + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = newValue, + ), + ) + }, + onOrgUnitActionCLicked = { + onUiEvent( + TrackerInputUiEvent.OnOrgUnitButtonClicked( + uid = inputModel.uid, + label = inputModel.label, + value = inputModel.value, + orgUnitSelectorScope = + inputModel.orgUnitSelectorScope + ?: OrgUnitSelectorScope.UserSearchScope(), + ), + ) + }, + ) + } + + TrackerInputType.MULTI_SELECTION -> { + val options = + inputModel.optionSetConfiguration?.options?.map { optionItem -> + CheckBoxData( + uid = optionItem.code, + checked = + optionItem.code.let { code -> + inputModel.value + ?.takeIf { it.isNotEmpty() } + ?.split(",") + ?.contains(code) + } ?: false, + enabled = inputModel.editable, + textInput = optionItem.displayName, + ) + } ?: emptyList() + + InputMultiSelection( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + items = options, + state = inputModel.inputState(), + supportingTextData = inputModel.supportingText(), + legendData = inputModel.legend, + isRequired = inputModel.mandatory, + inputStyle = inputStyle, + onItemsSelected = { checkBoxData -> + val checkedValues = + checkBoxData + .filter { it.checked } + .joinToString(",") { it.uid } + .takeIf { it.isNotEmpty() } + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = checkedValues, + ), + ) + }, + onClearItemSelection = { + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = null, + ), + ) + }, + ) + } + + TrackerInputType.QR_CODE -> { + InputQRCode( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onQRButtonClicked = { + onUiEvent( + TrackerInputUiEvent.OnScanButtonClicked( + uid = inputModel.uid, + optionSet = inputModel.optionSet, + renderType = inputModel.valueType, + ), + ) + }, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + ) + } + + TrackerInputType.BAR_CODE -> { + InputBarCode( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + inputStyle = inputStyle, + inputTextFieldValue = textValue, + onActionButtonClicked = { + onUiEvent( + TrackerInputUiEvent.OnScanButtonClicked( + uid = inputModel.uid, + optionSet = inputModel.optionSet, + renderType = inputModel.valueType, + ), + ) + }, + onValueChanged = onTextValueChanged, + imeAction = ImeAction.Next, + onImeActionClick = onImeActionClick, + ) + } + + TrackerInputType.HORIZONTAL_CHECKBOXES, TrackerInputType.VERTICAL_CHECKBOXES -> { + TrackerCheckboxInputProvider( + model = inputModel, + inputStyle = inputStyle, + modifier = modifierWithFocus, + onValueChange = { newValue -> + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = newValue, + ), + ) + }, + ) + } + + TrackerInputType.HORIZONTAL_RADIOBUTTONS, TrackerInputType.VERTICAL_RADIOBUTTONS -> { + TrackerRadioButtonInputProvider( + model = inputModel, + inputStyle = inputStyle, + modifier = modifierWithFocus, + onValueChange = { newValue -> + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = newValue, + ), + ) + }, + ) + } + + TrackerInputType.YES_ONLY_SWITCH -> { + InputYesOnlySwitch( + modifier = modifierWithFocus.fillMaxWidth(), + title = inputModel.label, + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequired = inputModel.mandatory, + inputStyle = inputStyle, + isChecked = inputModel.value?.toBooleanStrictOrNull() == true, + onClick = { isChecked -> + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = getYesOnlyValue(isChecked), + ), + ) + }, + ) + } + + TrackerInputType.YES_ONLY_CHECKBOX -> { + InputYesOnlyCheckBox( + modifier = modifierWithFocus.fillMaxWidth(), + checkBoxData = + CheckBoxData( + uid = inputModel.uid, + checked = inputModel.value?.toBooleanStrictOrNull() == true, + enabled = inputModel.editable, + textInput = inputModel.label, + ), + state = inputModel.inputState(), + supportingText = inputModel.supportingText(), + legendData = inputModel.legend, + isRequired = inputModel.mandatory, + inputStyle = inputStyle, + onClick = { isChecked -> + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = if (isChecked) "true" else null, + ), + ) + }, + ) + } + + TrackerInputType.DROPDOWN -> { + val optionSetConfiguration = inputModel.optionSetConfiguration!! + + var selectedItem by remember(inputModel.uid, inputModel.value) { + val displayName = + optionSetConfiguration.options + .find { + it.code == inputModel.value + }?.displayName + mutableStateOf(displayName ?: inputModel.value ?: "") + } + + val options = optionSetConfiguration.options + val useDropdown by remember { + derivedStateOf { options.size < 15 } + } + + InputDropDown( + modifier = modifierWithFocus.fillMaxWidth(), + inputStyle = inputStyle, + title = inputModel.label, + state = inputModel.inputState(), + selectedItem = DropdownItem(selectedItem), + supportingTextData = inputModel.supportingText(), + legendData = inputModel.legend, + isRequiredField = inputModel.mandatory, + onResetButtonClicked = { + selectedItem = "" + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = null, + ), + ) + }, + fetchItem = { index -> + DropdownItem(options.getOrNull(index)?.displayName ?: "") + }, + onSearchOption = { query -> + optionSetConfiguration.onSearch?.invoke(query) + }, + itemCount = options.size, + useDropDown = useDropdown, + onItemSelected = { index, newSelectedItem -> + selectedItem = newSelectedItem.label + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = options.getOrNull(index)?.code, + ), + ) + }, + loadOptions = { + optionSetConfiguration.onLoadOptions?.invoke() + }, + onDismiss = { + optionSetConfiguration.onSearch?.invoke("") + }, + ) + } + + TrackerInputType.CUSTOM_INTENT -> { + val values by remember(inputModel) { + derivedStateOf { + inputModel.value + ?.takeIf { it.isNotEmpty() } + ?.split(",") + ?.toMutableList() ?: mutableListOf() + } + } + + var customIntentState by remember(inputModel, inputModel.value) { + mutableStateOf( + if (inputModel.value.isNullOrEmpty()) { + CustomIntentState.LAUNCH + } else { + CustomIntentState.LOADED + }, + ) + } + + InputCustomIntent( + title = inputModel.label, + buttonText = stringResource(Res.string.custom_intent_launch), + supportingText = inputModel.supportingText(), + inputShellState = inputModel.inputState(), + inputStyle = inputStyle, + modifier = modifier, + isRequired = inputModel.mandatory, + onLaunch = { + customIntentState = CustomIntentState.LOADING + inputModel.customIntentUid?.let { customIntentUid -> + onUiEvent( + TrackerInputUiEvent.OnLaunchCustomIntent( + inputModel.uid, + customIntentUid, + ), + ) + } + }, + onClear = { + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = null, + ), + ) + }, + customIntentState = customIntentState, + values = values.toList(), + ) + } + + TrackerInputType.PERIOD_SELECTOR, + TrackerInputType.MATRIX, + TrackerInputType.SEQUENTIAL, + TrackerInputType.NOT_SUPPORTED, + TrackerInputType.COORDINATES, + TrackerInputType.IMAGE, + -> { + // no-op + } + } +} + +fun manageOnValueChange( + newValue: String?, + inputModel: TrackerInputUiState, + onUiEvent: (TrackerInputUiEvent) -> Unit, +) { + newValue?.let { + if (it != inputModel.value) { + onUiEvent( + TrackerInputUiEvent.OnValueChange( + uid = inputModel.uid, + value = it.takeIf { text -> text.isNotEmpty() }, + ), + ) + } + } +} + +private fun getYesOnlyValue(checked: Boolean): String? = if (!checked) "true" else null + +private fun handleOnFocusChange( + isFocused: Boolean, + inputModelId: String, + onUiEvent: (TrackerInputUiEvent) -> Unit, +) { + if (isFocused) { + onUiEvent(TrackerInputUiEvent.OnItemClick(inputModelId)) + } +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerRadioButtonInputProvider.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerRadioButtonInputProvider.kt new file mode 100644 index 00000000000..63656617658 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/provider/TrackerRadioButtonInputProvider.kt @@ -0,0 +1,58 @@ +package org.dhis2.tracker.input.ui.provider + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.input.ui.state.inputState +import org.dhis2.tracker.input.ui.state.supportingText +import org.hisp.dhis.mobile.ui.designsystem.component.InputRadioButton +import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle +import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData + +@Composable +fun TrackerRadioButtonInputProvider( + model: TrackerInputUiState, + inputStyle: InputStyle, + modifier: Modifier, + onValueChange: (String?) -> Unit, +) { + val dataMap = + buildMap { + model.optionSetConfiguration?.options.let { optionDataList -> + optionDataList?.forEach { optionData -> + put( + optionData.code, + RadioButtonData( + uid = optionData.code, + selected = model.value == optionData.code, + enabled = true, + textInput = optionData.displayName, + ), + ) + } + } + } + + val (codeList, data) = dataMap.toList().unzip() + + InputRadioButton( + modifier = modifier, + inputStyle = inputStyle, + title = model.label, + radioButtonData = data, + orientation = model.orientation, + state = model.inputState(), + supportingText = model.supportingText(), + legendData = model.legend, + isRequired = model.mandatory, + itemSelected = data.find { it.selected }, + onItemChange = { item -> + if (item != null) { + val selectedIndex = data.indexOf(item) + onValueChange(codeList[selectedIndex]) + } else { + onValueChange(null) + } + }, + ) +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/state/TrackerInputUiState.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/state/TrackerInputUiState.kt new file mode 100644 index 00000000000..41d7768f940 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/state/TrackerInputUiState.kt @@ -0,0 +1,198 @@ +package org.dhis2.tracker.input.ui.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope +import org.dhis2.mobile.tracker.resources.Res +import org.dhis2.mobile.tracker.resources.end_with_search_operator +import org.dhis2.mobile.tracker.resources.equal_search_operator +import org.dhis2.mobile.tracker.resources.no +import org.dhis2.mobile.tracker.resources.starts_with_search_operator +import org.dhis2.mobile.tracker.resources.yes +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.search.model.SearchOperator +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.LegendData +import org.hisp.dhis.mobile.ui.designsystem.component.Orientation +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState +import org.jetbrains.compose.resources.stringResource + +data class TrackerInputUiState( + val uid: String, + val label: String, + val value: String?, + val focused: Boolean, + val valueType: TrackerInputType, + val optionSet: String?, + val error: String?, + val warning: String?, + val description: String?, + val mandatory: Boolean, + val editable: Boolean, + val legend: LegendData?, + val orientation: Orientation, + val optionSetConfiguration: TrackerOptionSetConfiguration?, + val customIntentUid: String?, + val displayName: String?, + val orgUnitSelectorScope: OrgUnitSelectorScope?, + val searchOperator: SearchOperator?, + val minCharactersToSearch: Int?, +) + +@Composable +fun TrackerInputUiState.supportingText(): List? { + val label = + searchOperator?.let { operator -> + if (focused) operator.supportingTextString() else null + } + return supportingTextList(searchOperatorLabel = label) +} + +fun TrackerInputUiState.inputState(): InputShellState = + when { + !editable -> InputShellState.DISABLED + error != null -> InputShellState.ERROR + focused -> InputShellState.FOCUSED + else -> InputShellState.UNFOCUSED + } + +/** + * Non-composable function to be used wherever a composable context is not available. For example, + * Unit Tests. Should use the [TrackerInputUiState.supportingText] method instead. + */ +fun TrackerInputUiState.supportingTextList(searchOperatorLabel: String?): List? = + listOfNotNull( + error?.let { + SupportingTextData( + it, + SupportingTextState.ERROR, + ) + }, + warning?.let { + SupportingTextData( + it, + SupportingTextState.WARNING, + ) + }, + if (focused && searchOperator != null) { + searchOperatorLabel?.let { text -> + SupportingTextData( + text, + SupportingTextState.DEFAULT, + ) + } + } else { + null + }, + description?.let { + SupportingTextData( + it, + SupportingTextState.DEFAULT, + ) + }, + ).ifEmpty { null } + +@Composable +private fun SearchOperator.supportingTextString() = + when (this) { + SearchOperator.EQ -> + stringResource(Res.string.equal_search_operator) + SearchOperator.SW -> + stringResource(Res.string.starts_with_search_operator) + SearchOperator.EW -> + stringResource(Res.string.end_with_search_operator) + else -> null + } + +@Composable +fun TrackerInputUiState.loadOptionSetConfiguration( + getOptionSetFlow: (fieldUid: String, optionSetUid: String) -> Flow>?, + onOptionSetSearch: (fieldUid: String, query: String) -> Unit, +): TrackerInputUiState = + when { + valueType == TrackerInputType.YES_ONLY_CHECKBOX || + valueType == TrackerInputType.YES_ONLY_SWITCH || ( + optionSet == null && ( + valueType == TrackerInputType.HORIZONTAL_CHECKBOXES || + valueType == TrackerInputType.HORIZONTAL_RADIOBUTTONS || + valueType == TrackerInputType.VERTICAL_CHECKBOXES || + valueType == TrackerInputType.VERTICAL_RADIOBUTTONS + ) + ) + -> + this.getBooleanOptionConfiguration() + + optionSet != null -> { + val optionSetFlow = + getOptionSetFlow( + uid, + optionSet, + ) + this.loadWithOptionSetFlow( + optionSetFlow = optionSetFlow, + onSearch = { query -> + onOptionSetSearch(uid, query) + }, + ) + } + + else -> this + } + +@Composable +private fun TrackerInputUiState.getBooleanOptionConfiguration(): TrackerInputUiState { + val booleanConfiguration = + TrackerOptionSetConfiguration( + options = + listOf( + TrackerOptionItem( + code = true.toString(), + displayName = stringResource(Res.string.yes), + ), + TrackerOptionItem( + code = false.toString(), + displayName = stringResource(Res.string.no), + ), + ), + ) + + return copy(optionSetConfiguration = booleanConfiguration) +} + +@Composable +private fun TrackerInputUiState.loadWithOptionSetFlow( + optionSetFlow: Flow>?, + onSearch: ((String) -> Unit)?, +): TrackerInputUiState { + if (optionSetFlow == null) return this + + val optionsData = optionSetFlow.collectAsLazyPagingItems() + + LaunchedEffect(Unit) { + optionsData.refresh() + } + + val options by remember { + derivedStateOf { + (0 until optionsData.itemCount).mapNotNull { index -> + optionsData[index] + } + } + } + + return copy( + optionSetConfiguration = + TrackerOptionSetConfiguration( + options = options, + onSearch = onSearch, + onLoadOptions = { optionsData.refresh() }, + ), + ) +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/state/TrackerOptionSetConfiguration.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/state/TrackerOptionSetConfiguration.kt new file mode 100644 index 00000000000..7f2a2917c6a --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/input/ui/state/TrackerOptionSetConfiguration.kt @@ -0,0 +1,16 @@ +package org.dhis2.tracker.input.ui.state + +/** + * Configuration for option set fields in tracker inputs. + * Provides options and callbacks for dropdown/selection components. + */ +data class TrackerOptionSetConfiguration( + val options: List, + val onSearch: ((String) -> Unit)? = null, + val onLoadOptions: (() -> Unit)? = null, +) + +data class TrackerOptionItem( + val code: String, + val displayName: String, +) diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/relationships/ui/RelationshipsScreen.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/relationships/ui/RelationshipsScreen.kt index 679cdf17b08..fe879bf836e 100644 --- a/tracker/src/commonMain/kotlin/org/dhis2/tracker/relationships/ui/RelationshipsScreen.kt +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/relationships/ui/RelationshipsScreen.kt @@ -85,7 +85,6 @@ import org.hisp.dhis.mobile.ui.designsystem.files.buildPainterForFile import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2TextStyle import org.hisp.dhis.mobile.ui.designsystem.theme.Shape import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing -import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor import org.hisp.dhis.mobile.ui.designsystem.theme.dropShadow import org.hisp.dhis.mobile.ui.designsystem.theme.getTextStyle @@ -117,8 +116,6 @@ fun RelationShipsScreen( Snackbar( modifier = Modifier.dropShadow(shape = SnackbarDefaults.shape), snackbarData = data, - containerColor = SurfaceColor.SurfaceBright, - contentColor = TextColor.OnSurface, ) } }, diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/OptionSetRepository.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/OptionSetRepository.kt new file mode 100644 index 00000000000..03075e6c880 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/OptionSetRepository.kt @@ -0,0 +1,25 @@ +package org.dhis2.tracker.search.data + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import org.dhis2.tracker.input.ui.state.TrackerOptionItem + +/** + * Repository for fetching option set data. + * Handles pagination and search of options for dropdown/selection inputs. + */ +interface OptionSetRepository { + /** + * Fetches paginated options for a given option set. + * + * @param optionSetUid The unique identifier of the option set + * @param pageSize Number of items per page + * @param searchQuery Optional search query to filter options + * @return Flow of paginated option items + */ + fun getOptions( + optionSetUid: String, + pageSize: Int = 10, + searchQuery: String? = null, + ): Flow> +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/SearchParametersRepository.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/SearchParametersRepository.kt new file mode 100644 index 00000000000..5b13e398794 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/SearchParametersRepository.kt @@ -0,0 +1,9 @@ +package org.dhis2.tracker.search.data + +import org.dhis2.tracker.search.model.SearchParameterModel + +interface SearchParametersRepository { + suspend fun getSearchParametersByProgram(programUid: String): List + + suspend fun getSearchParametersByTrackedEntityType(trackedEntityTypeUid: String): List +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepository.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepository.kt new file mode 100644 index 00000000000..c434cefdfb0 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepository.kt @@ -0,0 +1,42 @@ +package org.dhis2.tracker.search.data + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import org.dhis2.tracker.search.model.SearchOperator +import org.dhis2.tracker.search.model.SearchTrackedEntityAttribute +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult + +interface SearchTrackedEntityRepository { + suspend fun isTETypeAttribute( + teType: String, + dataId: String, + ): Boolean + + suspend fun getTEAttribute(dataId: String): SearchTrackedEntityAttribute + + suspend fun addToQuery( + dataId: String, + dataValues: List? = null, + searchOperator: SearchOperator?, + ) + + suspend fun addFiltersToQuery( + program: String?, + teType: String, + ) + + suspend fun excludeValuesFromQuery(excludeValues: List) + + fun fetchResults( + isOnline: Boolean, + hasStateFilters: Boolean, + allowCache: Boolean, + selectedProgram: String?, + ): Flow> + + suspend fun fetchImmediateResults( + isOnline: Boolean, + hasStateFilters: Boolean, + selectedProgram: String?, + ): List +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.kt new file mode 100644 index 00000000000..8018e82997f --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.kt @@ -0,0 +1,42 @@ +package org.dhis2.tracker.search.di + +import org.dhis2.mobile.commons.resources.StringResourceProvider +import org.dhis2.tracker.search.domain.FetchOptionSetOptions +import org.dhis2.tracker.search.domain.SearchTrackedEntities +import org.dhis2.tracker.search.ui.viewmodel.SearchParametersViewModel +import org.koin.core.module.Module +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +/** + * Platform-specific repository module. + * Implemented in androidMain and other platform-specific source sets. + */ +internal expect val trackerSearchRepositoryModule: Module + +/** + * Common tracker search module containing use cases and common dependencies. + */ +val trackerSearchModule = + module { + // Include platform-specific repository implementations + includes(trackerSearchRepositoryModule) + + // Use cases + // With teType parameter + factory { params -> + SearchTrackedEntities( + repository = get(), + customIntentRepository = get(), + teType = params.get(), + ) + } + factoryOf(::FetchOptionSetOptions) + + // Providers + single { StringResourceProvider() } + + // ViewModels + viewModelOf(::SearchParametersViewModel) + } diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/FetchOptionSetOptions.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/FetchOptionSetOptions.kt new file mode 100644 index 00000000000..bdc123bf1fc --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/FetchOptionSetOptions.kt @@ -0,0 +1,35 @@ +package org.dhis2.tracker.search.domain + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.tracker.input.ui.state.TrackerOptionItem +import org.dhis2.tracker.search.data.OptionSetRepository + +/** + * Use case for fetching option set options with pagination. + * Coordinates between repository and UI layer. + */ +class FetchOptionSetOptions( + private val optionSetRepository: OptionSetRepository, +) : UseCase>> { + override suspend fun invoke(input: Params): Result>> = + try { + val flow = + optionSetRepository.getOptions( + optionSetUid = input.optionSetUid, + pageSize = input.pageSize, + searchQuery = input.searchQuery, + ) + Result.success(flow) + } catch (e: DomainError) { + Result.failure(e) + } + + data class Params( + val optionSetUid: String, + val pageSize: Int = 10, + val searchQuery: String? = null, + ) +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/FetchSearchParameters.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/FetchSearchParameters.kt new file mode 100644 index 00000000000..df57dfdbceb --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/FetchSearchParameters.kt @@ -0,0 +1,59 @@ +package org.dhis2.tracker.search.domain + +import kotlinx.coroutines.withContext +import org.dhis2.mobile.commons.coroutine.Dispatcher +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.search.data.SearchParametersRepository +import org.dhis2.tracker.search.model.FetchSearchParametersData +import org.dhis2.tracker.search.model.SearchParameterModel + +class FetchSearchParameters( + val dispatcher: Dispatcher, + val repository: SearchParametersRepository, +) : UseCase> { + val hiddenInputTypes = + listOf( + TrackerInputType.IMAGE, + TrackerInputType.COORDINATES, + TrackerInputType.LETTER, + TrackerInputType.UNIT_INTERVAL, + TrackerInputType.COORDINATES, + TrackerInputType.URL, + TrackerInputType.NOT_SUPPORTED, + ) + + override suspend fun invoke(input: FetchSearchParametersData): Result> = + withContext(dispatcher.io) { + try { + val searchParameters = + input.programUid?.let { + repository.getSearchParametersByProgram(it) + } ?: repository.getSearchParametersByTrackedEntityType(input.teiTypeUid) + + val filteredSearchParameters = + searchParameters + .filter { + it.inputType !in hiddenInputTypes + } + + Result.success(sortSearchParameters(filteredSearchParameters)) + } catch (e: DomainError) { + Result.failure(e) + } + } + + // Sort parameters to list first QR or BarCode uniques, then QR or BarCode and then remaining uniques + internal fun sortSearchParameters(parameters: List): List = + parameters.sortedWith( + compareByDescending { + isQrCodeOrBarCode(it.inputType) && it.isUnique + }.thenByDescending { + isQrCodeOrBarCode(it.inputType) + }.thenByDescending { it.isUnique }, + ) + + internal fun isQrCodeOrBarCode(inputType: TrackerInputType?): Boolean = + inputType == TrackerInputType.QR_CODE || inputType == TrackerInputType.BAR_CODE +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/SearchTrackedEntities.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/SearchTrackedEntities.kt new file mode 100644 index 00000000000..cc13a14e6d9 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/domain/SearchTrackedEntities.kt @@ -0,0 +1,109 @@ +package org.dhis2.tracker.search.domain + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.domain.UseCase +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel +import org.dhis2.tracker.search.data.SearchTrackedEntityRepository +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult + +class SearchTrackedEntities( + private val repository: SearchTrackedEntityRepository, + private val customIntentRepository: CustomIntentRepository, + private val teType: String, +) : UseCase>> { + override suspend fun invoke(input: SearchTrackedEntitiesInput): Result>> { + try { + prepareQuery(input, teType) + // Due to performance issues and possible duplicates we exclude the values in the exclude list only when no program is selected + if (input.excludeValues?.isNotEmpty() == true && input.selectedProgram == null) { + repository.excludeValuesFromQuery(input.excludeValues.toList()) + } + // fetch results from repository based on the prepared query + // and if the device is online and has state filters + return Result.success( + repository.fetchResults( + isOnline = input.isOnline, + hasStateFilters = input.hasStateFilters, + allowCache = input.allowCache, + selectedProgram = input.selectedProgram, + ), + ) + } catch (domainError: DomainError) { + // Errors will be handled by the view layer + return Result.failure(domainError) + } + } + + /** + * Invoke method for immediate non-paginated results. + * Useful for scenarios like QR code scanning where immediate results are needed. + */ + suspend fun invokeImmediate(input: SearchTrackedEntitiesInput): Result> { + try { + prepareQuery(input, teType) + // Due to performance issues and possible duplicates we exclude the values in the exclude list only when no program is selected + if (input.excludeValues?.isNotEmpty() == true && input.selectedProgram == null) { + repository.excludeValuesFromQuery(input.excludeValues.toList()) + } + // fetch immediate results from repository + return Result.success( + repository.fetchImmediateResults( + isOnline = input.isOnline, + hasStateFilters = input.hasStateFilters, + selectedProgram = input.selectedProgram, + ), + ) + } catch (domainError: DomainError) { + return Result.failure(domainError) + } + } + + private suspend fun prepareQuery( + input: SearchTrackedEntitiesInput, + teType: String, + ) { + // Add filters from FilterManager + repository.addFiltersToQuery(input.selectedProgram, teType) + + input.queryDataList?.forEach { data -> + val isTETypeAttribute = getIsTETypeAttribute(input.selectedProgram, data.attributeId, teType) + + if (input.selectedProgram != null || isTETypeAttribute) { + val normalizedValues = + data.values?.let { + if (it.size > 1 && + !customIntentRepository.attributeHasCustomIntentAndReturnsAListOfValues( + data.attributeId, + CustomIntentActionTypeModel.SEARCH, + ) + ) { + mutableListOf(it.joinToString(",")) + } else { + it + } + } + + repository.addToQuery( + data.attributeId, + normalizedValues, + data.searchOperator, + ) + } + } + } + + private suspend fun getIsTETypeAttribute( + selectedProgram: String?, + dataId: String, + teType: String, + ): Boolean = + if (selectedProgram == null) { + repository.isTETypeAttribute(teType, dataId) + } else { + false + } +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/FetchSearchParametersData.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/FetchSearchParametersData.kt new file mode 100644 index 00000000000..7e09670f911 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/FetchSearchParametersData.kt @@ -0,0 +1,6 @@ +package org.dhis2.tracker.search.model + +data class FetchSearchParametersData( + val teiTypeUid: String, + val programUid: String? = null, +) diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/QueryData.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/QueryData.kt new file mode 100644 index 00000000000..a306466dda0 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/QueryData.kt @@ -0,0 +1,7 @@ +package org.dhis2.tracker.search.model + +data class QueryData( + val attributeId: String, + val values: List?, + val searchOperator: SearchOperator?, +) diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchOperator.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchOperator.kt new file mode 100644 index 00000000000..ab6ccf44e7a --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchOperator.kt @@ -0,0 +1,24 @@ +package org.dhis2.tracker.search.model + +import androidx.annotation.VisibleForTesting + +enum class SearchOperator { + LIKE, + SW, + EW, + EQ, +} + +/** + * Method use explicitly for testing purposes. Simulating that the search operator + * has a label assigned. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +fun SearchOperator.hasLabel(): Boolean = + when (this) { + SearchOperator.EQ, + SearchOperator.SW, + SearchOperator.EW, + -> true + else -> false + } diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchParameterModel.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchParameterModel.kt new file mode 100644 index 00000000000..83f5a98dfba --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchParameterModel.kt @@ -0,0 +1,14 @@ +package org.dhis2.tracker.search.model + +import org.dhis2.tracker.input.model.TrackerInputType + +data class SearchParameterModel( + val uid: String, + val label: String, + val inputType: TrackerInputType, + val optionSet: String?, + val customIntentUid: String? = null, + val minCharactersToSearch: Int?, + val searchOperator: SearchOperator?, + val isUnique: Boolean, +) diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchTrackedEntitiesInput.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchTrackedEntitiesInput.kt new file mode 100644 index 00000000000..48d8061fc59 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchTrackedEntitiesInput.kt @@ -0,0 +1,10 @@ +package org.dhis2.tracker.search.model + +data class SearchTrackedEntitiesInput( + val selectedProgram: String?, + val allowCache: Boolean, + val excludeValues: Set?, + val hasStateFilters: Boolean, + val isOnline: Boolean, + val queryDataList: List? = null, +) diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchTrackedEntityAttribute.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchTrackedEntityAttribute.kt new file mode 100644 index 00000000000..916ec481093 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/SearchTrackedEntityAttribute.kt @@ -0,0 +1,6 @@ +package org.dhis2.tracker.search.model + +data class SearchTrackedEntityAttribute( + val isUnique: Boolean, + val isOptionSet: Boolean, +) diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/TrackedEntitySearchItemResult.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/TrackedEntitySearchItemResult.kt new file mode 100644 index 00000000000..30c973fa10b --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/model/TrackedEntitySearchItemResult.kt @@ -0,0 +1,113 @@ +package org.dhis2.tracker.search.model + +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.relationships.model.RelationshipModel +import kotlin.time.Instant + +data class TrackedEntitySearchItemResult( + val uid: String, + val created: Instant?, + val lastUpdated: Instant?, + val createdAtClient: Instant?, + val lastUpdatedAtClient: Instant?, + val ownerOrgUnit: String?, + val enrollmentOrgUnit: String?, + val shouldDisplayOrgUnit: Boolean, + val geometry: TrackedEntityGeometry?, + val syncState: SyncState?, + val aggregatedSyncState: SyncState?, + val deleted: Boolean, + val isOnline: Boolean, + val teTypeName: String?, + val type: TrackedEntityTypeDomain, + val header: String?, + val overDueDate: Instant?, + val selectedEnrollment: DomainEnrollment?, + val profilePicture: String?, + val enrolledPrograms: List?, + val enrollments: List?, + val relationships: List?, + val defaultTypeIcon: String?, + val attributeValues: List, +) + +class DomainEnrollment( + val uid: String, + val orgUnit: String?, + val program: String?, + val enrollmentDate: Instant?, + val incidentDate: Instant?, + val completedDate: Instant?, + val followUp: Boolean, + val status: EnrollmentStatus, + val trackedEntityInstance: String?, +) + +class DomainProgram( + val uid: String, + val displayName: String, + val style: DomainObjectStyle, +) + +class DomainObjectStyle( + val icon: String?, + val color: String?, +) + +enum class EnrollmentStatus { + ACTIVE, + COMPLETED, + CANCELLED, +} + +data class TrackedEntitySearchItemAttributeDomain( + val attribute: String, + val displayName: String, + val displayFormName: String, + val value: String?, + val valueType: TrackerInputType, + val displayInList: Boolean, + val optionSet: String?, +) + +data class TrackedEntityTypeDomain( + val trackedEntityTypeAttributeDomains: List, + val featureType: GeometryFeatureType, +) + +data class TrackedEntityTypeAttributeDomain( + val trackedEntityTypeUid: String?, + val trackedEntityAttributeUid: String?, + val displayInList: Boolean, + val mandatory: Boolean, + val searchable: Boolean, + val sortOrder: Int, +) + +data class TrackedEntityGeometry( + val geometryFeatureType: GeometryFeatureType, + val coordinates: String?, +) + +enum class GeometryFeatureType( + val featureType: String, + val geometryType: String, +) { + POINT("POINT", "Point"), + POLYGON("POLYGON", "Polygon"), + MULTI_POLYGON("MULTI_POLYGON", "MultiPolygon"), + NONE("NONE", "None"), + SYMBOL("SYMBOL", "Symbol"), +} + +enum class SyncState { + TO_POST, + TO_UPDATE, + ERROR, + SYNCED, + WARNING, + UPLOADING, + RELATIONSHIP, + SENT_VIA_SMS, + SYNCED_VIA_SMS, +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/action/SearchScreenUiEvent.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/action/SearchScreenUiEvent.kt new file mode 100644 index 00000000000..22b8e1c5010 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/action/SearchScreenUiEvent.kt @@ -0,0 +1,9 @@ +package org.dhis2.tracker.search.ui.action + +sealed interface SearchScreenUiEvent { + data object OnSearchButtonClicked : SearchScreenUiEvent + + data object OnClearSearchButtonClicked : SearchScreenUiEvent + + data object OnCloseClicked : SearchScreenUiEvent +} diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/provider/ParameterSelectorItemProvider.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/provider/ParameterSelectorItemProvider.kt new file mode 100644 index 00000000000..b9652e9e481 --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/provider/ParameterSelectorItemProvider.kt @@ -0,0 +1,93 @@ +package org.dhis2.tracker.search.ui.provider + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material.icons.outlined.QrCode2 +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.action.TrackerInputUiEvent +import org.dhis2.tracker.input.ui.provider.TrackerInputProvider +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.hisp.dhis.mobile.ui.designsystem.component.parameter.ParameterInputStyle +import org.hisp.dhis.mobile.ui.designsystem.component.parameter.model.ParameterSelectorItemModel +import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor + +@Composable +fun provideParameterSelectorItem( + inputModel: TrackerInputUiState, + helperText: String, + onNextClicked: () -> Unit, + onUiEvent: (TrackerInputUiEvent) -> Unit, +): ParameterSelectorItemModel { + val status = + if (inputModel.focused) { + ParameterSelectorItemModel.Status.FOCUSED + } else if (inputModel.value.isNullOrEmpty()) { + ParameterSelectorItemModel.Status.CLOSED + } else { + ParameterSelectorItemModel.Status.UNFOCUSED + } + + return ParameterSelectorItemModel( + icon = { ProvideParameterIcon(inputModel.valueType) }, + label = inputModel.label, + helper = helperText, + inputField = { + TrackerInputProvider( + modifier = Modifier, + inputStyle = ParameterInputStyle(), + inputModel = inputModel, + onNextClicked = onNextClicked, + onUiEvent = onUiEvent, + ) + }, + status = status, + onExpand = { + onUiEvent(TrackerInputUiEvent.OnItemClick(inputModel.uid)) + when (inputModel.valueType) { + TrackerInputType.QR_CODE, + TrackerInputType.BAR_CODE, + -> + onUiEvent( + TrackerInputUiEvent.OnScanButtonClicked( + inputModel.uid, + inputModel.optionSet, + inputModel.valueType, + ), + ) + + else -> { + // no-op + } + } + }, + ) +} + +@Composable +private fun ProvideParameterIcon(valueType: TrackerInputType?) = + when (valueType) { + TrackerInputType.QR_CODE -> + Icon( + imageVector = Icons.Outlined.QrCode2, + contentDescription = "QR Code Icon", + tint = SurfaceColor.Primary, + ) + + TrackerInputType.BAR_CODE -> + Icon( + painter = provideDHIS2Icon("material_barcode_scanner"), + contentDescription = "Barcode Icon", + tint = SurfaceColor.Primary, + ) + + else -> + Icon( + imageVector = Icons.Outlined.AddCircleOutline, + contentDescription = "Add Icon", + tint = SurfaceColor.Primary, + ) + } diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/screen/SearchParametersScreen.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/screen/SearchParametersScreen.kt new file mode 100644 index 00000000000..cacf4b0d15a --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/screen/SearchParametersScreen.kt @@ -0,0 +1,308 @@ +package org.dhis2.tracker.search.ui.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.dhis2.mobile.tracker.resources.Res +import org.dhis2.mobile.tracker.resources.clear_search +import org.dhis2.mobile.tracker.resources.empty_search_attributes_message +import org.dhis2.mobile.tracker.resources.optional +import org.dhis2.mobile.tracker.resources.search +import org.dhis2.tracker.input.ui.action.TrackerInputUiEvent +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.input.ui.state.TrackerOptionItem +import org.dhis2.tracker.input.ui.state.loadOptionSetConfiguration +import org.dhis2.tracker.search.ui.action.SearchScreenUiEvent +import org.dhis2.tracker.search.ui.provider.provideParameterSelectorItem +import org.dhis2.tracker.search.ui.state.SearchParametersUiState +import org.dhis2.tracker.search.ui.viewmodel.SearchParametersViewModel +import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItemColor +import org.hisp.dhis.mobile.ui.designsystem.component.Button +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle +import org.hisp.dhis.mobile.ui.designsystem.component.InfoBar +import org.hisp.dhis.mobile.ui.designsystem.component.parameter.ParameterSelectorItem +import org.hisp.dhis.mobile.ui.designsystem.theme.Radius +import org.hisp.dhis.mobile.ui.designsystem.theme.Shape +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun SearchParametersScreen( + externalUiState: SearchParametersUiState, + onSearchScreenUiEvent: (SearchScreenUiEvent) -> Unit, + onTrackerInputUiEvent: (TrackerInputUiEvent) -> Unit, + isLandscape: Boolean, + getOptionSetFlow: (fieldUid: String, optionSetUid: String) -> Flow>?, + onOptionSetSearch: (fieldUid: String, query: String) -> Unit, +) { + val viewModel = koinViewModel() + + LaunchedEffect(externalUiState) { + viewModel.updateFromExternal(externalUiState) + } + + val uiState by viewModel.uiState.collectAsState() + val validationResult by viewModel.validationResult.collectAsState() + + val snackBarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + + uiState.minAttributesMessage?.let { message -> + coroutineScope.launch { + uiState.shouldShowMinAttributeWarning.collectLatest { + if (it) { + snackBarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Short, + ) + uiState.updateMinAttributeWarning(false) + } + } + } + } + + LaunchedEffect(uiState.isOnBackPressed) { + uiState.isOnBackPressed.collectLatest { + manageOnBackPressed(it, focusManager, onSearchScreenUiEvent) + } + } + + LaunchedEffect(validationResult) { + validationResult?.let { isValid -> + if (isValid) { + onSearchScreenUiEvent(SearchScreenUiEvent.OnSearchButtonClicked) + } + + viewModel.resetValidationResult() + } + } + + val backgroundShape = getBackgroundShape(isLandscape) + + Scaffold( + containerColor = Color.Transparent, + snackbarHost = { + SnackbarHost( + hostState = snackBarHostState, + modifier = + Modifier.padding( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 48.dp, + ), + ) + }, + ) { paddingValues -> + val layoutDirection = LocalLayoutDirection.current + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = Color.White, shape = backgroundShape) + .padding( + top = 0.dp, + bottom = paddingValues.calculateBottomPadding(), + start = paddingValues.calculateStartPadding(layoutDirection), + end = paddingValues.calculateEndPadding(layoutDirection), + ), + ) { + LazyColumn( + modifier = + Modifier + .weight(1F) + .testTag(SEARCH_PARAMETERS_LIST_TAG), + ) { + if (uiState.items.isEmpty()) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + InfoBar( + modifier = Modifier.testTag("EMPTY_SEARCH_ATTRIBUTES_TEXT_TAG"), + text = stringResource(Res.string.empty_search_attributes_message), + icon = { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = "warning", + tint = AdditionalInfoItemColor.WARNING.color, + ) + }, + textColor = AdditionalInfoItemColor.WARNING.color, + backgroundColor = AdditionalInfoItemColor.WARNING.color.copy(alpha = 0.1f), + ) + } + } + } else { + itemsIndexed( + items = uiState.items, + key = { _, fieldUiModel -> + fieldUiModel.uid + }, + ) { index, trackerInputModel -> + ParameterSelectorItem( + modifier = + Modifier + .testTag("SEARCH_PARAM_ITEM"), + model = + provideParameterSelectorItem( + inputModel = + trackerInputModel.loadOptionSetConfiguration( + getOptionSetFlow = getOptionSetFlow, + onOptionSetSearch = onOptionSetSearch, + ), + helperText = stringResource(Res.string.optional), + onNextClicked = { + manageOnNextClick( + index, + uiState.items, + onTrackerInputUiEvent, + ) + }, + onUiEvent = onTrackerInputUiEvent, + ), + ) + } + } + + if (uiState.clearSearchEnabled) { + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Button( + modifier = Modifier.padding(16.dp, 24.dp, 16.dp, 8.dp), + style = ButtonStyle.TEXT, + text = stringResource(Res.string.clear_search), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "clear search", + tint = SurfaceColor.Primary, + ) + }, + ) { + focusManager.clearFocus() + onSearchScreenUiEvent(SearchScreenUiEvent.OnClearSearchButtonClicked) + } + } + } + } + } + + Button( + enabled = uiState.searchEnabled, + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp, 8.dp, 16.dp, 8.dp) + .testTag("SEARCH_BUTTON"), + style = ButtonStyle.FILLED, + text = stringResource(Res.string.search), + icon = { + val iconTint = getButtonColour(uiState.searchEnabled) + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = "search", + tint = iconTint, + ) + }, + ) { + focusManager.clearFocus() + viewModel.onValidateSearch() + } + } + } +} + +private fun manageOnBackPressed( + it: Boolean, + focusManager: FocusManager, + onSearchScreenUiEvent: (SearchScreenUiEvent) -> Unit, +) { + if (it) { + focusManager.clearFocus() + onSearchScreenUiEvent(SearchScreenUiEvent.OnCloseClicked) + } +} + +private fun manageOnNextClick( + index: Int, + items: List, + onTrackerInputUiEvent: (TrackerInputUiEvent) -> Unit, +) { + val nextIndex = index + 1 + if (index + 1 < items.size && nextIndex < items.size) { + val nexItemUid = items[nextIndex].uid + onTrackerInputUiEvent( + TrackerInputUiEvent.OnItemClick( + nexItemUid, + ), + ) + } +} + +fun getBackgroundShape(isLandscape: Boolean): RoundedCornerShape = + if (isLandscape) { + RoundedCornerShape( + topStart = CornerSize(Radius.L), + topEnd = CornerSize(Radius.NoRounding), + bottomEnd = CornerSize(Radius.NoRounding), + bottomStart = CornerSize(Radius.NoRounding), + ) + } else { + Shape.LargeTop + } + +fun getButtonColour(enabled: Boolean): Color = + if (enabled) { + TextColor.OnPrimary + } else { + TextColor.OnDisabledSurface + } + +const val SEARCH_PARAMETERS_LIST_TAG = "SEARCH_PARAMETERS_LIST_TAG" diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/model/SearchParametersUiState.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/state/SearchParametersUiState.kt similarity index 86% rename from app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/model/SearchParametersUiState.kt rename to tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/state/SearchParametersUiState.kt index be65022a20e..3f3fed50f4b 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/model/SearchParametersUiState.kt +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/state/SearchParametersUiState.kt @@ -1,11 +1,11 @@ -package org.dhis2.usescases.searchTrackEntity.searchparameters.model +package org.dhis2.tracker.search.ui.state import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import org.dhis2.form.model.FieldUiModel +import org.dhis2.tracker.input.ui.state.TrackerInputUiState data class SearchParametersUiState( - val items: List = listOf(), + val items: List = listOf(), val minAttributesMessage: String? = null, private val _shouldShowMinAttributeWarning: MutableSharedFlow = MutableSharedFlow( diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/viewmodel/SearchParametersViewModel.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/viewmodel/SearchParametersViewModel.kt new file mode 100644 index 00000000000..3ea6a59c0cb --- /dev/null +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/search/ui/viewmodel/SearchParametersViewModel.kt @@ -0,0 +1,72 @@ +package org.dhis2.tracker.search.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.dhis2.mobile.commons.resources.StringResourceProvider +import org.dhis2.mobile.tracker.resources.Res +import org.dhis2.mobile.tracker.resources.min_characters_warning +import org.dhis2.tracker.search.ui.state.SearchParametersUiState + +class SearchParametersViewModel( + private val resourceProvider: StringResourceProvider, +) : ViewModel() { + private val _uiState = MutableStateFlow(SearchParametersUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _validationResult = MutableStateFlow(null) + val validationResult: StateFlow = _validationResult.asStateFlow() + + fun updateFromExternal(externalState: SearchParametersUiState) { + _uiState.value = externalState + } + + fun onValidateSearch() { + viewModelScope.launch { + _validationResult.value = onValidateMinCharacters() + } + } + + private suspend fun onValidateMinCharacters(): Boolean { + val items = _uiState.value.items + // Check if there are invalid items to later check the warning + val invalidItems = + items.filter { item -> + val min = item.minCharactersToSearch ?: return@filter false + val value = item.value + !value.isNullOrEmpty() && value.length < min + } + + if (invalidItems.isNotEmpty()) { + val updatedItems = + items.map { item -> + val min = item.minCharactersToSearch + if (min != null && !item.value.isNullOrEmpty() && item.value.length < min) { + item.copy( + error = + resourceProvider.provideString( + resource = Res.string.min_characters_warning, + min, + ), + ) + } else { + item.copy(error = null) + } + } + _uiState.update { it.copy(items = updatedItems) } + return false + } + + val clearedItems = items.map { it.copy(error = null) } + _uiState.update { it.copy(items = clearedItems) } + return true + } + + fun resetValidationResult() { + _validationResult.value = null + } +} diff --git a/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/SearchParametersIntegrationTest.kt b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/SearchParametersIntegrationTest.kt new file mode 100644 index 00000000000..881b0f3364f --- /dev/null +++ b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/SearchParametersIntegrationTest.kt @@ -0,0 +1,290 @@ +package org.dhis2.tracker.search + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.dhis2.mobile.commons.coroutine.Dispatcher +import org.dhis2.mobile.commons.resources.StringResourceProvider +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.input.ui.mapper.toTrackerInputUiState +import org.dhis2.tracker.input.ui.state.supportingTextList +import org.dhis2.tracker.search.data.SearchParametersRepository +import org.dhis2.tracker.search.domain.FetchSearchParameters +import org.dhis2.tracker.search.model.FetchSearchParametersData +import org.dhis2.tracker.search.model.SearchOperator +import org.dhis2.tracker.search.model.SearchParameterModel +import org.dhis2.tracker.search.model.hasLabel +import org.dhis2.tracker.search.ui.state.SearchParametersUiState +import org.dhis2.tracker.search.ui.viewmodel.SearchParametersViewModel +import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class SearchParametersIntegrationTest { + private val testDispatcher = StandardTestDispatcher() + private val dispatcher = + Dispatcher( + io = testDispatcher, + main = testDispatcher, + default = testDispatcher, + ) + + private val repository: SearchParametersRepository = mock() + private val resourceProvider: StringResourceProvider = mock() + + private lateinit var fetchSearchParameters: FetchSearchParameters + private lateinit var viewModel: SearchParametersViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + fetchSearchParameters = FetchSearchParameters(dispatcher, repository) + viewModel = SearchParametersViewModel(resourceProvider) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `given min character limit is configured, when user enters exactly min characters, then search proceeds successfully`() = + runTest(testDispatcher) { + // Given + val min = 2 + val teiTypeUid = "teiType123" + val searchParameter = + buildSearchParameter( + uid = "field1", + minCharactersToSearch = min, + ) + whenever(repository.getSearchParametersByTrackedEntityType(teiTypeUid)) + .thenReturn(listOf(searchParameter)) + + val fetchResult = + fetchSearchParameters(FetchSearchParametersData(teiTypeUid = teiTypeUid)) + assertTrue(fetchResult.isSuccess) + + // When - user enters exactly min characters + val itemWithExactMinChars = + fetchResult + .getOrNull()!! + .map { it.toTrackerInputUiState().copy(value = "a".repeat(min)) } + viewModel.updateFromExternal(SearchParametersUiState(items = itemWithExactMinChars)) + viewModel.onValidateSearch() + advanceUntilIdle() + + // Then - search proceeds successfully + val validationResult = viewModel.validationResult.first() + assertEquals(true, validationResult) + + // And no error message is shown + val uiState = viewModel.uiState.first() + assertTrue(uiState.items.all { it.error == null }) + } + + @Test + fun `given min character limit, when user enters fewer than min characters, then search fails and error is shown`() = + runTest(testDispatcher) { + // Given + val min = 3 + val teiTypeUid = "teiType123" + val errorMessage = "Minimum $min characters required" + val searchParameter = + buildSearchParameter( + uid = "field1", + minCharactersToSearch = min, + ) + whenever(repository.getSearchParametersByTrackedEntityType(teiTypeUid)) + .thenReturn(listOf(searchParameter)) + whenever(resourceProvider.provideString(any(), any())).thenReturn(errorMessage) + + val fetchResult = + fetchSearchParameters(FetchSearchParametersData(teiTypeUid = teiTypeUid)) + assertTrue(fetchResult.isSuccess) + + // When - user enters fewer than min characters + val itemWithInsufficientChars = + fetchResult + .getOrNull()!! + .map { it.toTrackerInputUiState().copy(value = "a".repeat(min - 1)) } + viewModel.updateFromExternal(SearchParametersUiState(items = itemWithInsufficientChars)) + viewModel.onValidateSearch() + advanceUntilIdle() + + // Then - search proceeds unsuccessfully + val validationResult = viewModel.validationResult.first() + assertEquals(false, validationResult) + + // And an error message is shown + val uiState = viewModel.uiState.first() + assertNull(uiState.items.firstOrNull { it.error == null }) + assertEquals(errorMessage, uiState.items.first().error) + } + + @Test + fun `given a configured search operator, when user clicks field, then search operator message is shown`() = + runTest(testDispatcher) { + // Given + val teiTypeUid = "teiType123" + val searchParameters = + listOf( + buildSearchParameter( + uid = "field1", + minCharactersToSearch = 0, + searchOperator = SearchOperator.EQ, + ), + buildSearchParameter( + uid = "field2", + minCharactersToSearch = 0, + searchOperator = SearchOperator.LIKE, + ), + buildSearchParameter( + uid = "field3", + minCharactersToSearch = 0, + searchOperator = SearchOperator.SW, + ), + buildSearchParameter( + uid = "field4", + minCharactersToSearch = 0, + searchOperator = SearchOperator.EW, + ), + ) + + whenever(repository.getSearchParametersByTrackedEntityType(teiTypeUid)) + .thenReturn(searchParameters) + + val fetchResult = + fetchSearchParameters(FetchSearchParametersData(teiTypeUid = teiTypeUid)) + assertTrue(fetchResult.isSuccess) + + fetchResult + .getOrNull() + ?.forEach { searchParam -> + // Simulate the user clicking the field (focused = true) + val item = searchParam.toTrackerInputUiState().copy(focused = true) + val operator = searchParam.searchOperator + + // Mimic what the Composable layer does: resolve a label only when the + // operator has one (stringResource is not available in unit tests). + val operatorLabel = if (operator?.hasLabel() == true) "operator label" else null + val supportingText = item.supportingTextList(searchOperatorLabel = operatorLabel) + + if (operator?.hasLabel() == true) { + // EQ, SW, EW – a DEFAULT supporting text entry must be present + assertNotNull(supportingText) + assertTrue(supportingText.any { it.state == SupportingTextState.DEFAULT }) + } else { + // LIKE – no label, no error/warning/description → null + assertNull(supportingText) + } + } + } + + @Test + fun `given a configured search operator with error, when user clicks field, supporting text color in Red`() = + runTest(testDispatcher) { + // Given + val teiTypeUid = "teiType123" + val searchParameters = + listOf( + buildSearchParameter( + uid = "field1", + minCharactersToSearch = 0, + searchOperator = SearchOperator.EQ, + ), + ) + + whenever(repository.getSearchParametersByTrackedEntityType(teiTypeUid)) + .thenReturn(searchParameters) + + val fetchResult = + fetchSearchParameters(FetchSearchParametersData(teiTypeUid = teiTypeUid)) + assertTrue(fetchResult.isSuccess) + + val itemWithError = + fetchResult + .getOrNull()!! + .map { searchParam -> + searchParam.toTrackerInputUiState().copy(error = "any Error", focused = true) + } + viewModel.updateFromExternal(SearchParametersUiState(items = itemWithError)) + val uiState = viewModel.uiState.first() + val supportingListData = + uiState + .items + .first() + .supportingTextList(null) + ?.firstOrNull() + assertNotNull(supportingListData) + assertEquals(SupportingTextState.ERROR, supportingListData.state) + } + + @Test + fun `given a configured search operator with warning, when user clicks field, supporting text color in Orange`() = + runTest(testDispatcher) { + // Given + val teiTypeUid = "teiType123" + val searchParameters = + listOf( + buildSearchParameter( + uid = "field1", + minCharactersToSearch = 0, + searchOperator = SearchOperator.EQ, + ), + ) + + whenever(repository.getSearchParametersByTrackedEntityType(teiTypeUid)) + .thenReturn(searchParameters) + + val fetchResult = + fetchSearchParameters(FetchSearchParametersData(teiTypeUid = teiTypeUid)) + assertTrue(fetchResult.isSuccess) + + val itemWithError = + fetchResult + .getOrNull()!! + .map { searchParam -> + searchParam.toTrackerInputUiState().copy(warning = "any Warning", focused = true) + } + viewModel.updateFromExternal(SearchParametersUiState(items = itemWithError)) + val uiState = viewModel.uiState.first() + val supportingListData = + uiState.items + .first() + .supportingTextList(null) + ?.firstOrNull() + assertNotNull(supportingListData) + assertEquals(SupportingTextState.WARNING, supportingListData.state) + } + + private fun buildSearchParameter( + uid: String, + minCharactersToSearch: Int, + searchOperator: SearchOperator? = null, + ): SearchParameterModel = + SearchParameterModel( + uid = uid, + label = "First Name", + inputType = TrackerInputType.TEXT, + optionSet = null, + customIntentUid = null, + minCharactersToSearch = minCharactersToSearch, + searchOperator = searchOperator, + isUnique = false, + ) +} diff --git a/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/domain/FetchSearchParametersTest.kt b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/domain/FetchSearchParametersTest.kt new file mode 100644 index 00000000000..9b97999b5c5 --- /dev/null +++ b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/domain/FetchSearchParametersTest.kt @@ -0,0 +1,340 @@ +package org.dhis2.tracker.search.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.coroutine.Dispatcher +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.tracker.input.model.TrackerInputType +import org.dhis2.tracker.search.data.SearchParametersRepository +import org.dhis2.tracker.search.model.FetchSearchParametersData +import org.dhis2.tracker.search.model.SearchOperator +import org.dhis2.tracker.search.model.SearchParameterModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class FetchSearchParametersTest { + private lateinit var useCase: FetchSearchParameters + + private val repository: SearchParametersRepository = mock() + + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val dispatcher = + Dispatcher( + io = testDispatcher, + main = Dispatchers.Unconfined, + default = Dispatchers.Unconfined, + ) + + @Before + fun setup() { + useCase = + FetchSearchParameters( + dispatcher = dispatcher, + repository = repository, + ) + } + + @Test + fun `invoke should return sorted parameters for program`() = + runTest { + // Given + val programUid = "programUid123" + val teiTypeUid = "teiTypeUid123" + val input = FetchSearchParametersData(teiTypeUid = teiTypeUid, programUid = programUid) + + val parameters = + listOf( + createSearchParameter("param1", TrackerInputType.TEXT, isUnique = false), + createSearchParameter("param2", TrackerInputType.QR_CODE, isUnique = true), + createSearchParameter("param3", TrackerInputType.BAR_CODE, isUnique = false), + createSearchParameter( + "param4", + TrackerInputType.IMAGE, + isUnique = false, + ), + ) + + whenever(repository.getSearchParametersByProgram(programUid)) doReturn parameters + + // When + val result = useCase.invoke(input) + + // Then + assertTrue(result.isSuccess) + verify(repository).getSearchParametersByProgram(programUid) + + val sortedParameters = result.getOrNull() + assertEquals(3, sortedParameters?.size) + // QR_CODE unique should be first + assertEquals("param2", sortedParameters?.get(0)?.uid) + assertEquals(TrackerInputType.QR_CODE, sortedParameters?.get(0)?.inputType) + assertTrue(sortedParameters?.get(0)?.isUnique == true) + } + + @Test + fun `invoke should return sorted parameters for tracked entity type when no program`() = + runTest { + // Given + val teiTypeUid = "teiTypeUid123" + val input = FetchSearchParametersData(teiTypeUid = teiTypeUid, programUid = null) + + val parameters = + listOf( + createSearchParameter("param1", TrackerInputType.TEXT, isUnique = true), + createSearchParameter("param2", TrackerInputType.EMAIL, isUnique = false), + ) + + whenever(repository.getSearchParametersByTrackedEntityType(teiTypeUid)) doReturn parameters + + // When + val result = useCase.invoke(input) + + // Then + assertTrue(result.isSuccess) + verify(repository).getSearchParametersByTrackedEntityType(teiTypeUid) + + val sortedParameters = result.getOrNull() + assertEquals(2, sortedParameters?.size) + } + + @Test + fun `invoke should return failure when repository throws DomainError`() = + runTest { + // Given + val teiTypeUid = "teiTypeUid123" + val input = FetchSearchParametersData(teiTypeUid = teiTypeUid, programUid = null) + val domainError = DomainError.UnexpectedError("Test error") + + whenever(repository.getSearchParametersByTrackedEntityType(teiTypeUid)).thenAnswer { throw domainError } + + val result = useCase.invoke(input) + + // Then + assertTrue(result.isFailure) + assertEquals(domainError, result.exceptionOrNull()) + } + + @Test + fun `sortSearchParameters should prioritize QR and BarCode unique parameters`() { + // Given + val parameters = + listOf( + createSearchParameter("text1", TrackerInputType.TEXT, isUnique = false), + createSearchParameter("qr1", TrackerInputType.QR_CODE, isUnique = true), + createSearchParameter("text2", TrackerInputType.TEXT, isUnique = true), + createSearchParameter("bar1", TrackerInputType.BAR_CODE, isUnique = true), + createSearchParameter("qr2", TrackerInputType.QR_CODE, isUnique = false), + ) + + // When + val sorted = useCase.sortSearchParameters(parameters) + + // Then + assertEquals(5, sorted.size) + // First two should be QR_CODE or BAR_CODE with unique + assertTrue(useCase.isQrCodeOrBarCode(sorted[0].inputType) && sorted[0].isUnique) + assertTrue(useCase.isQrCodeOrBarCode(sorted[1].inputType) && sorted[1].isUnique) + // Third should be QR_CODE without unique + assertTrue(useCase.isQrCodeOrBarCode(sorted[2].inputType)) + // Fourth should be text with unique + assertEquals(TrackerInputType.TEXT, sorted[3].inputType) + assertTrue(sorted[3].isUnique) + // Last should be text without unique + assertEquals(TrackerInputType.TEXT, sorted[4].inputType) + assertFalse(sorted[4].isUnique) + } + + @Test + fun `sortSearchParameters should handle empty list`() { + // Given + val parameters = emptyList() + + // When + val sorted = useCase.sortSearchParameters(parameters) + + // Then + assertTrue(sorted.isEmpty()) + } + + @Test + fun `sortSearchParameters should handle list with only non-QR-BarCode parameters`() { + // Given + val parameters = + listOf( + createSearchParameter("text1", TrackerInputType.TEXT, isUnique = false), + createSearchParameter("email1", TrackerInputType.EMAIL, isUnique = true), + createSearchParameter("number1", TrackerInputType.NUMBER, isUnique = false), + ) + + // When + val sorted = useCase.sortSearchParameters(parameters) + + // Then + assertEquals(3, sorted.size) + // Unique should come before non-unique + assertTrue(sorted[0].isUnique) + assertEquals("email1", sorted[0].uid) + assertFalse(sorted[1].isUnique) + assertFalse(sorted[2].isUnique) + } + + @Test + fun `sortSearchParameters should handle list with only QR and BarCode parameters`() { + // Given + val parameters = + listOf( + createSearchParameter("qr1", TrackerInputType.QR_CODE, isUnique = false), + createSearchParameter("bar1", TrackerInputType.BAR_CODE, isUnique = true), + createSearchParameter("qr2", TrackerInputType.QR_CODE, isUnique = true), + createSearchParameter("bar2", TrackerInputType.BAR_CODE, isUnique = false), + ) + + // When + val sorted = useCase.sortSearchParameters(parameters) + + // Then + assertEquals(4, sorted.size) + // First two should be unique QR or BarCode + assertTrue(sorted[0].isUnique) + assertTrue(sorted[1].isUnique) + // Last two should be non-unique QR or BarCode + assertFalse(sorted[2].isUnique) + assertFalse(sorted[3].isUnique) + } + + @Test + fun `isQrCodeOrBarCode should return true for QR_CODE`() { + // When + val result = useCase.isQrCodeOrBarCode(TrackerInputType.QR_CODE) + + // Then + assertTrue(result) + } + + @Test + fun `isQrCodeOrBarCode should return true for BAR_CODE`() { + // When + val result = useCase.isQrCodeOrBarCode(TrackerInputType.BAR_CODE) + + // Then + assertTrue(result) + } + + @Test + fun `isQrCodeOrBarCode should return false for TEXT`() { + // When + val result = useCase.isQrCodeOrBarCode(TrackerInputType.TEXT) + + // Then + assertFalse(result) + } + + @Test + fun `isQrCodeOrBarCode should return false for null`() { + // When + val result = useCase.isQrCodeOrBarCode(null) + + // Then + assertFalse(result) + } + + @Test + fun `isQrCodeOrBarCode should return false for other input types`() { + // Test various other input types + assertFalse(useCase.isQrCodeOrBarCode(TrackerInputType.EMAIL)) + assertFalse(useCase.isQrCodeOrBarCode(TrackerInputType.NUMBER)) + assertFalse(useCase.isQrCodeOrBarCode(TrackerInputType.DATE)) + assertFalse(useCase.isQrCodeOrBarCode(TrackerInputType.DATE_TIME)) + assertFalse(useCase.isQrCodeOrBarCode(TrackerInputType.PHONE_NUMBER)) + assertFalse(useCase.isQrCodeOrBarCode(TrackerInputType.ORGANISATION_UNIT)) + } + + @Test + fun `sortSearchParameters should maintain stable order for equal priority items`() { + // Given - multiple items with same priority + val parameters = + listOf( + createSearchParameter("text1", TrackerInputType.TEXT, isUnique = false), + createSearchParameter("text2", TrackerInputType.TEXT, isUnique = false), + createSearchParameter("text3", TrackerInputType.TEXT, isUnique = false), + ) + + // When + val sorted = useCase.sortSearchParameters(parameters) + + // Then - order should be maintained + assertEquals(3, sorted.size) + assertEquals("text1", sorted[0].uid) + assertEquals("text2", sorted[1].uid) + assertEquals("text3", sorted[2].uid) + } + + @Test + fun `sortSearchParameters should handle complex mixed scenario`() { + // Given - a complex realistic scenario + val parameters = + listOf( + createSearchParameter("firstName", TrackerInputType.TEXT, isUnique = false), + createSearchParameter("nationalId", TrackerInputType.TEXT, isUnique = true), + createSearchParameter("qrCode", TrackerInputType.QR_CODE, isUnique = true), + createSearchParameter("email", TrackerInputType.EMAIL, isUnique = false), + createSearchParameter("barCode", TrackerInputType.BAR_CODE, isUnique = false), + createSearchParameter("uniqueCode", TrackerInputType.TEXT, isUnique = true), + createSearchParameter("scanQr", TrackerInputType.QR_CODE, isUnique = false), + ) + + // When + val sorted = useCase.sortSearchParameters(parameters) + + // Then - verify correct sorting priority + assertEquals(7, sorted.size) + + // Position 0: QR_CODE unique + assertEquals("qrCode", sorted[0].uid) + assertTrue(sorted[0].isUnique) + assertEquals(TrackerInputType.QR_CODE, sorted[0].inputType) + + // Position 1-2: BAR_CODE or QR_CODE non-unique + assertTrue(useCase.isQrCodeOrBarCode(sorted[1].inputType)) + assertTrue(useCase.isQrCodeOrBarCode(sorted[2].inputType)) + + // Position 3-4: Other unique parameters + assertTrue(sorted[3].isUnique) + assertTrue(sorted[4].isUnique) + assertFalse(useCase.isQrCodeOrBarCode(sorted[3].inputType)) + assertFalse(useCase.isQrCodeOrBarCode(sorted[4].inputType)) + + // Position 5-6: Non-unique, non-QR/BarCode + assertFalse(sorted[5].isUnique) + assertFalse(sorted[6].isUnique) + assertFalse(useCase.isQrCodeOrBarCode(sorted[5].inputType)) + assertFalse(useCase.isQrCodeOrBarCode(sorted[6].inputType)) + } + + // Helper function to create SearchParameterModel for testing + private fun createSearchParameter( + uid: String, + inputType: TrackerInputType, + isUnique: Boolean, + ): SearchParameterModel = + SearchParameterModel( + uid = uid, + label = "Label for $uid", + inputType = inputType, + optionSet = null, + customIntentUid = null, + minCharactersToSearch = null, + searchOperator = SearchOperator.LIKE, + isUnique = isUnique, + ) +} diff --git a/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/domain/SearchTrackedEntitiesTest.kt b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/domain/SearchTrackedEntitiesTest.kt new file mode 100644 index 00000000000..1f36c0b3776 --- /dev/null +++ b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/domain/SearchTrackedEntitiesTest.kt @@ -0,0 +1,556 @@ +package org.dhis2.tracker.search.domain + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.commons.customintents.CustomIntentRepository +import org.dhis2.mobile.commons.error.DomainError +import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel +import org.dhis2.tracker.search.data.SearchTrackedEntityRepository +import org.dhis2.tracker.search.model.QueryData +import org.dhis2.tracker.search.model.SearchOperator +import org.dhis2.tracker.search.model.SearchTrackedEntitiesInput +import org.dhis2.tracker.search.model.SearchTrackedEntityAttribute +import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SearchTrackedEntitiesTest { + private lateinit var useCase: SearchTrackedEntities + + private val repository: SearchTrackedEntityRepository = mock() + private val customIntentRepository: CustomIntentRepository = mock() + private val teType = "personType" + private val programUid = "programUid" + + @Before + fun setup() { + useCase = + SearchTrackedEntities( + repository = repository, + customIntentRepository = customIntentRepository, + teType = teType, + ) + } + + @Test + fun `invoke should successfully return flow when query has data`() = + runTest { + // Given + val queryDataList = + mutableListOf( + QueryData( + attributeId = "attr1", + values = listOf("value1"), + searchOperator = null, + ), + QueryData( + attributeId = "attr2", + values = listOf("value2"), + searchOperator = null, + ), + ) + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = true, + queryDataList = queryDataList, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.isTETypeAttribute(any(), any())) doReturn true + whenever(repository.getTEAttribute(any())) doReturn + SearchTrackedEntityAttribute( + isUnique = false, + isOptionSet = false, + ) + whenever( + customIntentRepository.attributeHasCustomIntentAndReturnsAListOfValues( + any(), + eq(CustomIntentActionTypeModel.SEARCH), + ), + ) doReturn false + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).addFiltersToQuery(programUid, teType) + verify(repository).addToQuery( + dataId = "attr1", + dataValues = listOf("value1"), + searchOperator = null, + ) + verify(repository).addToQuery( + dataId = "attr2", + dataValues = listOf("value2"), + searchOperator = null, + ) + verify(repository).fetchResults( + isOnline = eq(true), + hasStateFilters = eq(false), + allowCache = eq(true), + selectedProgram = eq(programUid), + ) + } + + @Test + fun `invoke should successfully return flow when query is null`() = + runTest { + // Given + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = false, + queryDataList = null, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).addFiltersToQuery("programUid", teType) + verify(repository, never()).addToQuery(any(), any(), any()) + verify(repository).fetchResults( + isOnline = false, + hasStateFilters = false, + allowCache = true, + selectedProgram = programUid, + ) + } + + @Test + fun `invoke should exclude values when no program is selected`() = + runTest { + // Given + val excludedValues = hashSetOf("excludedUid1", "excludedUid2") + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = null, + allowCache = true, + excludeValues = excludedValues, + hasStateFilters = false, + isOnline = false, + queryDataList = null, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).excludeValuesFromQuery(excludedValues.toList()) + } + + @Test + fun `invoke should not exclude values when program is selected`() = + runTest { + // Given + val excludedValues = hashSetOf("excludedUid1", "excludedUid2") + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = excludedValues, + hasStateFilters = false, + isOnline = false, + queryDataList = null, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository, never()).excludeValuesFromQuery(any()) + } + + @Test + fun `invoke should add query for unique attributes`() = + runTest { + // Given + val queryData = + mutableListOf( + QueryData( + attributeId = "uniqueAttr", + values = listOf("exactValue"), + searchOperator = SearchOperator.EQ, + ), + ) + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = true, + queryDataList = queryData, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.isTETypeAttribute(eq(teType), any())) doReturn true + whenever(repository.getTEAttribute("uniqueAttr")) doReturn + SearchTrackedEntityAttribute( + isUnique = true, + isOptionSet = false, + ) + whenever( + customIntentRepository.attributeHasCustomIntentAndReturnsAListOfValues( + any(), + eq(CustomIntentActionTypeModel.SEARCH), + ), + ) doReturn false + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).addToQuery( + dataId = "uniqueAttr", + dataValues = listOf("exactValue"), + searchOperator = SearchOperator.EQ, + ) + } + + @Test + fun `invoke should add query for option set attributes`() = + runTest { + // Given + val queryData = + mutableListOf( + QueryData( + attributeId = "optionSetAttr", + values = listOf("optionValue"), + searchOperator = SearchOperator.EQ, + ), + ) + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = true, + queryDataList = queryData, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.isTETypeAttribute(eq(teType), any())) doReturn true + whenever(repository.getTEAttribute("optionSetAttr")) doReturn + SearchTrackedEntityAttribute( + isUnique = false, + isOptionSet = true, + ) + whenever( + customIntentRepository.attributeHasCustomIntentAndReturnsAListOfValues( + any(), + eq(CustomIntentActionTypeModel.SEARCH), + ), + ) doReturn false + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).addToQuery( + dataId = "optionSetAttr", + dataValues = listOf("optionValue"), + searchOperator = SearchOperator.EQ, + ) + } + + @Test + fun `invoke should join multiple values when custom intent does not return list`() = + runTest { + // Given + val queryData = + mutableListOf( + QueryData( + attributeId = "attr1", + values = listOf("value1", "value2", "value3"), + searchOperator = null, + ), + ) + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = true, + queryDataList = queryData, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.isTETypeAttribute(eq(teType), any())) doReturn true + whenever(repository.getTEAttribute(any())) doReturn + SearchTrackedEntityAttribute( + isUnique = false, + isOptionSet = false, + ) + whenever( + customIntentRepository.attributeHasCustomIntentAndReturnsAListOfValues( + "attr1", + CustomIntentActionTypeModel.SEARCH, + ), + ) doReturn false + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).addToQuery( + dataId = "attr1", + dataValues = listOf("value1,value2,value3"), + searchOperator = null, + ) + } + + @Test + fun `invoke should keep multiple values when custom intent returns list`() = + runTest { + // Given + val queryData = + mutableListOf( + QueryData( + attributeId = "attr1", + values = listOf("value1", "value2", "value3"), + searchOperator = null, + ), + ) + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = true, + queryDataList = queryData, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.isTETypeAttribute(eq(teType), any())) doReturn true + whenever(repository.getTEAttribute(any())) doReturn + SearchTrackedEntityAttribute( + isUnique = false, + isOptionSet = false, + ) + whenever( + customIntentRepository.attributeHasCustomIntentAndReturnsAListOfValues( + "attr1", + CustomIntentActionTypeModel.SEARCH, + ), + ) doReturn true + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).addToQuery( + dataId = "attr1", + dataValues = listOf("value1", "value2", "value3"), + searchOperator = null, + ) + } + + @Test + fun `invoke should skip attributes not belonging to teType when no program selected`() = + runTest { + // Given + val queryData = + mutableListOf( + QueryData( + attributeId = "attr1", + values = listOf("value1"), + searchOperator = SearchOperator.EQ, + ), + QueryData( + attributeId = "attr2", + values = listOf("value2"), + searchOperator = SearchOperator.LIKE, + ), + ) + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = null, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = false, + queryDataList = queryData, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.isTETypeAttribute(teType, "attr1")) doReturn true + whenever(repository.isTETypeAttribute(teType, "attr2")) doReturn false + whenever(repository.getTEAttribute("attr1")) doReturn + SearchTrackedEntityAttribute( + isUnique = false, + isOptionSet = false, + ) + whenever( + customIntentRepository.attributeHasCustomIntentAndReturnsAListOfValues( + any(), + eq(CustomIntentActionTypeModel.SEARCH), + ), + ) doReturn false + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).addToQuery( + dataId = "attr1", + dataValues = listOf("value1"), + SearchOperator.EQ, + ) + verify(repository, never()).getTEAttribute("attr2") + } + + @Test + fun `invoke should return failure when domain error occurs`() = + runTest { + // Given + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = true, + queryDataList = null, + ) + + val domainError = DomainError.UnexpectedError("Test error") + + whenever(repository.fetchResults(any(), any(), any(), any())).thenAnswer { throw domainError } + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DomainError.UnexpectedError) + assertEquals("Test error", (result.exceptionOrNull() as DomainError.UnexpectedError).message) + } + + @Test + fun `invoke should handle online search with state filters`() = + runTest { + // Given + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = false, + excludeValues = hashSetOf(), + hasStateFilters = true, + isOnline = true, + queryDataList = null, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).fetchResults( + isOnline = true, + hasStateFilters = true, + allowCache = false, + selectedProgram = programUid, + ) + } + + @Test + fun `invoke should handle offline search`() = + runTest { + // Given + val searchParams = + SearchTrackedEntitiesInput( + selectedProgram = programUid, + allowCache = true, + excludeValues = hashSetOf(), + hasStateFilters = false, + isOnline = false, + queryDataList = null, + ) + + val mockPagingData: PagingData = PagingData.empty() + val mockFlow: Flow> = flowOf(mockPagingData) + + whenever(repository.fetchResults(any(), any(), any(), any())) doReturn mockFlow + + // When + val result = useCase.invoke(searchParams) + + // Then + assertTrue(result.isSuccess) + verify(repository).fetchResults( + isOnline = false, + hasStateFilters = false, + allowCache = true, + selectedProgram = programUid, + ) + } +} diff --git a/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/ui/viewmodel/SearchParametersViewModelTest.kt b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/ui/viewmodel/SearchParametersViewModelTest.kt new file mode 100644 index 00000000000..f334b522772 --- /dev/null +++ b/tracker/src/commonTest/kotlin/org/dhis2/tracker/search/ui/viewmodel/SearchParametersViewModelTest.kt @@ -0,0 +1,165 @@ +package org.dhis2.tracker.search.ui.viewmodel + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.dhis2.mobile.commons.resources.StringResourceProvider +import org.dhis2.tracker.input.ui.state.TrackerInputUiState +import org.dhis2.tracker.search.ui.state.SearchParametersUiState +import org.junit.Assert +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +@ExperimentalCoroutinesApi +class SearchParametersViewModelTest { + private val testingDispatcher = StandardTestDispatcher() + private val resourceProvider = mock() + private lateinit var viewModel: SearchParametersViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testingDispatcher) + viewModel = SearchParametersViewModel(resourceProvider) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `onValidateSearch should set validationResult to true when all items are valid`() = + runTest(testingDispatcher) { + // Given + val validItems = + listOf( + provideItem( + uid = "test1", + label = "Test 1", + value = "validvalue", + minCharactersToSearch = 3, + ), + provideItem( + uid = "test2", + label = "Test 2", + value = "another", + minCharactersToSearch = null, + ), + provideItem( + uid = "test3", + label = "Test 3", + value = null, + minCharactersToSearch = 3, + ), + ) + + viewModel.updateFromExternal(SearchParametersUiState(items = validItems)) + + // When + viewModel.onValidateSearch() + advanceUntilIdle() + + // Then + val validationResult = viewModel.validationResult.first() + assertEquals(true, validationResult) + } + + @Test + fun `onValidateSearch should set validationResult to false when items have insufficient characters`() = + runTest(testingDispatcher) { + // Given + val invalidItems = + listOf( + provideItem( + uid = "test1", + value = "ab", + minCharactersToSearch = 3, + ), + provideItem( + uid = "test2", + label = "Test 2", + value = "abcde", + minCharactersToSearch = 5, + ), + provideItem( + uid = "test3", + label = "Test 3", + value = "a", + minCharactersToSearch = 2, + ), + ) + whenever( + resourceProvider.provideString(any(), any()), + ).thenReturn("Minimum characters required") + + viewModel.updateFromExternal(SearchParametersUiState(items = invalidItems)) + + // When + viewModel.onValidateSearch() + advanceUntilIdle() + + // Then + val validationResult = viewModel.validationResult.first() + assertNotEquals(true, validationResult) + + val uiState = viewModel.uiState.first() // Also check that error is set on the item + assertEquals(3, uiState.items.size) + Assert.assertTrue(uiState.items[0].error?.isNotEmpty() == true) // Error should be set + } + + @Test + fun `resetValidationResult should set validationResult to null`() = + runTest(testingDispatcher) { + // Given + viewModel.updateFromExternal(SearchParametersUiState(items = listOf())) + viewModel.onValidateSearch() + advanceUntilIdle() + + // When + viewModel.resetValidationResult() + + // Then + val validationResult = viewModel.validationResult.first() + assertNull(validationResult) + } + + private fun provideItem( + uid: String = "test1", + label: String = "Test 1", + value: String?, + minCharactersToSearch: Int?, + ): TrackerInputUiState = + TrackerInputUiState( + uid = uid, + label = label, + value = value, + focused = false, + valueType = mock(), + optionSet = null, + error = null, + warning = null, + description = null, + mandatory = false, + editable = true, + legend = null, + orientation = mock(), + optionSetConfiguration = null, + customIntentUid = null, + displayName = null, + orgUnitSelectorScope = null, + searchOperator = null, + minCharactersToSearch = minCharactersToSearch, + ) +} diff --git a/tracker/src/desktopMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.desktop.kt b/tracker/src/desktopMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.desktop.kt new file mode 100644 index 00000000000..94b83b71c1d --- /dev/null +++ b/tracker/src/desktopMain/kotlin/org/dhis2/tracker/search/di/TrackerSearchModule.desktop.kt @@ -0,0 +1,13 @@ +package org.dhis2.tracker.search.di + +import org.koin.core.module.Module +import org.koin.dsl.module + +/** + * Desktop-specific implementation of the tracker search repository module. + * Currently empty as desktop support is not yet implemented for search. + */ +internal actual val trackerSearchRepositoryModule: Module = + module { + // Desktop implementation placeholder + } diff --git a/ui-components/.gitignore b/ui-components/.gitignore deleted file mode 100644 index 42afabfd2ab..00000000000 --- a/ui-components/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/ui-components/build.gradle.kts b/ui-components/build.gradle.kts deleted file mode 100644 index 10ce27ba8a6..00000000000 --- a/ui-components/build.gradle.kts +++ /dev/null @@ -1,60 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - id("com.android.library") - kotlin("android") - id("kotlin-parcelize") - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.kotlin.compose.compiler) -} -apply(from = "${project.rootDir}/jacoco/jacoco.gradle.kts") - -android { - compileSdk = libs.versions.sdk.get().toInt() - namespace = "org.dhis2.ui" - - defaultConfig { - minSdk = libs.versions.minSdk.get().toInt() - testOptions.targetSdk = libs.versions.sdk.get().toInt() - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - vectorDrawables.useSupportLibrary = true - } - - buildTypes { - getByName("debug") { - } - getByName("release") { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - flavorDimensions += listOf("default") - - buildFeatures { - compose = true - } -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } -} - -dependencies { - - implementation(libs.bundles.uicomponents.implementation) - api(libs.bundles.uicomponents.api) - debugApi(libs.bundles.uicomponents.debugapi) - androidTestImplementation(libs.bundles.uicomponents.androidtest) -} diff --git a/ui-components/consumer-rules.pro b/ui-components/consumer-rules.pro deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/ui-components/proguard-rules.pro b/ui-components/proguard-rules.pro deleted file mode 100644 index 466966b2539..00000000000 --- a/ui-components/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui-components/src/main/res/drawable/ic_add_image.xml b/ui-components/src/main/res/drawable/ic_add_image.xml deleted file mode 100644 index 4383e0f436a..00000000000 --- a/ui-components/src/main/res/drawable/ic_add_image.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/ui-components/src/main/res/font/rubik_bold.ttf b/ui-components/src/main/res/font/rubik_bold.ttf deleted file mode 100644 index 50eada5c69a..00000000000 Binary files a/ui-components/src/main/res/font/rubik_bold.ttf and /dev/null differ diff --git a/ui-components/src/main/res/font/rubik_light.ttf b/ui-components/src/main/res/font/rubik_light.ttf deleted file mode 100644 index 5ffda61826b..00000000000 Binary files a/ui-components/src/main/res/font/rubik_light.ttf and /dev/null differ diff --git a/ui-components/src/main/res/font/rubik_medium.ttf b/ui-components/src/main/res/font/rubik_medium.ttf deleted file mode 100644 index 24519e56fd4..00000000000 Binary files a/ui-components/src/main/res/font/rubik_medium.ttf and /dev/null differ diff --git a/ui-components/src/main/res/font/rubik_regular.ttf b/ui-components/src/main/res/font/rubik_regular.ttf deleted file mode 100644 index fb52c8eec80..00000000000 Binary files a/ui-components/src/main/res/font/rubik_regular.ttf and /dev/null differ diff --git a/ui-components/src/main/res/raw/warning.json b/ui-components/src/main/res/raw/warning.json deleted file mode 100644 index 8deece941c4..00000000000 --- a/ui-components/src/main/res/raw/warning.json +++ /dev/null @@ -1 +0,0 @@ -{"v":"5.1.5","fr":29.9700012207031,"ip":0,"op":42.0000017106951,"w":32,"h":32,"nm":"System-states-Sin conexión","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":3,"ty":4,"nm":"! contornos","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[16,16,0],"ix":2},"a":{"a":0,"k":[8.25,8.25,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.847,0.847,0.667],"y":[1,1,1]},"o":{"x":[1,1,0.333],"y":[0,0,0]},"n":["0p847_1_1_0","0p847_1_1_0","0p667_1_0p333_0"],"t":0,"s":[100,100,100],"e":[130,130,100]},{"i":{"x":[0,0,0.667],"y":[1,1,1]},"o":{"x":[0.127,0.127,0.333],"y":[0,0,0]},"n":["0_1_0p127_0","0_1_0p127_0","0p667_1_0p333_0"],"t":21,"s":[130,130,100],"e":[100,100,100]},{"t":42.0000017106951}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.442,0],[0,-0.443],[-0.442,0],[0,0.441]],"o":[[-0.442,0],[0,0.441],[0.442,0],[0,-0.443]],"v":[[0,-0.8],[-0.8,0],[0,0.8],[0.8,0]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[8.25,12.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.221,0],[0,0.221],[0,0],[0.221,0],[0,-0.221],[0,0]],"o":[[0.221,0],[0,0],[0,-0.221],[-0.221,0],[0,0],[0,0.221]],"v":[[0,2.544],[0.4,2.144],[0.4,-2.144],[0,-2.544],[-0.4,-2.144],[-0.4,2.144]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[8.25,7.194],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-4.416],[-4.416,0],[0,4.416],[4.416,0]],"o":[[0,4.416],[4.416,0],[0,-4.416],[-4.416,0]],"v":[[-8,0],[0,8],[8,0],[0,-8]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.987999949736,0.172999991623,0.340999977261,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[8.25,8.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3597.00014650881,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Sombra contornos","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":8.4,"s":[100],"e":[0]},{"t":23.8000009693939}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[16,16,0],"ix":2},"a":{"a":0,"k":[8.25,8.25,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.847,0.847,0.667],"y":[1,1,1]},"o":{"x":[1,1,0.333],"y":[0,0,0]},"n":["0p847_1_1_0","0p847_1_1_0","0p667_1_0p333_0"],"t":0,"s":[100,100,100],"e":[180,180,100]},{"i":{"x":[0,0,0.667],"y":[1,1,1]},"o":{"x":[0.127,0.127,0.333],"y":[0,0,0]},"n":["0_1_0p127_0","0_1_0p127_0","0p667_1_0p333_0"],"t":21,"s":[180,180,100],"e":[100,100,100]},{"t":42.0000017106951}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-4.416],[-4.416,0],[0,4.416],[4.416,0]],"o":[[0,4.416],[4.416,0],[0,-4.416],[-4.416,0]],"v":[[-8,0],[0,8],[8,0],[0,-8]],"c":true},"ix":2},"nm":"Trazado 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.987999949736,0.172999991623,0.340999977261,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Relleno 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[8.25,8.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Grupo 3","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3597.00014650881,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/ui-components/src/main/res/values-ar-rEG/strings.xml b/ui-components/src/main/res/values-ar-rEG/strings.xml deleted file mode 100644 index 5db79203977..00000000000 --- a/ui-components/src/main/res/values-ar-rEG/strings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - إلغاء - إلغاء - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ar-rIQ/strings.xml b/ui-components/src/main/res/values-ar-rIQ/strings.xml deleted file mode 100644 index 66ba3baf444..00000000000 --- a/ui-components/src/main/res/values-ar-rIQ/strings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - مكتمل - إلغاء - حفظ - مسح - إلغاء - إغلاق - البحث - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ar/strings.xml b/ui-components/src/main/res/values-ar/strings.xml deleted file mode 100644 index 56382963bd4..00000000000 --- a/ui-components/src/main/res/values-ar/strings.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - مكتمل - تجاهل التغييرات - ليس الان - استمر في التعديل - المراجعة - إلغاء - حفظ - مسح - تم - إلغاء - إغلاق - بحث - تنظيف الكل - - %s الأخطاء - %s الخطأ - %s الأخطاء - %s الأخطاء - %s الأخطاء - %s الأخطاء - - - %s التحذيرات - %s تحذير - %s التحذيرات - %s التحذيرات - %s التحذيرات - %s التحذيرات - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ckb/strings.xml b/ui-components/src/main/res/values-ckb/strings.xml deleted file mode 100644 index 5ae5c7edb82..00000000000 --- a/ui-components/src/main/res/values-ckb/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - تەواوبکە - لابردن - تۆمارکردن - پاککردنەوە - تةواو كرا - لابردن - داخستن - گەڕان - \ No newline at end of file diff --git a/ui-components/src/main/res/values-cs/strings.xml b/ui-components/src/main/res/values-cs/strings.xml deleted file mode 100644 index d0f0beb5124..00000000000 --- a/ui-components/src/main/res/values-cs/strings.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - Dokončit - Zrušit změny - Teď ne - Pokračovat v úpravách - Posouzení - Zrušit - Uložit - Vyčistit - Zde kreslete - Hotovo - Zrušit - Zavřít - Hledat - Vymazat vše - - %s chyba - %s chyby - %s chyb - %s chyb - - - %s varování - %s varování - %s varování - %s varování - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-da/strings.xml b/ui-components/src/main/res/values-da/strings.xml deleted file mode 100644 index fc0ecd37a5b..00000000000 --- a/ui-components/src/main/res/values-da/strings.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - Cancel - Save - Clear - Cancel - Close - \ No newline at end of file diff --git a/ui-components/src/main/res/values-en-rUS/strings.xml b/ui-components/src/main/res/values-en-rUS/strings.xml deleted file mode 100644 index a29e9d1d491..00000000000 --- a/ui-components/src/main/res/values-en-rUS/strings.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - Complete - Discard changes - Not now - Keep editing - Review - Cancel - Done - Cancel - Close - \ No newline at end of file diff --git a/ui-components/src/main/res/values-es-rES/strings.xml b/ui-components/src/main/res/values-es-rES/strings.xml deleted file mode 100644 index 2020a1e8ff1..00000000000 --- a/ui-components/src/main/res/values-es-rES/strings.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - Completar - Descartar los cambios - Ahora no - Seguir editando - Revisar - Cancelar - Guardar - Limpiar - Terminado - Cancelar - Cerrar - Buscar - \ No newline at end of file diff --git a/ui-components/src/main/res/values-es/strings.xml b/ui-components/src/main/res/values-es/strings.xml deleted file mode 100644 index 511e942b0d0..00000000000 --- a/ui-components/src/main/res/values-es/strings.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - Completar - Descartar los cambios - Ahora no - Seguir editando - Revisar - Cancelar - Guardar - Limpiar - Dibujar aquí - Hecho - Cancelar - Cerrar - Buscar - Limpiar todo - - %s error - %s errores - %s errores - - - %s advertencia - %s advertencias - %s advertencias - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-fr/strings.xml b/ui-components/src/main/res/values-fr/strings.xml deleted file mode 100644 index 5abb56131ff..00000000000 --- a/ui-components/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - Terminer - Annuler les modifications - Pas maintenant - Continuer l’édition - Vérifier - Annuler - Sauvegarder - Effacer - Dessinez ici - Terminé - Annuler - Fermer - Rechercher - Tout effacer - - %s erreur - %s erreurs - %s erreurs - - - %s avertissement - %s avertissements - %s avertissements - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-hi-rIN/strings.xml b/ui-components/src/main/res/values-hi-rIN/strings.xml deleted file mode 100644 index 99d20b1a222..00000000000 --- a/ui-components/src/main/res/values-hi-rIN/strings.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - पूरा हुआ - बदलावों को छोड़ना है - अभी नहीं - संपादन करते रहें - रिव्यू - रद्द करें - सहेजें - साफ़ करें - यहाँ चित्र बनाएँ - पूरा हुआ - रद्द करें - बंद करें - सर्च करें / खोजें - \ No newline at end of file diff --git a/ui-components/src/main/res/values-id/strings.xml b/ui-components/src/main/res/values-id/strings.xml deleted file mode 100644 index 5a9bfb7f266..00000000000 --- a/ui-components/src/main/res/values-id/strings.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - Lengkap - Hapus perubahan - Batal - Simpan - Hapus - Selesai - Batal - Tutup - Pencarian - \ No newline at end of file diff --git a/ui-components/src/main/res/values-km/strings.xml b/ui-components/src/main/res/values-km/strings.xml deleted file mode 100644 index 494d58bf5d4..00000000000 --- a/ui-components/src/main/res/values-km/strings.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - បញ្ចប់ - លុបការកែប្រែ - រក្សាទុកការកែប្រែ - បោះបង់ - រក្សាទុក​ - សម្អាត - Done - បោះបង់ - បិទ - ស្វែងរក - \ No newline at end of file diff --git a/ui-components/src/main/res/values-lo/strings.xml b/ui-components/src/main/res/values-lo/strings.xml deleted file mode 100644 index 373be607e47..00000000000 --- a/ui-components/src/main/res/values-lo/strings.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - ສຳເລັດແລ້ວ - ລຶບການປ່ຽນແປງ - ບໍ່​ແມ່ນ​ຕອນ​ນີ້ - ສຶບຕໍ່ແກ້ໄຂ - ທົບທວນຄືນ - ຍົກເລີກ - ບັນທຶກ - ລຶບ - ສຳເລັດ - ຍົກເລີກ - ປິດ - ຄົ້ນຫາ - ລຶບທັງໝົດ - - %s ຂໍ້ຜິດພາດ - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-my/strings.xml b/ui-components/src/main/res/values-my/strings.xml deleted file mode 100644 index 3fc9554ded3..00000000000 --- a/ui-components/src/main/res/values-my/strings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - ပြည့်စုံသည်။ - ပယ်ဖျက်သည် - သိမ်းဆည်းသည် - ရှင်းလင်းသည် - Done - ပယ်ဖျက်သည် - ပိတ်သည် - \ No newline at end of file diff --git a/ui-components/src/main/res/values-nb/strings.xml b/ui-components/src/main/res/values-nb/strings.xml deleted file mode 100644 index ce8f592faf1..00000000000 --- a/ui-components/src/main/res/values-nb/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Fullfør - Forkast endringer - Fortsett å rediger - Avbryt - Lagre - Fjern - Fullfør - Avbryt - Lukk - Søk - Fjern alt - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ne/strings.xml b/ui-components/src/main/res/values-ne/strings.xml deleted file mode 100644 index 70b00f6ec82..00000000000 --- a/ui-components/src/main/res/values-ne/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - रद्द गर्नुहोस् - रद्द गर्नुहोस् - खोज - \ No newline at end of file diff --git a/ui-components/src/main/res/values-nl/strings.xml b/ui-components/src/main/res/values-nl/strings.xml deleted file mode 100644 index 1a082762bd7..00000000000 --- a/ui-components/src/main/res/values-nl/strings.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - Compleet - Veranderingen ongedaan maken - Niet nu - Blijf bewerken - Beoordeling - Annuleren - Sla op - Verwijder - Klaar - Annuleren - Sluit - Zoek - - %s-fout - %s fouten - - - %s waarschuwing - %s waarschuwingen - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-or/strings.xml b/ui-components/src/main/res/values-or/strings.xml deleted file mode 100644 index 02e0b184706..00000000000 --- a/ui-components/src/main/res/values-or/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - ହୋଇଗଲା - \ No newline at end of file diff --git a/ui-components/src/main/res/values-prs/strings.xml b/ui-components/src/main/res/values-prs/strings.xml deleted file mode 100644 index cb379842f56..00000000000 --- a/ui-components/src/main/res/values-prs/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - تکمیل - انصراف - ذخیره - پاک کردن - انجام شد - انصراف - بستن - جستجو - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ps/strings.xml b/ui-components/src/main/res/values-ps/strings.xml deleted file mode 100644 index 90fc8eab303..00000000000 --- a/ui-components/src/main/res/values-ps/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - بشپړ یې کړئ - رد یې کړئ [ ژباړه ـ ردول ] - ثبت یې کړئ [ ژباړه - ثبت کړئ ] - پاک یې کړئ - ترسره شو - رد یې کړئ [ ژباړه ـ ردول ] - بند یې کړئ - جستجو - \ No newline at end of file diff --git a/ui-components/src/main/res/values-pt-rBR/strings.xml b/ui-components/src/main/res/values-pt-rBR/strings.xml deleted file mode 100644 index 7afc55e912d..00000000000 --- a/ui-components/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - Completo - Cancelar - Salvar - Limpar - Concluído - Cancelar - Fechar - Pesquisar - Limpar tudo - \ No newline at end of file diff --git a/ui-components/src/main/res/values-pt/strings.xml b/ui-components/src/main/res/values-pt/strings.xml deleted file mode 100644 index 2b34c177dfc..00000000000 --- a/ui-components/src/main/res/values-pt/strings.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - Concluir - Descartar alterações - Agora não - Continue a editar - Revisão - Cancelar - Guardar - Limpar - Desenhar aqui - concluído - Cancelar - Fechar - Pesquisar - Limpar tudo - - %s erro - %s erros - %s Erros - - - %s aviso - %s avisos - %s Alertas - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ro/strings.xml b/ui-components/src/main/res/values-ro/strings.xml deleted file mode 100644 index 3467c6ee24e..00000000000 --- a/ui-components/src/main/res/values-ro/strings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - Anulare - salva - Terminat - Anulare - Închidere - Căutare - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ru/strings.xml b/ui-components/src/main/res/values-ru/strings.xml deleted file mode 100644 index 462a4f26949..00000000000 --- a/ui-components/src/main/res/values-ru/strings.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - Завершить - Не сохранять изменения - Не сейчас - Продолжить редактирование - Просмотр - Отменить - Сохранить - Очистить - Готово - Отменить - Закрыть - Поиск - Очистить все - - %s ошибка - %s ошибки - %s ошибки - %s ошибок - - - %s предупреждение - %s предупреждения - %s предупреждения - %s предупреждений - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-si/strings.xml b/ui-components/src/main/res/values-si/strings.xml deleted file mode 100644 index 04e57120b45..00000000000 --- a/ui-components/src/main/res/values-si/strings.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - වෙනස් කිරීම් ඉවතලන්න - සුරකින්න - නිම කළා - වසන්න - සොයන්න - \ No newline at end of file diff --git a/ui-components/src/main/res/values-sv/strings.xml b/ui-components/src/main/res/values-sv/strings.xml deleted file mode 100644 index e5bae8302e9..00000000000 --- a/ui-components/src/main/res/values-sv/strings.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - Färdigt - Ignorera ändringar - Fortsätt redigera - Avbryt - Spara - Klar - Avbryt - Stänga - Sök - Rensa alla - \ No newline at end of file diff --git a/ui-components/src/main/res/values-tet/strings.xml b/ui-components/src/main/res/values-tet/strings.xml deleted file mode 100644 index 4de090c4391..00000000000 --- a/ui-components/src/main/res/values-tet/strings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - Kompletu - Kansela - Rai - Hamós - Kansela - Taka - \ No newline at end of file diff --git a/ui-components/src/main/res/values-tg/strings.xml b/ui-components/src/main/res/values-tg/strings.xml deleted file mode 100644 index 0e95b9a4042..00000000000 --- a/ui-components/src/main/res/values-tg/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - Анҷом ёфт - Бекор кардан - Захира кардан - Пок кардан - Иҷро шуд - Бекор кардан - Пӯшидан - Ҷустуҷӯ - \ No newline at end of file diff --git a/ui-components/src/main/res/values-uk/strings.xml b/ui-components/src/main/res/values-uk/strings.xml deleted file mode 100644 index d054ffd3e7b..00000000000 --- a/ui-components/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - Завершити - Відхилити внесені зміни - Не зараз - Продовжити редагування - Перегляд - Скасувати - Зберегти - Очистити - Намалюй тут. - Обрати - Скасувати - Закрити - Пошук - Очистити все - \ No newline at end of file diff --git a/ui-components/src/main/res/values-ur/strings.xml b/ui-components/src/main/res/values-ur/strings.xml deleted file mode 100644 index 915587895b9..00000000000 --- a/ui-components/src/main/res/values-ur/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - مکمل - منسوخ - محفوظ - کلیئر - ہو گیا - منسوخ - بند - تلاش کریں - \ No newline at end of file diff --git a/ui-components/src/main/res/values-uz-rUZ/strings.xml b/ui-components/src/main/res/values-uz-rUZ/strings.xml deleted file mode 100644 index bf48d074e68..00000000000 --- a/ui-components/src/main/res/values-uz-rUZ/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Bajarildi - Oʼzgartirishlar bekor qilinsin - Tahrirni davom eting - Ko‘rib chiqish - Бекор қилиш - Saqlash - Аниқ - Бекор қилиш - Yopmoq - Izlash - Barchasini tozalash - \ No newline at end of file diff --git a/ui-components/src/main/res/values-uz/strings.xml b/ui-components/src/main/res/values-uz/strings.xml deleted file mode 100644 index abeb9f1dd86..00000000000 --- a/ui-components/src/main/res/values-uz/strings.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - Тўлдириш - Ўзгартиришлар бекор қилинсин - Hozir emas - Таҳрирни давом этинг - Кўриб чиқиш - Рад этиш - Сақлаш - Тозалаш - Шу ерда чизинг - Бўлди - Рад этиш - Ёпмоқ - Излаш - Барчасини тозалаш - - %s da xatoliklar - - - %s da ogohlantirishlar - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-vi/strings.xml b/ui-components/src/main/res/values-vi/strings.xml deleted file mode 100644 index 8af5d33ccf2..00000000000 --- a/ui-components/src/main/res/values-vi/strings.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - Hoàn tất - Hủy bỏ các thay đổi - Không phải bây giờ - Tiếp tục chỉnh sửa - Xem lại - Hủy - Lưu - Xóa - Vẽ ở đây - Hoàn thành - Hủy - Đóng - Tìm kiếm - Xóa tất cả - - Các lỗi %s - - - Các cảnh báo %s - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-zh-rCN/strings.xml b/ui-components/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 5a20c6f8bbc..00000000000 --- a/ui-components/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - 完成 - 忽略修改 - 现在不要 - 继续编辑 - 审查 - 取消 - 保存 - 清除 - 在此画图 - 完成 - 取消 - 关闭 - 搜索 - 全清除 - - %s 错误 - - - %s 警告 - - \ No newline at end of file diff --git a/ui-components/src/main/res/values-zh/strings.xml b/ui-components/src/main/res/values-zh/strings.xml deleted file mode 100644 index 5a20c6f8bbc..00000000000 --- a/ui-components/src/main/res/values-zh/strings.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - 完成 - 忽略修改 - 现在不要 - 继续编辑 - 审查 - 取消 - 保存 - 清除 - 在此画图 - 完成 - 取消 - 关闭 - 搜索 - 全清除 - - %s 错误 - - - %s 警告 - - \ No newline at end of file diff --git a/ui-components/src/main/res/values/colors.xml b/ui-components/src/main/res/values/colors.xml deleted file mode 100644 index a6b3daec935..00000000000 --- a/ui-components/src/main/res/values/colors.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/ui-components/src/main/res/values/strings.xml b/ui-components/src/main/res/values/strings.xml deleted file mode 100644 index a39d266a406..00000000000 --- a/ui-components/src/main/res/values/strings.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - Complete - Discard changes - Not now - Keep editing - Review - Cancel - Save - Clear - Draw here - Extended Button - Done - Cancel - Close - Search - Clear all - - %s error - %s errors - - - %s warning - %s warnings - - \ No newline at end of file diff --git a/ui-components/src/main/res/values/styles.xml b/ui-components/src/main/res/values/styles.xml deleted file mode 100644 index 32e17fef12a..00000000000 --- a/ui-components/src/main/res/values/styles.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 9e29dc675af..d7d138dfd25 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,7 +1,5 @@ This is a patch version that fixes: -- ANDROAPP-7269 Crash on search -- ANDROAPP-7293 Bottom sheet landscape behavior -- ANDROAPP-7345 Changes to enrollment date not respected by program rules -- ANDROAPP-7368 Crash when trying to update fields in Tracker -.... + +- ANDROAPP-7634 SMS module is accidentally enabled when ASWA is installed + You can find all the details on Jira and Github. \ No newline at end of file