diff --git a/.github/workflows/apk-release.yml b/.github/workflows/apk-release.yml index c95cdfc7e9..330284bacc 100644 --- a/.github/workflows/apk-release.yml +++ b/.github/workflows/apk-release.yml @@ -62,36 +62,33 @@ jobs: env: KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} - - name: Spotless check quest application - run: ./gradlew :quest:spotlessCheck - working-directory: android + # - name: Spotless check quest application + # run: ./gradlew :quest:spotlessCheck + # working-directory: android - - name: Generate AAB (Android App Bundle) file - if: ${{ false }} - run: ./gradlew :quest:bundle${{steps.product-flavor.outputs.idCapitalized}} -x :quest:test${{steps.product-flavor.outputs.idCapitalized}}DebugUnitTest --stacktrace - working-directory: android + # - name: Generate AAB (Android App Bundle) file + # if: ${{ false }} + # run: ./gradlew :quest:bundle${{steps.product-flavor.outputs.idCapitalized}} -x :quest:test${{steps.product-flavor.outputs.idCapitalized}}DebugUnitTest --stacktrace + # working-directory: android - - name: Upload AAB file to tag assets - if: ${{ false }} - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: android/quest/build/outputs/bundle/${{steps.product-flavor.outputs.id}}/release/quest-${{steps.product-flavor.outputs.id}}-release.aab - asset_name: "quest-$tag.aab" - tag: ${{ github.ref }} - overwrite: true + # - name: Upload AAB file to tag assets + # if: ${{ false }} + # uses: svenstaro/upload-release-action@v2 + # with: + # repo_token: ${{ secrets.GITHUB_TOKEN }} + # file: android/quest/build/outputs/bundle/${{steps.product-flavor.outputs.id}}/release/quest-${{steps.product-flavor.outputs.id}}-release.aab + # asset_name: "quest-$tag.aab" + # tag: ${{ github.ref }} + # overwrite: true - name: Generate APK (Android App PacKage) file run: ./gradlew :quest:assemble${{steps.product-flavor.outputs.idCapitalized}} -x :quest:test${{steps.product-flavor.outputs.idCapitalized}}DebugUnitTest --stacktrace working-directory: android - name: Upload APK file to tag assets - uses: svenstaro/upload-release-action@v2 + uses: wzieba/Firebase-Distribution-Github-Action@v1 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: android/quest/build/outputs/apk/${{steps.product-flavor.outputs.id}}/release/quest-${{steps.product-flavor.outputs.id}}-release.apk - asset_name: "quest-$tag.apk" - tag: ${{ github.ref }} - prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') }} - overwrite: true - \ No newline at end of file + appId: ${{secrets.FIREBASE_APP_ID}} + serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }} + groups: dev + file: android/quest/build/outputs/apk/${{steps.product-flavor.outputs.id}}/release/quest-${{steps.product-flavor.outputs.id}}-release.apk \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 42defe8f58..0de2495d78 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -25,6 +25,7 @@ plugins { id("com.google.gms.google-services") version "4.3.14" apply false id("com.google.firebase.firebase-perf") version "1.4.2" apply false id("com.google.firebase.crashlytics") version "2.9.5" + id("com.google.firebase.appdistribution") version "5.0.0" apply false } allprojects { @@ -35,6 +36,18 @@ allprojects { mavenCentral() maven(url = "https://oss.sonatype.org/content/repositories/snapshots") maven(url = "https://jcenter.bintray.com/") + ProjectProperties.readProperties("local.properties") + .takeIf { it.getProperty("dtreeRepositoryUsername") != null && it.getProperty("dtreeRepositoryPassword") != null } + ?.let { + maven { + url = uri("https://maven.pkg.github.com/d-tree-org/android-fhir") + name = "dtreeRepository" + credentials { + username = it["dtreeRepositoryUsername"]?.toString() + password = it["dtreeRepositoryPassword"]?.toString() + } + } + } maven { name = "fhirsdk" url = uri("/Users/ndegwamartin/.m2.dev/fhirsdk") @@ -45,7 +58,7 @@ allprojects { subprojects { apply { plugin("com.diffplug.spotless") - plugin( "jacoco") + plugin("jacoco") } configure { diff --git a/android/buildSrc/src/main/kotlin/Properties.kt b/android/buildSrc/src/main/kotlin/Properties.kt new file mode 100644 index 0000000000..f5cab3df94 --- /dev/null +++ b/android/buildSrc/src/main/kotlin/Properties.kt @@ -0,0 +1,19 @@ +import java.io.File +import java.io.FileInputStream +import java.io.InputStreamReader +import java.util.Properties + +object ProjectProperties { + fun readProperties(file: String): Properties { + val properties = Properties() + val localProperties = File(file) + if (localProperties.isFile) { + InputStreamReader(FileInputStream(localProperties), Charsets.UTF_8).use { reader + -> + properties.load(reader) + } + } else println("\u001B[34mFILE_NOT_FOUND_EXCEPTION: File $file not found\u001B[0m") + + return properties + } +} \ No newline at end of file diff --git a/android/buildSrc/src/main/kotlin/fhir-properties.gradle.kts b/android/buildSrc/src/main/kotlin/fhir-properties.gradle.kts index b6da05c5f9..f31e8c0fb5 100644 --- a/android/buildSrc/src/main/kotlin/fhir-properties.gradle.kts +++ b/android/buildSrc/src/main/kotlin/fhir-properties.gradle.kts @@ -1,7 +1,3 @@ -import java.io.FileInputStream -import java.io.InputStreamReader -import java.util.Properties - val fhirAuthArray = arrayOf( "FHIR_BASE_URL", "OAUTH_BASE_URL", "OAUTH_CIENT_ID", "OAUTH_CLIENT_SECRET", "OAUTH_SCOPE", "APP_ID" ) @@ -10,7 +6,7 @@ val keystoreAuthArray = arrayOf( "KEYSTORE_ALIAS", "KEY_PASSWORD", "KEYSTORE_PASSWORD" ) -val localProperties = readProperties((properties["localPropertiesFile"] ?: "${rootProject.projectDir}/local.properties").toString()) +val localProperties = ProjectProperties.readProperties((properties["localPropertiesFile"] ?: "${rootProject.projectDir}/local.properties").toString()) fhirAuthArray.forEach { property -> extra.set(property, localProperties.getProperty(property, when { @@ -20,22 +16,8 @@ fhirAuthArray.forEach { property -> )) } - -val keystoreProperties = readProperties((properties["keystorePropertiesFile"] ?: "${rootProject.projectDir}/keystore.properties").toString()) +val keystoreProperties = ProjectProperties.readProperties((properties["keystorePropertiesFile"] ?: "${rootProject.projectDir}/keystore.properties").toString()) keystoreAuthArray.forEach { property -> extra.set(property, keystoreProperties.getProperty(property, "sample_$property")) } - -fun readProperties(file: String): Properties { - val properties = Properties() - val localProperties = File(file) - if (localProperties.isFile) { - InputStreamReader(FileInputStream(localProperties), Charsets.UTF_8).use { reader - -> - properties.load(reader) - } - } else println("\u001B[34mFILE_NOT_FOUND_EXCEPTION: File $file not found\u001B[0m") - - return properties -} diff --git a/android/dataclerk/build.gradle.kts b/android/dataclerk/build.gradle.kts index d01779d76e..026996bb0c 100644 --- a/android/dataclerk/build.gradle.kts +++ b/android/dataclerk/build.gradle.kts @@ -11,6 +11,7 @@ plugins { id("com.google.firebase.firebase-perf") id("com.google.gms.google-services") id("com.google.firebase.crashlytics") + id("com.google.firebase.appdistribution") } kotlin { jvmToolchain(17) } @@ -31,6 +32,7 @@ android { buildConfigField("String", "FHIR_BASE_URL", """"${project.extra["FHIR_BASE_URL"]}"""") buildConfigField("String", "OAUTH_BASE_URL", """"${project.extra["OAUTH_BASE_URL"]}"""") buildConfigField("String", "OAUTH_CIENT_ID", """"${project.extra["OAUTH_CIENT_ID"]}"""") + buildConfigField("String", "APP_ID", """"${project.extra["APP_ID"]}"""") buildConfigField( "String", "OAUTH_CLIENT_SECRET", @@ -42,33 +44,63 @@ android { vectorDrawables { useSupportLibrary = true } } - // signingConfigs { - // release { - // - // v1SigningEnabled false - // v2SigningEnabled true - // - // keyAlias System.getenv("KEYSTORE_ALIAS")?: project.KEYSTORE_ALIAS - // keyPassword System.getenv("KEY_PASSWORD") ?: project.KEY_PASSWORD - // storePassword System.getenv("KEYSTORE_PASSWORD") ?: project.KEYSTORE_PASSWORD - // storeFile file(System.getProperty("user.home") + "/fhircore.keystore.jks") - // } - // } + signingConfigs { + create("release") { + enableV1Signing = false + enableV2Signing = true + + keyAlias = System.getenv("KEYSTORE_ALIAS") ?: """${project.extra["KEYSTORE_ALIAS"]}""" + keyPassword = System.getenv("KEY_PASSWORD") ?: """${project.extra["KEY_PASSWORD"]}""" + storePassword = + System.getenv("KEYSTORE_PASSWORD") ?: """${project.extra["KEYSTORE_PASSWORD"]}""" + storeFile = file(System.getProperty("user.home") + "/fhircore.keystore.jks") + } + } buildTypes { debug { // enableUnitTestCoverage true - resValue("string", "authenticator_account_type", "\"${android.defaultConfig.applicationId}\"") } release { - resValue("string", "authenticator_account_type", "\"${android.defaultConfig.applicationId}\"") - // minifyEnabled false - // proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), - // "proguard-rules.pro" - // signingConfig signingConfigs.release + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("release") // firebaseCrashlytics { - // nativeSymbolUploadEnabled false + // isNativeSymbolUploadEnabled = false // } + firebaseAppDistribution { + artifactType = "APK" + releaseNotes = "Update" + } + } + } + + packaging { + resources { + excludes += + listOf( + "META-INF/ASL-2.0.txt", + "META-INF/LGPL-3.0.txt", + "license.html", + "readme.html", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/license.txt", + "META-INF/license.html", + "META-INF/LICENSE.md", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/NOTICE.md", + "META-INF/notice.txt", + "META-INF/ASL2.0", + "META-INF/ASL-2.0.txt", + "META-INF/LGPL-3.0.txt", + "META-INF/sun-jaxb.episode", + "META-INF/*.kotlin_module", + "META-INF/AL2.0", + "META-INF/LGPL2.1", + ) } } @@ -99,32 +131,51 @@ android { } } } - packaging { - resources { - excludes += - listOf( - "META-INF/ASL-2.0.txt", - "META-INF/LGPL-3.0.txt", - "license.html", - "readme.html", - "META-INF/DEPENDENCIES", - "META-INF/LICENSE", - "META-INF/LICENSE.txt", - "META-INF/license.txt", - "META-INF/license.html", - "META-INF/LICENSE.md", - "META-INF/NOTICE", - "META-INF/NOTICE.txt", - "META-INF/NOTICE.md", - "META-INF/notice.txt", - "META-INF/ASL2.0", - "META-INF/ASL-2.0.txt", - "META-INF/LGPL-3.0.txt", - "META-INF/sun-jaxb.episode", - "META-INF/*.kotlin_module", - "META-INF/AL2.0", - "META-INF/LGPL2.1", - ) + + flavorDimensions += "apps" + + productFlavors { + create("exposedInfant") { + dimension = "apps" + applicationIdSuffix = ".exposedInfant" + versionNameSuffix = "-exposedInfant" + versionCode = 1 + versionName = "0.0.1" + } + create("artClient") { + dimension = "apps" + applicationIdSuffix = ".artClient" + versionNameSuffix = "-artClient" + versionCode = 1 + versionName = "0.0.1" + } + create("exposedInfantDev") { + dimension = "apps" + applicationIdSuffix = ".exposedInfantDev" + versionNameSuffix = "-exposedInfantDev" + versionCode = 3 + versionName = "0.0.3" + } + create("artClientDev") { + dimension = "apps" + applicationIdSuffix = ".artClientDev" + versionNameSuffix = "-artClientDev" + versionCode = 3 + versionName = "0.0.3" + } + create("exposedInfantStaging") { + dimension = "apps" + applicationIdSuffix = ".exposedInfantStaging" + versionNameSuffix = "-exposedInfantStaging" + versionCode = 1 + versionName = "0.0.1" + } + create("artClientStaging") { + dimension = "apps" + applicationIdSuffix = ".artClientStaging" + versionNameSuffix = "-artClientStaging" + versionCode = 1 + versionName = "0.0.1" } } @@ -153,10 +204,26 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.paging:paging-compose:3.2.1") + implementation("com.github.anrwatchdog:anrwatchdog:1.4.0") + // Hilt - Dependency Injection implementation("com.google.dagger:hilt-android:${Deps.versions.hiltVersion}") kapt("com.google.dagger:hilt-compiler:${Deps.versions.hiltVersion}") + testImplementation(Deps.junit5_api) + testRuntimeOnly(Deps.junit5_engine) + testRuntimeOnly(Deps.junit5_engine_vintage) + testImplementation(Deps.robolectric) + testImplementation(Deps.atsl.core) + testImplementation(Deps.atsl.ext_junit) + testImplementation(Deps.atsl.ext_junit_ktx) + testImplementation(Deps.coroutines.test) + testImplementation(Deps.androidx.core_test) + debugImplementation(Deps.fragment_testing) + releaseImplementation(Deps.fragment_testing) + testImplementation(Deps.mockk) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + // analytics implementation(platform("com.google.firebase:firebase-bom:32.7.3")) @@ -164,16 +231,16 @@ dependencies { implementation("com.google.firebase:firebase-crashlytics-ktx") implementation("com.google.firebase:firebase-analytics-ktx") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.01")) - androidTestImplementation("androidx.compose.ui:ui-test-junit4") + // Hilt test dependencies testImplementation("com.google.dagger:hilt-android-testing:${Deps.versions.hiltVersion}") - kaptTest("com.google.dagger:hilt-compiler:${Deps.versions.hiltVersion}") + kaptTest("com.google.dagger:hilt-android-compiler:${Deps.versions.hiltVersion}") - debugImplementation("androidx.compose.ui:ui-tooling") + androidTestImplementation(Deps.atsl.ext_junit) + androidTestImplementation(Deps.atsl.espresso) debugImplementation("androidx.compose.ui:ui-test-manifest") + testImplementation("androidx.compose.ui:ui-test-junit4") + // debugImplementation because LeakCanary should only run in debug builds. + // debugImplementation "com.squareup.leakcanary:leakcanary-android:2.7" } kapt { correctErrorTypes = true } diff --git a/android/dataclerk/src/artClient/res/values/strings.xml b/android/dataclerk/src/artClient/res/values/strings.xml new file mode 100644 index 0000000000..eb8fcd5562 --- /dev/null +++ b/android/dataclerk/src/artClient/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Data Clerk ART + org.dtree.fhircore.dataclerk.artClient + diff --git a/android/dataclerk/src/artClientDev/res/values/strings.xml b/android/dataclerk/src/artClientDev/res/values/strings.xml new file mode 100644 index 0000000000..56dd16dfc4 --- /dev/null +++ b/android/dataclerk/src/artClientDev/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Data Clerk ART + org.dtree.fhircore.dataclerk.artClientDev + diff --git a/android/dataclerk/src/artClientStaging/res/values/strings.xml b/android/dataclerk/src/artClientStaging/res/values/strings.xml new file mode 100644 index 0000000000..2cdc290c05 --- /dev/null +++ b/android/dataclerk/src/artClientStaging/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Data Clerk ART + org.dtree.fhircore.dataclerk.artClientStaging + diff --git a/android/dataclerk/src/exposedInfant/res/values/strings.xml b/android/dataclerk/src/exposedInfant/res/values/strings.xml new file mode 100644 index 0000000000..61302e0a3a --- /dev/null +++ b/android/dataclerk/src/exposedInfant/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Data Clerk EID + org.dtree.fhircore.dataclerk.exposedInfant + diff --git a/android/dataclerk/src/exposedInfantDev/res/values/strings.xml b/android/dataclerk/src/exposedInfantDev/res/values/strings.xml new file mode 100644 index 0000000000..6e7a8f6b4a --- /dev/null +++ b/android/dataclerk/src/exposedInfantDev/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Data Clerk EID + org.dtree.fhircore.dataclerk.exposedInfantDev + diff --git a/android/dataclerk/src/exposedInfantStaging/res/values/strings.xml b/android/dataclerk/src/exposedInfantStaging/res/values/strings.xml new file mode 100644 index 0000000000..a26cce4cf1 --- /dev/null +++ b/android/dataclerk/src/exposedInfantStaging/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Data Clerk EID + org.dtree.fhircore.dataclerk.exposedInfantStaging + diff --git a/android/dataclerk/src/main/AndroidManifest.xml b/android/dataclerk/src/main/AndroidManifest.xml index 62df52fa4b..b8745f7f9b 100644 --- a/android/dataclerk/src/main/AndroidManifest.xml +++ b/android/dataclerk/src/main/AndroidManifest.xml @@ -26,8 +26,8 @@ + android:grantUriPermissions="true" + android:exported="false"> diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkAppConfigService.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkAppConfigService.kt new file mode 100644 index 0000000000..b4c0d12198 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkAppConfigService.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dtree.fhircore.dataclerk + +import javax.inject.Inject +import javax.inject.Singleton +import org.smartregister.fhircore.engine.configuration.app.AppConfigService + +@Singleton +class DataClerkAppConfigService @Inject constructor() : AppConfigService { + override fun getAppId(): String { + return BuildConfig.APP_ID + } + + override fun getBaseFhirUrl(): String { + return BuildConfig.FHIR_BASE_URL + } +} diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt index 9b8a707f13..ef072e2fd3 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt @@ -29,6 +29,7 @@ import javax.inject.Inject import org.dtree.fhircore.dataclerk.data.QuestXFhirQueryResolver import org.smartregister.fhircore.engine.data.remote.fhir.resource.ReferenceUrlResolver import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl +import org.smartregister.fhircore.engine.ui.questionnaire.items.CustomQuestItemDataProvider import timber.log.Timber @HiltAndroidApp @@ -41,6 +42,8 @@ class DataClerkApplication : Application(), DataCaptureConfig.Provider, Configur @Inject lateinit var xFhirQueryResolver: QuestXFhirQueryResolver + @Inject lateinit var customQuestItemDataProvider: CustomQuestItemDataProvider + override fun onCreate() { super.onCreate() @@ -58,14 +61,17 @@ class DataClerkApplication : Application(), DataCaptureConfig.Provider, Configur urlResolver = referenceUrlResolver, xFhirQueryResolver = xFhirQueryResolver, questionnaireItemViewHolderFactoryMatchersProviderFactory = - QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl, + QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl( + customQuestItemDataProvider, + ), ) return configuration as DataCaptureConfig } - override val workManagerConfiguration: Configuration = - Configuration.Builder() - .setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.VERBOSE else Log.INFO) - .setWorkerFactory(workerFactory) - .build() + override val workManagerConfiguration: Configuration + get() = + Configuration.Builder() + .setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.VERBOSE else Log.INFO) + .setWorkerFactory(workerFactory) + .build() } diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt index fdbfbeef36..45d83cee53 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt @@ -48,11 +48,11 @@ class DataClerkConfigService @Inject constructor(@ApplicationContext val context Coding().apply { system = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_careteam_system + org.smartregister.fhircore.engine.R.string.sync_strategy_careteam_system, ) display = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_careteam_display + org.smartregister.fhircore.engine.R.string.sync_strategy_careteam_display, ) }, ), @@ -62,11 +62,11 @@ class DataClerkConfigService @Inject constructor(@ApplicationContext val context Coding().apply { system = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_location_system + org.smartregister.fhircore.engine.R.string.sync_strategy_location_system, ) display = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_location_display + org.smartregister.fhircore.engine.R.string.sync_strategy_location_display, ) }, ), @@ -76,11 +76,11 @@ class DataClerkConfigService @Inject constructor(@ApplicationContext val context Coding().apply { system = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_organization_system + org.smartregister.fhircore.engine.R.string.sync_strategy_organization_system, ) display = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_organization_display + org.smartregister.fhircore.engine.R.string.sync_strategy_organization_display, ) }, ), @@ -90,11 +90,11 @@ class DataClerkConfigService @Inject constructor(@ApplicationContext val context Coding().apply { system = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_practitioner_system + org.smartregister.fhircore.engine.R.string.sync_strategy_practitioner_system, ) display = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_practitioner_display + org.smartregister.fhircore.engine.R.string.sync_strategy_practitioner_display, ) }, isResource = false, @@ -105,7 +105,7 @@ class DataClerkConfigService @Inject constructor(@ApplicationContext val context Coding().apply { system = context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_appid_system + org.smartregister.fhircore.engine.R.string.sync_strategy_appid_system, ) display = context.getString(org.smartregister.fhircore.engine.R.string.application_id) }, diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt index 516565ef36..5c5059f6ba 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt @@ -20,7 +20,9 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import org.dtree.fhircore.dataclerk.DataClerkAppConfigService import org.dtree.fhircore.dataclerk.DataClerkConfigService +import org.smartregister.fhircore.engine.configuration.app.AppConfigService import org.smartregister.fhircore.engine.configuration.app.ConfigService @InstallIn(SingletonComponent::class) @@ -28,4 +30,7 @@ import org.smartregister.fhircore.engine.configuration.app.ConfigService abstract class ConfigServiceModule { @Binds abstract fun provideConfigService(questConfigService: DataClerkConfigService): ConfigService + + @Binds + abstract fun providesAppConfigService(configService: DataClerkAppConfigService): AppConfigService } diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeScreen.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeScreen.kt index adc72b988a..78b428849c 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeScreen.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -40,10 +41,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.google.android.fhir.sync.SyncJobStatus @@ -71,6 +74,7 @@ fun HomeScreen( ) val syncState by appMainViewModel.syncSharedFlow.collectAsState(initial = null) val refreshKey by appMainViewModel.refreshHash + val scope = rememberCoroutineScope() LaunchedEffect(syncState) { if (syncState is SyncJobStatus.Succeeded) { @@ -98,7 +102,7 @@ fun HomeScreen( } IconButton(onClick = { navController.navigate("info") }) { Icon( - imageVector = Icons.Default.BugReport, + imageVector = Icons.Default.Settings, contentDescription = "Debug", ) } @@ -118,7 +122,9 @@ fun HomeScreen( bottomBar = { if (!appState.isInitialSync) { Button( - onClick = { patientRegistrationLauncher.launch(appMainViewModel.openForm(context)) }, + onClick = { + scope.launch { patientRegistrationLauncher.launch(appMainViewModel.openForm(context)) } + }, modifier = Modifier.fillMaxWidth(), ) { Text(text = appState.registrationButton) @@ -128,7 +134,7 @@ fun HomeScreen( ) { paddingValues -> Column(Modifier.padding(paddingValues)) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -153,7 +159,10 @@ fun SyncStatusBar( .times(100) .div(100) .toFloat() - LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), progress = progress) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) } else if (syncState is SyncJobStatus.Started) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeViewModel.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeViewModel.kt index ba2dce84da..3422227afa 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeViewModel.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeViewModel.kt @@ -16,7 +16,7 @@ package org.dtree.fhircore.dataclerk.ui.home -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager @@ -25,17 +25,19 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import org.dtree.fhircore.dataclerk.ui.home.paging.PatientPagingSource import org.dtree.fhircore.dataclerk.ui.main.AppDataStore import org.dtree.fhircore.dataclerk.ui.main.PatientItem @HiltViewModel class HomeViewModel @Inject constructor(private val dataStore: AppDataStore) : ViewModel() { - val patientCount = mutableStateOf(0L) + val patientCount = mutableLongStateOf(0L) var patientsPaging: MutableStateFlow>> = MutableStateFlow(emptyFlow()) @@ -52,7 +54,7 @@ class HomeViewModel @Inject constructor(private val dataStore: AppDataStore) : V pagingSourceFactory = { PatientPagingSource(dataStore) }, ) .flow - .cachedIn(viewModelScope) + .cachedIn(viewModelScope.plus(Dispatchers.IO)) fun refresh() { viewModelScope.launch { diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/PatientList.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/PatientList.kt index 0b01c75b07..c0b18f813c 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/PatientList.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/PatientList.kt @@ -60,6 +60,7 @@ fun PatientList(viewModel: HomeViewModel, navigate: (PatientItem) -> Unit) { verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp), ) { + item { Text(text = "Recently updated") } items(items = patients.itemSnapshotList.items) { patient -> PatientItemCard(patient, onClick = { navigate(patient) }) } @@ -116,8 +117,12 @@ fun PatientList(viewModel: HomeViewModel, navigate: (PatientItem) -> Unit) { @Composable fun PatientItemCard(patient: PatientItem, onClick: () -> Unit) { - OutlinedCard(modifier = Modifier.fillMaxWidth().clickable { onClick() }) { - Column(Modifier.padding(Constants.defaultCardPadding).fillMaxWidth()) { + OutlinedCard( + modifier = Modifier.fillMaxWidth().clickable { onClick() }, + ) { + Column( + Modifier.padding(Constants.defaultCardPadding).fillMaxWidth(), + ) { Text( text = patient.name, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/paging/PatientPagingSource.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/paging/PatientPagingSource.kt index 5edd0f82bf..0bc5a00982 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/paging/PatientPagingSource.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/paging/PatientPagingSource.kt @@ -32,9 +32,8 @@ class PatientPagingSource(private val dataStore: AppDataStore) : PagingSource): LoadResult { return try { - val page = params.key ?: 1 - val response = dataStore.loadPatients(page = page) - val nextPage = if (response.isEmpty()) null else page.plus(1) + val response = dataStore.loadPatients() + val nextPage = null Timber.e("next page is $nextPage") LoadResult.Page( data = response, diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppDataStore.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppDataStore.kt index 611c8574b1..1b4357d9a4 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppDataStore.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppDataStore.kt @@ -16,8 +16,8 @@ package org.dtree.fhircore.dataclerk.ui.main -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.rest.gclient.DateClientParam +import ca.uhn.fhir.rest.gclient.TokenClientParam import com.google.android.fhir.FhirEngine import com.google.android.fhir.get import com.google.android.fhir.logicalId @@ -25,13 +25,16 @@ import com.google.android.fhir.search.Operation import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search import com.google.android.fhir.search.StringFilterModifier +import com.google.android.fhir.search.filter.TokenParamFilterCriterion import com.google.android.fhir.search.search import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Date import javax.inject.Inject +import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Practitioner import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -39,47 +42,74 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.HealthStatus +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.SystemConstants +import org.smartregister.fhircore.engine.util.extension.activeCarePlans import org.smartregister.fhircore.engine.util.extension.extractAddress import org.smartregister.fhircore.engine.util.extension.extractGeneralPractitionerReference import org.smartregister.fhircore.engine.util.extension.extractHealthStatusFromMeta +import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.extractName import org.smartregister.fhircore.engine.util.extension.extractOfficialIdentifier import org.smartregister.fhircore.engine.util.extension.extractWithFhirPath -import timber.log.Timber class AppDataStore @Inject constructor( private val fhirEngine: FhirEngine, private val configurationRegistry: ConfigurationRegistry, - val defaultRepository: DefaultRepository, + private val defaultRepository: DefaultRepository, + private val sharedPreferencesHelper: SharedPreferencesHelper, ) { - private val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + + private val currentPractitioner by lazy { + sharedPreferencesHelper.read( + key = SharedPreferenceKey.PRACTITIONER_ID.name, + defaultValue = null, + ) + } private fun getApplicationConfiguration(): ApplicationConfiguration { return configurationRegistry.getAppConfigs() } - suspend fun loadPatients(page: Int = 1): List { - Timber.e("Page: $page") - // TODO: replace with _tag search when update is out + suspend fun loadPatients(): List { return fhirEngine .search { filter(Patient.ACTIVE, { value = of(true) }) - sort(Patient.NAME, Order.ASCENDING) - count = 20 - from = (page - 1) * 20 + filterPatient() + sort(DateClientParam("_lastUpdated"), Order.DESCENDING) + count = 10 } .map { it.resource } - .map { inputModel -> - // Timber.e(jsonParser.encodeResourceToString(inputModel)) - inputModel.toPatientItem(getApplicationConfiguration()) - } + .map { inputModel -> inputModel.toPatientItem(getApplicationConfiguration()) } } suspend fun getPatient(patientId: String): PatientItem { val patient = fhirEngine.get(patientId) - return patient.toPatientItem(getApplicationConfiguration()) + var model = patient.toPatientItem(getApplicationConfiguration()) + + val list = arrayListOf() + list.add(patient) + + if (patient.hasGeneralPractitioner()) { + val id = patient.generalPractitioner.first().extractId() + val practitioner = fhirEngine.get(id) + list.add(practitioner) + } + + val carePlans = patient.activeCarePlans(fhirEngine) + + if (carePlans.isNotEmpty()) { + list.add(carePlans.first()) + } + + model = + model.copy( + populateResources = list, + ) + return model } suspend fun getResource(resourceId: String): Resource { @@ -88,11 +118,15 @@ constructor( suspend fun patientCount(): Long { return fhirEngine.count( - Search(ResourceType.Patient).apply { filter(Patient.ACTIVE, { value = of(true) }) }, + Search(ResourceType.Patient).apply { + filter(Patient.ACTIVE, { value = of(true) }) + filterPatient() + }, ) } suspend fun search(text: String): List { + val isArt = getApplicationConfiguration().appId.contains("art-client") return fhirEngine .search { filter( @@ -107,10 +141,47 @@ constructor( sort(Patient.NAME, Order.ASCENDING) } .map { it.resource } - .map { inputModel -> - // Timber.e(jsonParser.encodeResourceToString(inputModel)) - inputModel.toPatientItem(getApplicationConfiguration()) + .filter { + it.meta.tag?.firstOrNull { coding -> + coding.system == SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM && + (if (isArt) { + listOf("client-already-on-art", "newly-diagnosed-client") + } else { + listOf("exposed-infant") + }) + .contains(coding.code) + } != null } + .map { inputModel -> inputModel.toPatientItem(getApplicationConfiguration()) } + } + + suspend fun getCurrentPractitioner(): Practitioner? { + return currentPractitioner?.let { fhirEngine.get(it) } + } + + private fun Search.filterPatient() { + val paramQueries: List<(TokenParamFilterCriterion.() -> Unit)> = + (if (getApplicationConfiguration().appId.contains("art-client")) { + listOf( + Coding().apply { + system = SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM + code = "client-already-on-art" + }, + Coding().apply { + system = SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM + code = "newly-diagnosed-client" + }, + ) + } else { + listOf( + Coding().apply { + system = SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM + code = "exposed-infant" + }, + ) + }) + .map { coding -> { value = of(coding) } } + filter(TokenClientParam("_tag"), *paramQueries.toTypedArray(), operation = Operation.OR) } } @@ -127,6 +198,7 @@ data class PatientItem( val healthStatus: HealthStatus, val practitioners: List? = null, val dateCreated: Date? = null, + val populateResources: ArrayList = arrayListOf(), ) data class AddressData( @@ -134,7 +206,11 @@ data class AddressData( val state: String = "", val text: String = "", val fullAddress: String = "", -) +) { + override fun toString(): String { + return "$district \n $state" + } +} internal fun Patient.toPatientItem(configuration: ApplicationConfiguration): PatientItem { val phone = if (hasTelecom()) telecom[0].value else "N/A" @@ -166,5 +242,6 @@ internal fun Patient.toPatientItem(configuration: ApplicationConfiguration): Pat fullAddress = this.extractAddress(), ), dateCreated = this.meta.lastUpdated, + populateResources = arrayListOf(), ) } diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainViewModel.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainViewModel.kt index 3c1f829ec9..22c1fa130c 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainViewModel.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainViewModel.kt @@ -35,10 +35,10 @@ import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Flag +import org.hl7.fhir.r4.model.Practitioner +import org.hl7.fhir.r4.model.Resource import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry -import org.smartregister.fhircore.engine.configuration.app.AppConfigClassification import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration -import org.smartregister.fhircore.engine.configuration.view.RegisterViewConfiguration import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireType @@ -57,15 +57,14 @@ constructor( private val dataStore: AppDataStore, ) : ViewModel() { - private val patientRegisterConfiguration: RegisterViewConfiguration by lazy { - configurationRegistry.retrieveConfiguration(AppConfigClassification.PATIENT_REGISTER) - } + private val applicationConfiguration: ApplicationConfiguration = + configurationRegistry.getAppConfigs() + private val simpleDateFormat = SimpleDateFormat(SYNC_TIMESTAMP_OUTPUT_FORMAT, Locale.getDefault()) val appMainUiState: MutableState = mutableStateOf(appMainUiStateOf()) val syncSharedFlow = MutableSharedFlow() + var currentPractitioner: Practitioner? = null - private val applicationConfiguration: ApplicationConfiguration = - configurationRegistry.getAppConfigs() val refreshHash = mutableStateOf("") suspend fun retrieveAppMainUiState(syncBroadcaster: SyncBroadcaster) { @@ -77,7 +76,7 @@ constructor( lastSyncTime = retrieveLastSyncTimestamp() ?: "", languages = configurationRegistry.fetchLanguages(), isInitialSync = syncBroadcaster.isInitialSync(), - registrationButton = patientRegisterConfiguration.newClientButtonText, + // registrationButton = patientRegisterConfiguration.newClientButtonText, ) } @@ -90,28 +89,35 @@ constructor( ) .displayName - fun openForm(context: Context): Intent { - val isArtClient = patientRegisterConfiguration.appId.contains("art-client") + suspend fun openForm(context: Context): Intent { + val isArtClient = applicationConfiguration.appId.contains("art-client") val artCode = Coding().apply { code = if (isArtClient) "client-already-on-art" else "exposed-infant" display = if (isArtClient) "Person Already on ART" else "Exposed Infant" } + val resources = + arrayListOf( + Flag().apply { + code = + CodeableConcept().apply { + text = if (isArtClient) "client-already-on-art" else "exposed-infant" + addCoding(artCode) + } + }, + ) + + if (currentPractitioner == null) { + dataStore.getCurrentPractitioner()?.let { currentPractitioner = it } + } + currentPractitioner?.let { resources.add(it) } + return Intent(context, QuestionnaireActivity::class.java) .putExtras( QuestionnaireActivity.intentArgs( - formName = patientRegisterConfiguration.registrationForm, + formName = applicationConfiguration.registrationForm, questionnaireType = QuestionnaireType.DEFAULT, - populationResources = - arrayListOf( - Flag().apply { - code = - CodeableConcept().apply { - text = if (isArtClient) "client-already-on-art" else "exposed-infant" - addCoding(artCode) - } - }, - ), + populationResources = resources, ), ) } diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientScreen.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientScreen.kt index 50a5d98fea..55bc4e11ff 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientScreen.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientScreen.kt @@ -121,7 +121,9 @@ fun PatientDetailsTab( is PatientDetailHeader -> PatientDetailsCardViewBinding(data) is PatientDetailProperty -> PatientListItemViewBinding(data) is PatientDetailOverview -> - PatientDetailsHeaderBinding(data) { patientViewModel.editPatient(context) } + PatientDetailsHeaderBinding(data) { + patientViewModel.editPatient(context, state.patientDetail) + } is PatientReferenceProperty -> list[data.patientProperty.value]?.let { PatientReferencePropertyBinding(data, it) } } diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientViewModel.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientViewModel.kt index ca6abed50d..107114669f 100644 --- a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientViewModel.kt +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.dtree.fhircore.dataclerk.R import org.dtree.fhircore.dataclerk.ui.main.AppDataStore +import org.dtree.fhircore.dataclerk.ui.main.PatientItem import org.dtree.fhircore.dataclerk.util.getFormattedAge import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireType @@ -104,12 +105,19 @@ constructor( lastInGroup = true, ), ) - val address = patientItem.addressData.fullAddress data.add( PatientDetailProperty( PatientProperty( getString(R.string.patient_property_address), - address.ifBlank { "N/A" }, + patientItem.addressData.district.ifBlank { "N/A" }, + ), + ), + ) + data.add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_physical_locator), + patientItem.addressData.text.ifBlank { "N/A" }, ), ), ) @@ -155,12 +163,13 @@ constructor( private fun getString(resId: Int) = context.resources.getString(resId) - fun editPatient(context: Context) { + fun editPatient(context: Context, patientDetail: PatientItem) { QuestionnaireActivity.launchQuestionnaire( context = context, questionnaireId = EDIT_PROFILE_FORM, clientIdentifier = patientId, questionnaireType = QuestionnaireType.EDIT, + populationResources = patientDetail.populateResources, ) } diff --git a/android/dataclerk/src/main/res/values/strings.xml b/android/dataclerk/src/main/res/values/strings.xml index 7c4fc408d9..beeed96d6f 100644 --- a/android/dataclerk/src/main/res/values/strings.xml +++ b/android/dataclerk/src/main/res/values/strings.xml @@ -16,4 +16,5 @@ Date of Birth Age Gender + Physical locator diff --git a/android/dataclerk/src/main/res/xml/file_paths.xml b/android/dataclerk/src/main/res/xml/file_paths.xml index dc666d0141..0290848fc6 100644 --- a/android/dataclerk/src/main/res/xml/file_paths.xml +++ b/android/dataclerk/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index e59c89ce86..23f8623ffd 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id("de.mannodermaus.android-junit5") id("com.google.dagger.hilt.android") id("org.jetbrains.kotlin.android") + id("com.google.firebase.crashlytics") } kotlin { jvmToolchain(17) } @@ -124,7 +125,7 @@ dependencies { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } - api("org.smartregister:knowledge:0.1.0-alpha03-preview3-SNAPSHOT") { + api("org.smartregister:knowledge:0.1.0-alpha03-preview4-SNAPSHOT") { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } @@ -136,6 +137,7 @@ dependencies { exclude(group = "com.google.android.fhir", module = "common") exclude(group = "com.google.android.fhir", module = "engine") exclude(group = "com.github.ben-manes.caffeine") + exclude(group = "org.smartregister", module = "knowledge") } // Hilt - Dependency Injection @@ -183,7 +185,7 @@ dependencies { exclude(group = "com.google.android.fhir", module = "common") exclude(group = "com.google.android.fhir", module = "engine") } - api("org.smartregister:data-capture:1.1.0-preview6-SNAPSHOT") { + api("org.smartregister:data-capture:1.1.0-preview8.1-SNAPSHOT") { isTransitive = true exclude(group = "ca.uhn.hapi.fhir") exclude(group = "com.google.android.fhir", module = "engine") @@ -193,10 +195,11 @@ dependencies { api("org.smartregister:common:0.1.0-alpha05-preview3-SNAPSHOT") { isTransitive = true } - api("org.smartregister:engine:1.0.0-preview5-SNAPSHOT") { + api("org.smartregister:engine:1.0.0-preview7.1-SNAPSHOT") { isTransitive = true exclude(group = "com.google.android.fhir", module = "common") exclude(group = "com.github.ben-manes.caffeine") + exclude(group = "org.smartregister", module = "knowledge") } api("com.google.code.gson:gson:2.10.1") @@ -215,6 +218,7 @@ dependencies { implementation(platform("com.google.firebase:firebase-bom:32.7.3")) implementation("com.google.firebase:firebase-perf-ktx") + implementation("com.google.firebase:firebase-crashlytics-ktx") implementation("androidx.core:core-splashscreen:1.0.0") diff --git a/android/engine/src/main/AndroidManifest.xml b/android/engine/src/main/AndroidManifest.xml index e1697dddf5..e101670a7d 100644 --- a/android/engine/src/main/AndroidManifest.xml +++ b/android/engine/src/main/AndroidManifest.xml @@ -30,7 +30,8 @@ + android:theme="@style/AppTheme" + android:launchMode="singleTop"/> { - filter(Appointment.STATUS, { value = of(Appointment.AppointmentStatus.BOOKED.toCode()) }) - filter( - Appointment.DATE, - { - value = of(DateTimeType.today()) - prefix = ParamPrefixEnum.LESSTHAN - }, - ) - } - .map { it.resource } - .filter { - it.hasStart() && - it.start.before(DateTime().withTimeAtStartOfDay().toDate()) && - it.status == Appointment.AppointmentStatus.BOOKED - } - .toTypedArray() - - updateNoShow(missedAppointments) - - Timber.i("Updated ${missedAppointments.size} missed appointments") - return Result.success() - } - - private suspend fun updateNoShow(appointments: Array) { - appointments.forEach { it.status = Appointment.AppointmentStatus.NOSHOW } - fhirEngine.update(*appointments) + return withContext(dispatcherProvider.io()) { + fhirResourceUtil.handleMissedAppointment() + Result.success() + } } companion object { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/appointment/ProposedWelcomeServiceAppointmentsWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/appointment/ProposedWelcomeServiceAppointmentsWorker.kt index ee64128ae2..cc4f83a765 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/appointment/ProposedWelcomeServiceAppointmentsWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/appointment/ProposedWelcomeServiceAppointmentsWorker.kt @@ -20,19 +20,11 @@ import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import ca.uhn.fhir.rest.param.ParamPrefixEnum -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.get -import com.google.android.fhir.search.search import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import org.hl7.fhir.r4.model.Appointment -import org.hl7.fhir.r4.model.CodeableConcept -import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.DateTimeType -import org.hl7.fhir.r4.model.IdType -import org.hl7.fhir.r4.model.ResourceType -import timber.log.Timber +import kotlinx.coroutines.withContext +import org.smartregister.fhircore.engine.task.FhirResourceUtil +import org.smartregister.fhircore.engine.util.DispatcherProvider @HiltWorker class ProposedWelcomeServiceAppointmentsWorker @@ -40,83 +32,14 @@ class ProposedWelcomeServiceAppointmentsWorker constructor( @Assisted val appContext: Context, @Assisted workerParameters: WorkerParameters, - val fhirEngine: FhirEngine, + private val fhirResourceUtil: FhirResourceUtil, + val dispatcherProvider: DispatcherProvider, ) : CoroutineWorker(appContext, workerParameters) { - val welcomeServiceCodeableConcept = - CodeableConcept(Coding("https://d-tree.org", "Welcome", "Welcome Service")).apply { - text = "Welcome Service" - } - override suspend fun doWork(): Result { - Timber.i("Checking 'Welcome Service' appointments") - - val proposedAppointments = - fhirEngine - .search { - filter( - Appointment.STATUS, - { value = of(Appointment.AppointmentStatus.PROPOSED.toCode()) }, - ) - filter(Appointment.REASON_CODE, { value = of(welcomeServiceCodeableConcept) }) - filter( - Appointment.DATE, - { - value = of(DateTimeType.today()) - prefix = ParamPrefixEnum.LESSTHAN_OR_EQUALS - }, - ) - } - .map { it.resource } - .filter { - it.hasStart() && - it.hasSupportingInformation() && - it.supportingInformation.any { reference -> - reference.referenceElement.resourceType == ResourceType.Appointment.name - } - } - .map { - val supportFinishVisitAppointmentRef = - it.supportingInformation.first { reference -> - reference.referenceElement.resourceType == ResourceType.Appointment.name - } - val supportFinishVisitAppointmentRefId = - IdType(supportFinishVisitAppointmentRef.reference).idPart - val supportFinishVisitAppointment = - fhirEngine.get(supportFinishVisitAppointmentRefId) - Pair(it, supportFinishVisitAppointment) - } - - val proposedAppointmentsToCancel = - proposedAppointments - .filter { - val finishVisitAppointment = it.second - finishVisitAppointment.status == Appointment.AppointmentStatus.FULFILLED - } - .map { it.first } - - val proposedAppointmentsToBook = - proposedAppointments - .filter { - val finishVisitAppointment = it.second - finishVisitAppointment.status in - arrayOf(Appointment.AppointmentStatus.NOSHOW, Appointment.AppointmentStatus.BOOKED) - } - .map { it.first } - - bookWelcomeService(proposedAppointmentsToBook.toTypedArray()) - cancelWelcomeService(proposedAppointmentsToCancel.toTypedArray()) - - return Result.success() - } - - private suspend fun bookWelcomeService(appointments: Array) { - appointments.forEach { it.status = Appointment.AppointmentStatus.BOOKED } - fhirEngine.update(*appointments) - } - - private suspend fun cancelWelcomeService(appointments: Array) { - appointments.forEach { it.status = Appointment.AppointmentStatus.CANCELLED } - fhirEngine.update(*appointments) + return withContext(dispatcherProvider.io()) { + fhirResourceUtil.handleWelcomeServiceAppointmentWorker() + Result.success() + } } companion object { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index f61f86f15a..17ca783971 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -83,7 +83,5 @@ constructor( const val ID = "_id" const val COUNT = "count" const val DEFAULT_COUNT = "100" - const val DEFAULT_TASK_FILTER_TAG_META_CODING_SYSTEM = "https://d-tree.org/fhir/task-filter-tag" - const val DEFAULT_TASK_ORDER_FILTER_TAG_META_CODING_SYSTEM = "https://d-tree.org" } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index ff95ca30a9..449d10b7f4 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -19,8 +19,7 @@ package org.smartregister.fhircore.engine.configuration.app import kotlinx.serialization.Serializable import org.smartregister.fhircore.engine.configuration.Configuration import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry -import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry.Companion.DEFAULT_TASK_FILTER_TAG_META_CODING_SYSTEM -import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry.Companion.DEFAULT_TASK_ORDER_FILTER_TAG_META_CODING_SYSTEM +import org.smartregister.fhircore.engine.util.SystemConstants @Serializable data class ApplicationConfiguration( @@ -34,9 +33,8 @@ data class ApplicationConfiguration( var appLogoIconResourceFile: String = "ic_default_logo", var count: String = ConfigurationRegistry.DEFAULT_COUNT, var patientTypeFilterTagViaMetaCodingSystem: String = "", - var taskOrderFilterTagViaMetaCodingSystem: String = - DEFAULT_TASK_ORDER_FILTER_TAG_META_CODING_SYSTEM, - var taskFilterTagViaMetaCodingSystem: String = DEFAULT_TASK_FILTER_TAG_META_CODING_SYSTEM, + var taskOrderFilterTagViaMetaCodingSystem: String = SystemConstants.TASK_TASK_ORDER_SYSTEM, + var taskFilterTagViaMetaCodingSystem: String = SystemConstants.TASK_FILTER_TAG_SYSTEM, var registrationForm: String = "patient-demographic-registration", ) : Configuration diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/domain/PregnancyStatus.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/domain/PregnancyStatus.kt new file mode 100644 index 0000000000..71ed17e39d --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/domain/PregnancyStatus.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.data.domain + +enum class PregnancyStatus { + None, + Pregnant, + BreastFeeding, +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index 8b8480c4fc..cf64010a1e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt @@ -51,6 +51,7 @@ import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.addTags import org.smartregister.fhircore.engine.util.extension.filterBy import org.smartregister.fhircore.engine.util.extension.filterByResourceTypeId +import org.smartregister.fhircore.engine.util.extension.generateCreatedOn import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.generateMissingVersionId import org.smartregister.fhircore.engine.util.extension.loadPatientImmunizations @@ -191,6 +192,7 @@ constructor( resource.onEach { it.generateMissingId() it.generateMissingVersionId() + it.generateCreatedOn() if (addResourceTags) { it.addTags(configService.provideResourceTags(sharedPreferencesHelper)) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/RegisterFilter.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/RegisterFilter.kt index 677011893f..849fb37bf0 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/RegisterFilter.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/RegisterFilter.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.engine.data.local import java.util.Date +import org.hl7.fhir.r4.model.CodeableConcept import org.smartregister.fhircore.engine.domain.model.HealthStatus sealed interface RegisterFilter @@ -28,7 +29,8 @@ data class AppointmentRegisterFilter( Iterable< HealthStatus, >?, // nullable whereby null represents absence of filter by patient category - val reasonCode: String?, // nullable whereby null represents absence of filter by reason code + val reasonCode: + CodeableConcept?, // nullable whereby null represents absence of filter by reason code ) : RegisterFilter enum class TracingAgeFilterEnum { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurger.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurger.kt index 450afb4ecd..a6bd659b89 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurger.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurger.kt @@ -22,6 +22,7 @@ import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search import com.google.android.fhir.search.search import org.hl7.fhir.r4.model.Appointment +import org.hl7.fhir.r4.model.AuditEvent import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Observation @@ -32,12 +33,15 @@ import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.Task import timber.log.Timber +// TODO: Filter the status at the DB first than fetching all of the resources, we don't want to +// block the db class ResourcePurger(private val fhirEngine: FhirEngine) { suspend operator fun invoke() { - onPurge(ResourceType.Observation) onPurge(ResourceType.Encounter) + onPurge(ResourceType.AuditEvent) onPurge(ResourceType.QuestionnaireResponse) + onPurgeObservation() onPurgeCarePlans() onPurgeAppointment() } @@ -58,7 +62,28 @@ class ResourcePurger(private val fhirEngine: FhirEngine) { private suspend fun onPurgeAppointment() = query(ResourceType.Appointment) - .filterNot { it.status == Appointment.AppointmentStatus.BOOKED } + .filter { app -> + listOf( + Appointment.AppointmentStatus.ENTEREDINERROR, + Appointment.AppointmentStatus.CANCELLED, + Appointment.AppointmentStatus.NOSHOW, + Appointment.AppointmentStatus.FULFILLED, + ) + .contains(app.status) + } + .onEach { it.purge() } + + private suspend fun onPurgeObservation() = + query(ResourceType.Observation) + .filter { app -> + listOf( + Observation.ObservationStatus.ENTEREDINERROR, + Observation.ObservationStatus.CANCELLED, + Observation.ObservationStatus.FINAL, + Observation.ObservationStatus.CORRECTED, + ) + .contains(app.status) + } .onEach { it.purge() } /** Purge Inactive [CarePlan] together with it's associated [Task] */ @@ -74,6 +99,7 @@ class ResourcePurger(private val fhirEngine: FhirEngine) { .filter { it.status == CarePlan.CarePlanStatus.ACTIVE } .also { if (it.size > 1) onPurgeCarePlanWithAssociatedTask(it.subList(1, it.size)) } + // TODO: Filter out the care_plans in the query before hand private suspend fun onPurgeCarePlans() = with(fhirEngine) { search(Search(ResourceType.Patient)).onEach { patient -> diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurgerWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurgerWorker.kt index a8bf1db541..aab3b9cbe7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurgerWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/purger/ResourcePurgerWorker.kt @@ -39,9 +39,10 @@ constructor( ) : CoroutineWorker(appContext, workerParameters) { override suspend fun doWork(): Result { + val isOneTimeSync = inputData.getBoolean(ONE_TIME_SYNC_KEY, false) val optimalHour = LocalTime().hourOfDay Timber.i("Running $NAME...") - if (optimalHour < 6 || optimalHour > 17) { + if ((optimalHour < 6 || optimalHour > 17) || isOneTimeSync) { sharedPreferencesHelper.write( SharedPreferenceKey.LAST_PURGE_KEY.name, System.currentTimeMillis(), @@ -53,5 +54,6 @@ constructor( companion object { const val NAME = "ResourcePurgerWorker" + const val ONE_TIME_SYNC_KEY = "one_time_sync" } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt index a4d13080a4..5d439f9e96 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt @@ -16,15 +16,12 @@ package org.smartregister.fhircore.engine.data.local.register.dao -import ca.uhn.fhir.rest.gclient.TokenClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.FhirEngine import com.google.android.fhir.logicalId import com.google.android.fhir.search.Operation import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search -import com.google.android.fhir.search.filter.TokenParamFilterCriterion -import com.google.android.fhir.search.has import com.google.android.fhir.search.search import java.util.Calendar import java.util.Date @@ -32,13 +29,12 @@ import javax.inject.Inject import javax.inject.Singleton import org.hl7.fhir.r4.model.Appointment import org.hl7.fhir.r4.model.CodeableConcept -import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateTimeType -import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration +import org.smartregister.fhircore.engine.data.domain.PregnancyStatus import org.smartregister.fhircore.engine.data.local.AppointmentRegisterFilter import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.RegisterFilter @@ -54,8 +50,9 @@ import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.engine.util.extension.extractAge import org.smartregister.fhircore.engine.util.extension.extractHealthStatusFromMeta import org.smartregister.fhircore.engine.util.extension.extractName +import org.smartregister.fhircore.engine.util.extension.extractOfficialIdentifier +import org.smartregister.fhircore.engine.util.extension.getPregnancyStatus import org.smartregister.fhircore.engine.util.extension.safeSubList -import org.smartregister.fhircore.engine.util.extension.toHealthStatus @Singleton class AppointmentRegisterDao @@ -107,7 +104,9 @@ constructor( val patient = appointment.patientRef()?.let { defaultRepository.loadResource(it) as Patient } ?: return false - return patient.meta.tag.any { coding -> coding.toHealthStatus() in categories } + return patient.meta.tag.any { coding -> + coding.code in categories.map { it.name.lowercase().replace("_", "-") } + } } private suspend fun searchAppointments( @@ -133,8 +132,13 @@ constructor( sort(Appointment.DATE, Order.ASCENDING) } + val keySet = mutableSetOf() return searchResults - .map { it.resource } + .mapNotNull { + if (keySet.contains(it.resource.logicalId)) return@mapNotNull null + keySet.add(it.resource.logicalId) + it.resource + } .filter { isAppointmentValid( appointment = it, @@ -147,7 +151,7 @@ constructor( private suspend fun isAppointmentValid( appointment: Appointment, - reasonCode: String? = null, + reasonCode: CodeableConcept? = null, patientCategory: Iterable? = null, myPatients: Boolean = false, ): Boolean { @@ -161,9 +165,12 @@ constructor( val appointmentReasonFilter = reasonCode == null || - (appointment.reasonCode.flatMap { cc -> cc.coding }.any { c -> c.code == reasonCode }) + (appointment.reasonCode + .flatMap { cc -> cc.coding } + .any { c -> c.code == reasonCode.coding.firstOrNull()?.code }) return (appointment.status == Appointment.AppointmentStatus.BOOKED || + appointment.status == Appointment.AppointmentStatus.WAITLIST || appointment.status == Appointment.AppointmentStatus.NOSHOW) && appointment.hasStart() && patientAssignmentFilter && @@ -175,14 +182,16 @@ constructor( private fun Search.genericFilter( dateOfAppointment: Date? = null, - reasonCode: String? = null, + reasonCode: CodeableConcept? = null, patientCategory: Iterable? = null, myPatients: Boolean = false, ) { filter( Appointment.STATUS, { value = of(Appointment.AppointmentStatus.BOOKED.toCode()) }, + { value = of(Appointment.AppointmentStatus.WAITLIST.toCode()) }, { value = of(Appointment.AppointmentStatus.NOSHOW.toCode()) }, + operation = Operation.OR, ) if (dateOfAppointment != null) { @@ -190,96 +199,62 @@ constructor( Appointment.DATE, { value = of(DateTimeType(dateOfAppointment)) - prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS - }, - ) - filter( - Appointment.DATE, - { - value = - of( - DateTimeType(dateOfAppointment).apply { - add( - Calendar.DATE, - 1, - ) - }, - ) - prefix = ParamPrefixEnum.LESSTHAN + prefix = ParamPrefixEnum.EQUAL }, ) } if (myPatients && currentPractitioner != null) { filter( - Appointment.PRACTITIONER, - { value = currentPractitioner!!.asReference(ResourceType.Appointment).reference }, + Appointment.ACTOR, + { value = currentPractitioner!!.asReference(ResourceType.Practitioner).reference }, ) } if (reasonCode != null) { - val codeAbleConcept = - CodeableConcept().apply { - addCoding( - Coding().apply { - system = "https://d-tree.org" - code = reasonCode - }, - ) - } - filter(Appointment.REASON_CODE, { value = of(codeAbleConcept) }) + filter(Appointment.REASON_CODE, { value = of(reasonCode) }) } - if (patientCategory != null) { - val patientTypeFilterTag = applicationConfiguration().patientTypeFilterTagViaMetaCodingSystem - - val paramQueries: List<(TokenParamFilterCriterion.() -> Unit)> = - patientCategory.flatMap { healthStatus -> - val coding: Coding = - Coding().apply { - system = patientTypeFilterTag - code = healthStatus.name.lowercase().replace("_", "-") - } - val alternativeCoding: Coding = - Coding().apply { - system = patientTypeFilterTag - code = healthStatus.name.lowercase() - } - - return@flatMap listOf(coding, alternativeCoding).map< - Coding, - TokenParamFilterCriterion.() -> Unit, - > { c -> - { value = of(c) } - } - } - - has(Appointment.PATIENT) { - filter(TokenClientParam("_tag"), *paramQueries.toTypedArray(), operation = Operation.OR) - } - } + // TODO: look into this further + // if (patientCategory != null) { + // val patientTypeFilterTag = + // applicationConfiguration().patientTypeFilterTagViaMetaCodingSystem + // + // val paramQueries: List<(TokenParamFilterCriterion.() -> Unit)> = + // patientCategory.map { healthStatus -> + // val coding: Coding = + // Coding().apply { + // system = patientTypeFilterTag + // code = healthStatus.name.lowercase().replace("_", "-") + // } + // return@map { value = of(coding) } + // } + // + // has(Appointment.PATIENT) { + // filter(TokenClientParam("_tag"), *paramQueries.toTypedArray(), operation = + // Operation.OR) + // } + // } } private suspend fun transformAppointment(appointment: Appointment): RegisterData { val refPatient = appointment.patientRef()!! val patient = defaultRepository.loadResource(refPatient) as Patient + val pregnancyStatus = defaultRepository.getPregnancyStatus(patient.logicalId) return RegisterData.AppointmentRegisterData( logicalId = patient.logicalId, appointmentLogicalId = appointment.logicalId, name = patient.extractName(), - identifier = - patient.identifier - .firstOrNull { identifier -> identifier.use == Identifier.IdentifierUse.OFFICIAL } - ?.value, + identifier = patient.extractOfficialIdentifier(), gender = patient.gender, age = patient.extractAge(), healthStatus = patient.extractHealthStatusFromMeta( applicationConfiguration().patientTypeFilterTagViaMetaCodingSystem, ), - isPregnant = defaultRepository.isPatientPregnant(patient), - isBreastfeeding = defaultRepository.isPatientBreastfeeding(patient), + isPregnant = pregnancyStatus == PregnancyStatus.Pregnant, + isBreastfeeding = pregnancyStatus == PregnancyStatus.BreastFeeding, reasons = appointment.reasonCode.flatMap { cc -> cc.coding.map { coding -> coding.code } }, ) } @@ -320,9 +295,13 @@ constructor( filter(Appointment.STATUS, { value = of(Appointment.AppointmentStatus.BOOKED.toCode()) }) filter(Appointment.DATE, { value = of(DateTimeType.today()) }) } - + val keySet = mutableSetOf() return appointments - .map { it.resource } + .mapNotNull { + if (keySet.contains(it.resource.logicalId)) return@mapNotNull null + keySet.add(it.resource.logicalId) + it.resource + } .filter { it.status == Appointment.AppointmentStatus.BOOKED && it.hasStart() && diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt index e673e58010..f54a20287d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt @@ -24,8 +24,6 @@ import com.google.android.fhir.search.StringFilterModifier import com.google.android.fhir.search.search import javax.inject.Inject import javax.inject.Singleton -import org.hl7.fhir.r4.model.CarePlan -import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient @@ -34,10 +32,10 @@ import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType -import org.hl7.fhir.r4.model.Task import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.data.domain.Guardian +import org.smartregister.fhircore.engine.data.domain.PregnancyStatus import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.HealthStatus import org.smartregister.fhircore.engine.domain.model.ProfileData @@ -45,10 +43,9 @@ import org.smartregister.fhircore.engine.domain.model.RegisterData import org.smartregister.fhircore.engine.domain.repository.PatientDao import org.smartregister.fhircore.engine.domain.repository.RegisterDao import org.smartregister.fhircore.engine.domain.util.PaginationConstant -import org.smartregister.fhircore.engine.util.extension.activelyBreastfeeding +import org.smartregister.fhircore.engine.util.extension.activeCarePlans import org.smartregister.fhircore.engine.util.extension.canonical import org.smartregister.fhircore.engine.util.extension.canonicalName -import org.smartregister.fhircore.engine.util.extension.clinicVisitOrder import org.smartregister.fhircore.engine.util.extension.extractAddress import org.smartregister.fhircore.engine.util.extension.extractAddressDistrict import org.smartregister.fhircore.engine.util.extension.extractAddressState @@ -57,13 +54,17 @@ import org.smartregister.fhircore.engine.util.extension.extractFamilyName import org.smartregister.fhircore.engine.util.extension.extractGeneralPractitionerReference import org.smartregister.fhircore.engine.util.extension.extractGivenName import org.smartregister.fhircore.engine.util.extension.extractHealthStatusFromMeta +import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.extractName import org.smartregister.fhircore.engine.util.extension.extractOfficialIdentifier import org.smartregister.fhircore.engine.util.extension.extractTelecom import org.smartregister.fhircore.engine.util.extension.familyName +import org.smartregister.fhircore.engine.util.extension.getPregnancyStatus +import org.smartregister.fhircore.engine.util.extension.getResourcesByIds import org.smartregister.fhircore.engine.util.extension.givenName -import org.smartregister.fhircore.engine.util.extension.hasActivePregnancy import org.smartregister.fhircore.engine.util.extension.loadResource +import org.smartregister.fhircore.engine.util.extension.patientConditions +import org.smartregister.fhircore.engine.util.extension.shouldShowOnProfile import org.smartregister.fhircore.engine.util.extension.toAgeDisplay import org.smartregister.fhircore.engine.util.extension.yearsPassed import timber.log.Timber @@ -146,6 +147,7 @@ constructor( override suspend fun loadProfileData(appFeatureName: String?, resourceId: String): ProfileData { val patient = defaultRepository.loadResource(resourceId)!! val configuration = getApplicationConfiguration() + val carePlan = patient.activeCarePlans(fhirEngine).firstOrNull() return ProfileData.HivProfileData( logicalId = patient.logicalId, @@ -163,22 +165,15 @@ constructor( phoneContacts = patient.extractTelecom(), chwAssigned = patient.generalPractitionerFirstRep, showIdentifierInProfile = true, + currentCarePlan = carePlan, healthStatus = patient.extractHealthStatusFromMeta(configuration.patientTypeFilterTagViaMetaCodingSystem), tasks = - patient - .activeTasks() - .sortedWith( - compareBy( - { - it.clinicVisitOrder(configuration.taskOrderFilterTagViaMetaCodingSystem) - ?: Double.MAX_VALUE - }, - // tasks with no clinicVisitOrder, would be sorted with Task#description - { it.description }, - ), - ), - services = patient.activeCarePlans(), + carePlan + ?.activity + ?.filter { it.shouldShowOnProfile() } + ?.sortedWith(compareBy(nullsLast()) { it?.detail?.code?.text?.toBigIntegerOrNull() }) + ?: listOf(), conditions = patient.activeConditions(), otherPatients = patient.otherChildren(), guardians = patient.guardians(), @@ -245,10 +240,15 @@ constructor( chwAssigned = Reference(), // Empty showIdentifierInProfile = false, healthStatus = HealthStatus.NOT_ON_ART, + currentCarePlan = null, ) - private suspend fun transformPatientToHivRegisterData(patient: Patient) = - RegisterData.HivRegisterData( + private suspend fun transformPatientToHivRegisterData( + patient: Patient, + ): RegisterData.HivRegisterData { + val pregnancyStatus = defaultRepository.getPregnancyStatus(patient.logicalId) + + return RegisterData.HivRegisterData( logicalId = patient.logicalId, identifier = hivPatientIdentifier(patient), name = patient.extractName(), @@ -264,9 +264,10 @@ constructor( patient.extractHealthStatusFromMeta( getApplicationConfiguration().patientTypeFilterTagViaMetaCodingSystem, ), - isPregnant = defaultRepository.isPatientPregnant(patient), - isBreastfeeding = defaultRepository.isPatientBreastfeeding(patient), + isPregnant = pregnancyStatus == PregnancyStatus.Pregnant, + isBreastfeeding = pregnancyStatus == PregnancyStatus.BreastFeeding, ) + } internal suspend fun Patient.activeConditions() = defaultRepository.patientConditions(this.logicalId).filter { condition -> @@ -281,7 +282,7 @@ constructor( subjectType = ResourceType.Patient, ) .also { - return if (it.isNullOrEmpty().not()) { + return if (it.isEmpty().not()) { it .sortedByDescending { it.effectiveDateTimeType.value } .distinctBy { it.code.coding.last().code } @@ -290,46 +291,6 @@ constructor( } } - internal suspend fun Patient.activeTasks(): List { - return this.activeCarePlans() - .flatMap { it.activity } - .flatMap { - it.outcomeReference - .filter { outcomeRef -> outcomeRef.reference.startsWith(ResourceType.Task.name) } - .map { reference -> - val task = defaultRepository.loadResource(reference) as Task - task.apply { - if ( - it.detail.status == CarePlan.CarePlanActivityStatus.COMPLETED && - status != Task.TaskStatus.COMPLETED - ) { - status = Task.TaskStatus.COMPLETED - } - } - } - } - } - - internal suspend fun Patient.activeCarePlans(): List { - patientCarePlan(this.logicalId) - .filter { it.status == CarePlan.CarePlanStatus.ACTIVE } - .apply { - val sortByLastUpdated = sortedBy { it.meta.lastUpdated } - return if (size > 1 || size == 1) { - listOf(sortByLastUpdated.first()) - } else { - listOf() - } - } - } - - internal suspend fun patientCarePlan(patientId: String) = - defaultRepository.searchResourceFor( - subjectId = patientId, - subjectType = ResourceType.Patient, - subjectParam = CarePlan.SUBJECT, - ) - internal suspend fun Patient.practitioners(): List { return generalPractitioner .mapNotNull { @@ -344,18 +305,12 @@ constructor( .distinctBy { it.logicalId } } - internal suspend fun Patient.otherPatients() = this.fetchOtherPatients(this.logicalId) - - internal suspend fun Patient.otherChildren(): List { - return this.fetchOtherPatients(this.logicalId) - } - - internal suspend fun Patient.fetchOtherPatients(patientId: String): List { + private suspend fun Patient.otherChildren(): List { val list: ArrayList = arrayListOf() val filteredItems = defaultRepository.searchResourceFor( - subjectId = patientId, + subjectId = logicalId, subjectType = ResourceType.Patient, subjectParam = Patient.LINK, ) @@ -366,31 +321,43 @@ constructor( return list } - internal fun Patient.isValidChildContact(): Boolean { + private fun Patient.isValidChildContact(): Boolean { val healthStatus = this.extractHealthStatusFromMeta( getApplicationConfiguration().patientTypeFilterTagViaMetaCodingSystem, ) - if (healthStatus == HealthStatus.CHILD_CONTACT || healthStatus == HealthStatus.EXPOSED_INFANT) { - if (this.hasBirthDate()) { - if (this.birthDate!!.yearsPassed() < LINKED_CHILD_AGE_LIMIT) return true - } - } else { - return true + if ( + healthStatus == HealthStatus.CHILD_CONTACT || + healthStatus == HealthStatus.EXPOSED_INFANT || + healthStatus == HealthStatus.CLIENT_ALREADY_ON_ART || + healthStatus == HealthStatus.NEWLY_DIAGNOSED_CLIENT + ) { + return this.hasBirthDate() && this.birthDate!!.yearsPassed() <= LINKED_CHILD_AGE_LIMIT } + return false } - internal suspend fun Patient.guardians(): List = - this.link - .filter { - (it.other.referenceElement.resourceType == ResourceType.RelatedPerson.name).or( - it.type == Patient.LinkType.REFER && - it.other.referenceElement.resourceType == ResourceType.Patient.name, - ) + internal suspend fun Patient.guardians(): List { + val patients = mutableListOf() + val relatedPersons = mutableListOf() + this.link.forEach { + if ( + it.type == Patient.LinkType.REFER && + it.other.referenceElement.resourceType == ResourceType.RelatedPerson.name + ) { + relatedPersons.add(it.other.extractId()) + } else if ( + it.type == Patient.LinkType.REFER && + it.other.referenceElement.resourceType == ResourceType.Patient.name + ) { + patients.add(it.other.extractId()) } - .map { defaultRepository.loadResource(it.other) } + } + return fhirEngine.getResourcesByIds(patients) + + fhirEngine.getResourcesByIds(relatedPersons) + } private fun getApplicationConfiguration(): ApplicationConfiguration { return configurationRegistry.getAppConfigs() @@ -409,6 +376,8 @@ constructor( return patients .filter(this::isValidPatient) .map { patient -> + val pregnancyStatus = defaultRepository.getPregnancyStatus(patient.logicalId) + RegisterData.HivRegisterData( logicalId = patient.logicalId, identifier = hivPatientIdentifier(patient), @@ -425,8 +394,8 @@ constructor( patient.extractHealthStatusFromMeta( getApplicationConfiguration().patientTypeFilterTagViaMetaCodingSystem, ), - isPregnant = defaultRepository.isPatientPregnant(patient), - isBreastfeeding = defaultRepository.isPatientBreastfeeding(patient), + isPregnant = pregnancyStatus == PregnancyStatus.Pregnant, + isBreastfeeding = pregnancyStatus == PregnancyStatus.BreastFeeding, ) } .filterNot { it.healthStatus == HealthStatus.DEFAULT } @@ -438,28 +407,8 @@ constructor( companion object { const val HAPI_MDM_TAG = "HAPI-MDM" - const val LINKED_CHILD_AGE_LIMIT = 20 + const val LINKED_CHILD_AGE_LIMIT = 15 const val ORGANISATION_SYSTEM = "http://smartregister.org/fhir/organization-tag" const val ORGANISATION_DISPLAY = "Practitioner Organization" } } - -suspend fun DefaultRepository.patientConditions(patientId: String) = - searchResourceFor( - subjectId = patientId, - subjectParam = Condition.SUBJECT, - subjectType = ResourceType.Patient, - ) - -suspend fun DefaultRepository.isPatientPregnant(patient: Patient) = - patientConditions(patient.logicalId).hasActivePregnancy() - -suspend fun DefaultRepository.isPatientBreastfeeding(patient: Patient) = - patientConditions(patient.logicalId).activelyBreastfeeding() - -infix fun Patient.belongsTo(code: String) = - meta.tag.any { - it.code == code && - it.system == HivRegisterDao.ORGANISATION_SYSTEM && - it.display == HivRegisterDao.ORGANISATION_DISPLAY - } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HomeTracingRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HomeTracingRegisterDao.kt index 0de8d613ce..adcc3fd09a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HomeTracingRegisterDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HomeTracingRegisterDao.kt @@ -24,6 +24,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.tracing.TracingRepository import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.ReasonConstants import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @Singleton @@ -46,10 +47,5 @@ constructor( sharedPreferencesHelper, ) { - override val tracingCoding: Coding = taskCode - - companion object { - val taskCode: Coding = - Coding("https://d-tree.org/fhir/contact-tracing", "home-tracing", "Home Tracing") - } + override val tracingCoding: Coding = ReasonConstants.homeTracingCoding } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/PhoneTracingRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/PhoneTracingRegisterDao.kt index 421746141c..d442acd689 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/PhoneTracingRegisterDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/PhoneTracingRegisterDao.kt @@ -24,6 +24,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.tracing.TracingRepository import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.ReasonConstants.phoneTracingCoding import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @Singleton @@ -46,10 +47,5 @@ constructor( sharedPreferencesHelper, ) { - override val tracingCoding: Coding = taskCode - - companion object { - val taskCode: Coding = - Coding("https://d-tree.org/fhir/contact-tracing", "phone-tracing", "Phone Tracing") - } + override val tracingCoding: Coding = phoneTracingCoding } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt index a645ba9e4d..b78bba289e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt @@ -36,7 +36,6 @@ import org.hl7.fhir.r4.model.Appointment import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateTimeType -import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Practitioner @@ -45,6 +44,7 @@ import org.hl7.fhir.r4.model.Task import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.data.domain.Guardian +import org.smartregister.fhircore.engine.data.domain.PregnancyStatus import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.RegisterFilter import org.smartregister.fhircore.engine.data.local.TracingAgeFilterEnum @@ -68,6 +68,8 @@ import org.smartregister.fhircore.engine.util.extension.extractHealthStatusFromM import org.smartregister.fhircore.engine.util.extension.extractName import org.smartregister.fhircore.engine.util.extension.extractOfficialIdentifier import org.smartregister.fhircore.engine.util.extension.extractTelecom +import org.smartregister.fhircore.engine.util.extension.getPregnancyStatus +import org.smartregister.fhircore.engine.util.extension.patientConditions import org.smartregister.fhircore.engine.util.extension.referenceValue import org.smartregister.fhircore.engine.util.extension.safeSubList import org.smartregister.fhircore.engine.util.extension.toAgeDisplay @@ -345,7 +347,8 @@ abstract class TracingRegisterDao( practitioners = patient.practitioners(), currentAttempt = attempt.copy( - reasons = tasks.mapNotNull { task -> task.reasonCode?.codingFirstRep?.display }, + reasons = + tasks.mapNotNull { task -> task.reasonCode?.codingFirstRep?.display }.distinct(), ), ) } @@ -448,23 +451,24 @@ abstract class TracingRegisterDao( .copy(reasons = tasks.mapNotNull { task -> task.reasonCode?.codingFirstRep?.code }) val oldestTaskDate = tasks.minOfOrNull { it.authoredOn } + val pregnancyStatus = defaultRepository.getPregnancyStatus(this.logicalId) + return RegisterData.TracingRegisterData( logicalId = this.logicalId, name = this.extractName(), - identifier = - this.identifier.firstOrNull { it.use == Identifier.IdentifierUse.OFFICIAL }?.value, + identifier = this.extractOfficialIdentifier(), gender = this.gender, familyName = this.extractFamilyName(), healthStatus = this.extractHealthStatusFromMeta( applicationConfiguration().patientTypeFilterTagViaMetaCodingSystem, ), - isPregnant = defaultRepository.isPatientPregnant(this), - isBreastfeeding = defaultRepository.isPatientBreastfeeding(this), + isPregnant = pregnancyStatus == PregnancyStatus.Pregnant, + isBreastfeeding = pregnancyStatus == PregnancyStatus.BreastFeeding, attempts = attempt.numberOfAttempts, lastAttemptDate = attempt.lastAttempt, firstAdded = oldestTaskDate, - reasons = attempt.reasons, + reasons = attempt.reasons.distinct(), ) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/tracing/TracingRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/tracing/TracingRepository.kt index 9367aba907..cd9b16e778 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/tracing/TracingRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/tracing/TracingRepository.kt @@ -38,6 +38,8 @@ import org.smartregister.fhircore.engine.domain.model.TracingHistory import org.smartregister.fhircore.engine.domain.model.TracingOutcome import org.smartregister.fhircore.engine.domain.model.TracingOutcomeDetails import org.smartregister.fhircore.engine.domain.util.PaginationConstant +import org.smartregister.fhircore.engine.util.ReasonConstants +import org.smartregister.fhircore.engine.util.SystemConstants import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.referenceValue import timber.log.Timber @@ -153,11 +155,15 @@ class TracingRepository @Inject constructor(val fhirEngine: FhirEngine) { Observation.CODE, { value = - of(CodeableConcept(Coding("https://d-tree.org", "tracing-outcome-conducted", ""))) - }, - { - value = - of(CodeableConcept(Coding("https://d-tree.org", "tracing-outcome-unconducted", ""))) + of( + CodeableConcept( + Coding( + SystemConstants.OBSERVATION_CODE_SYSTEM, + ReasonConstants.TRACING_OUTCOME_CODE, + ReasonConstants.TRACING_OUTCOME_CODE, + ), + ), + ) }, operation = Operation.OR, ) @@ -165,14 +171,15 @@ class TracingRepository @Inject constructor(val fhirEngine: FhirEngine) { outcomeObs .map { it.resource } .firstOrNull { - it.code.coding.any { coding -> - coding.code in arrayOf("tracing-outcome-conducted", "tracing-outcome-unconducted") - } + it.code.coding.any { coding -> coding.code == ReasonConstants.TRACING_OUTCOME_CODE } } - ?.let { - conducted = it.code.codingFirstRep.code == "tracing-outcome-conducted" - if (it.hasValueCodeableConcept()) { - outcome = it.valueCodeableConcept.text + ?.let { obs -> + conducted = + obs.component.firstOrNull()?.let { comp -> + if (comp.hasValueBooleanType()) comp.valueBooleanType?.value else false + } ?: false + if (obs.hasValueCodeableConcept()) { + outcome = obs.valueCodeableConcept.text } } @@ -186,34 +193,19 @@ class TracingRepository @Inject constructor(val fhirEngine: FhirEngine) { of( CodeableConcept( Coding( - "https://d-tree.org", - "phone-tracing-outcome-date-of-agreed-appointment", + SystemConstants.OBSERVATION_CODE_SYSTEM, + ReasonConstants.DATE_OF_AGREED_APPOINTMENT, "", ), ), ) }, - { - value = - of( - CodeableConcept( - Coding( - "https://d-tree.org", - "home-tracing-outcome-date-of-agreed-appointment", - "", - ), - ), - ) - }, - operation = Operation.OR, ) } dateObs .map { it.resource } .firstOrNull { - it.code.coding.any { coding -> - coding.code.endsWith("tracing-outcome-date-of-agreed-appointment") - } + it.code.coding.any { coding -> coding.code == ReasonConstants.DATE_OF_AGREED_APPOINTMENT } } ?.let { if (it.hasValueDateTimeType()) { @@ -280,7 +272,7 @@ class TracingRepository @Inject constructor(val fhirEngine: FhirEngine) { .firstOrNull { entry -> entry.flag.codingFirstRep.display == lastAttempt.item.reference.extractLogicalIdUuid() && - entry.flag.codingFirstRep.code == "tracing-outcome" + entry.flag.codingFirstRep.code == ReasonConstants.TRACING_OUTCOME_CODE } ?.flag ?.text diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/config/ConfigRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/config/ConfigRepository.kt index dbcf1aa90c..526178ddac 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/config/ConfigRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/config/ConfigRepository.kt @@ -41,6 +41,8 @@ constructor( .entry .map { it.resource } + if (binaryResources.isEmpty()) throw Exception("${appConfigService.getAppId()} not found") + defaultRepository.saveLocalOnly(*binaryResources.toTypedArray()) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt index 7032dbf3d8..4391183bb7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt @@ -29,7 +29,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.hl7.fhir.r4.context.SimpleWorkerContext -import org.hl7.fhir.r4.model.Parameters import org.smartregister.fhircore.engine.auditEvent.AuditEventRepository import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService @@ -40,6 +39,7 @@ import org.smartregister.fhircore.engine.domain.repository.PatientDao import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.trace.PerformanceReporter import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.worker.CoreSimpleWorkerContext @InstallIn(SingletonComponent::class) @Module @@ -68,11 +68,9 @@ class CoreModule { @Singleton @Provides - fun provideWorkerContextProvider(): SimpleWorkerContext = - SimpleWorkerContext().apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } + fun provideWorkerContextProvider(): SimpleWorkerContext { + return CoreSimpleWorkerContext() + } @Singleton @Provides diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/LocationHierarchy.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/LocationHierarchy.kt new file mode 100644 index 0000000000..10b3ed85d9 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/LocationHierarchy.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.domain.model + +import org.smartregister.fhircore.engine.util.extension.referenceValue +import org.smartregister.model.location.ChildTreeNode + +data class LocationHierarchy( + val identifier: String, + val name: String, + val children: List = listOf(), +) { + override fun toString(): String { + return name + } + + companion object { + fun fromLocationHierarchy( + hierarchy: org.smartregister.model.location.LocationHierarchy, + ): LocationHierarchy { + val parentNode = hierarchy.locationHierarchyTree.locationsHierarchy.listOfNodes.treeNode + return LocationHierarchy( + identifier = parentNode.node?.referenceValue() ?: "", + name = parentNode.node.name, + children = parentNode.children.map { createLocation(it) }, + ) + } + + private fun createLocation(node: ChildTreeNode): LocationHierarchy { + val parentNode = node.children + val locations = mutableListOf() + for (childNode in parentNode.children) { + locations.add(createLocation(childNode)) + } + return LocationHierarchy( + identifier = parentNode.node?.referenceValue() ?: "", + name = parentNode.node.name, + children = locations, + ) + } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ProfileData.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ProfileData.kt index 85a586bfd5..b8009200bc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ProfileData.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ProfileData.kt @@ -89,12 +89,13 @@ sealed class ProfileData(open val logicalId: String, open val name: String) { val birthdate: Date?, val age: String = birthdate.toAgeDisplay(), val gender: Enumerations.AdministrativeGender, + val currentCarePlan: CarePlan?, + val isClientVisit: Boolean = true, val address: String, val addressDistrict: String = "", val addressTracingCatchment: String = "", val addressPhysicalLocator: String = "", - val services: List = listOf(), - val tasks: List = listOf(), + val tasks: List = listOf(), val chwAssigned: Reference, val healthStatus: HealthStatus, val phoneContacts: List = listOf(), diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/util/DataLoadState.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/util/DataLoadState.kt index 0ab48ae0cd..8e72600935 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/util/DataLoadState.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/util/DataLoadState.kt @@ -17,6 +17,8 @@ package org.smartregister.fhircore.engine.domain.util sealed class DataLoadState { + data object Idle : DataLoadState() + data object Loading : DataLoadState() data class Success(val data: T) : DataLoadState() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt index 879cdeb6ca..698f78e690 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt @@ -25,6 +25,8 @@ import com.google.android.fhir.sync.FhirSyncWorker import com.google.android.fhir.sync.ResourceSyncException import com.google.android.fhir.sync.Sync import com.google.android.fhir.sync.SyncJobStatus +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -116,10 +118,14 @@ constructor( when (syncJobStatus) { is SyncJobStatus.Failed, is SyncJobStatus.Succeeded, -> { + if (syncJobStatus is SyncJobStatus.Failed) { + syncJobStatus.exceptions.forEachIndexed { _, resourceSyncException -> + Firebase.crashlytics.recordException(resourceSyncException.exception) + } + } tracer.putAttribute(SYNC_TRACE, SYNC_ATTR_RESULT, syncJobStatus::class.java.simpleName) tracer.stopTrace(SYNC_TRACE) } - is SyncJobStatus.InProgress -> {} is SyncJobStatus.Started -> { tracer.startTrace(SYNC_TRACE) tracer.putAttribute( @@ -128,6 +134,7 @@ constructor( if (isInitialSync()) SYNC_ATTR_TYPE_INITIAL else SYNC_ATTR_TYPE_SUBSEQUENT, ) } + else -> {} } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt index 6e55b63072..1378a8917f 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt @@ -91,7 +91,7 @@ constructor( fun loadSyncParams(): Map> { val userInfo = sharedPreferencesHelper.read(SharedPreferenceKey.USER_CLAIM_INFO.name) - val pairs = mutableListOf>>() + var pairs = mutableListOf>>() val appConfig = configurationRegistry.getAppConfigs() @@ -137,13 +137,12 @@ constructor( // e.g. [(Patient, {organization=105})] to [(Patient, {organization=105, _count=100})] val updatedPair = pair.second.toMutableMap().apply { put(sp.code, expressionValue) } val index = pairs.indexOfFirst { it.first == resourceType } - resourceType.filterBasedOnPerResourceType(pairs) pairs.set(index, Pair(resourceType, updatedPair)) } } } } - + pairs = filterBasedOnPerResourceType(pairs) val syncConfigParams = sharedPreferencesHelper.filterByResourceLocation(pairs) Timber.i("SYNC CONFIG $syncConfigParams") @@ -151,61 +150,64 @@ constructor( } } -private fun ResourceType.filterBasedOnPerResourceType( +private fun filterBasedOnPerResourceType( pairs: MutableList>>, -) = - when (this) { - ResourceType.RelatedPerson -> - pairs.addParam(resourceType = this, param = RelatedPerson.SP_ACTIVE, value = true.toString()) - ResourceType.Patient -> - pairs.addParam(resourceType = this, param = Patient.SP_ACTIVE, value = true.toString()) - - // ResourceType.CarePlan -> - // pairs.addParam( - // resourceType = this, - // param = CarePlan.SP_STATUS, - // value = CarePlan.CarePlanStatus.ACTIVE.toString().lowercase(), - // ) - - ResourceType.Observation -> - pairs.addParam( - resourceType = this, - param = Observation.SP_STATUS, - value = Observation.ObservationStatus.FINAL.toString().lowercase(), - ) - - // ResourceType.Task -> - // pairs.addParam( - // resourceType = this, - // param = Task.SP_STATUS, - // value = - // String.format( - // "%s,%s", - // Task.TaskStatus.FAILED.toString().lowercase(), - // Task.TaskStatus.INPROGRESS.toString().lowercase() - // ) - // ) - - ResourceType.Appointment -> - pairs.addParam( - resourceType = this, - param = Appointment.SP_STATUS, - value = Appointment.AppointmentStatus.BOOKED.toString().lowercase(), - ) - ResourceType.Encounter -> - pairs.addParam( - resourceType = this, - param = Encounter.SP_STATUS, - value = Encounter.EncounterStatus.INPROGRESS.toString().lowercase(), - ) - ResourceType.List -> - pairs.addParam( - resourceType = this, - param = ListResource.SP_STATUS, - value = ListResource.ListStatus.CURRENT.toString().lowercase(), - ) - else -> Unit - } +): MutableList>> { + pairs.addParam( + resourceType = ResourceType.RelatedPerson, + param = RelatedPerson.SP_ACTIVE, + value = true.toString(), + ) + pairs.addParam( + resourceType = ResourceType.Patient, + param = Patient.SP_ACTIVE, + value = true.toString(), + ) + pairs.addParam( + resourceType = ResourceType.Observation, + param = Observation.SP_STATUS, + value = Observation.ObservationStatus.PRELIMINARY.toString().lowercase(), + ) + pairs.addParam( + resourceType = ResourceType.Appointment, + param = Appointment.SP_STATUS, + value = Appointment.AppointmentStatus.BOOKED.toString().lowercase(), + ) + pairs.addParam( + resourceType = ResourceType.Appointment, + param = Appointment.SP_STATUS, + value = Appointment.AppointmentStatus.PROPOSED.toString().lowercase(), + ) + pairs.addParam( + resourceType = ResourceType.Encounter, + param = Encounter.SP_STATUS, + value = Encounter.EncounterStatus.INPROGRESS.toString().lowercase(), + ) + pairs.addParam( + resourceType = ResourceType.List, + param = ListResource.SP_STATUS, + value = ListResource.ListStatus.CURRENT.toString().lowercase(), + ) + // ResourceType.CarePlan -> + // pairs.addParam( + // resourceType = this, + // param = CarePlan.SP_STATUS, + // value = CarePlan.CarePlanStatus.ACTIVE.toString().lowercase(), + // ) + + // ResourceType.Task -> + // pairs.addParam( + // resourceType = this, + // param = Task.SP_STATUS, + // value = + // String.format( + // "%s,%s", + // Task.TaskStatus.FAILED.toString().lowercase(), + // Task.TaskStatus.INPROGRESS.toString().lowercase() + // ) + // ) + return pairs +} private fun SharedPreferencesHelper.filterByResourceLocation( pairs: MutableList>>, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt index ff32b04017..c700b91c46 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt @@ -18,7 +18,6 @@ package org.smartregister.fhircore.engine.task import com.google.android.fhir.FhirEngine import com.google.android.fhir.get -import com.google.android.fhir.search.search import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -36,6 +35,7 @@ import org.hl7.fhir.r4.utils.FHIRPathEngine import org.hl7.fhir.r4.utils.StructureMapUtilities import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.util.extension.encodeResourceToString +import org.smartregister.fhircore.engine.util.extension.getCarePlanId import org.smartregister.fhircore.engine.util.helper.TransformSupportServices import timber.log.Timber @@ -133,33 +133,31 @@ constructor(val fhirEngine: FhirEngine, val transformSupportServices: TransformS } suspend fun completeTask(id: String, encounterStatus: EncounterStatus?) { - fhirEngine.run { - val task = - get(id).apply { - this.status = encounterStatusToTaskStatus(encounterStatus) - this.lastModified = Date() - } - update(task) - if (task.status == Task.TaskStatus.COMPLETED) { - val carePlans = - search { filter(CarePlan.SUBJECT, { value = task.`for`.reference }) } - .map { it.resource } - var carePlanToUpdate: CarePlan? = null - carePlans.forEach { carePlan -> - for ((index, value) in carePlan.activity.withIndex()) { - val outcome = value.outcomeReference.find { x -> x.reference.contains(id) } - if (outcome != null) { - carePlanToUpdate = carePlan.copy() - value.detail.status = CarePlan.CarePlanActivityStatus.COMPLETED - value.outcomeReference.first().reference = "Task/${task.id}" - carePlanToUpdate?.activity?.set(index, value) - break - } + val resourcesToUpdate = mutableListOf() + val task = + fhirEngine.get(id).apply { + this.status = encounterStatusToTaskStatus(encounterStatus) + this.lastModified = Date() + } + resourcesToUpdate.add(task) + val carePlanId = task.getCarePlanId() + + if (carePlanId != null) { + val carePlan = runCatching { fhirEngine.get(carePlanId) }.getOrNull() + if (carePlan != null) { + for ((index, value) in carePlan.activity.withIndex()) { + val outcome = value.outcomeReference.find { x -> x.reference.contains(id) } + if (outcome != null) { + value.detail.status = taskStatusToCarePlanActivityStatus(task.status) + carePlan.activity?.set(index, value) + break } } - carePlanToUpdate?.let { update(it) } + resourcesToUpdate.add(carePlan) } } + + fhirEngine.update(*resourcesToUpdate.toTypedArray()) } private fun encounterStatusToTaskStatus(encounterStatus: EncounterStatus?): Task.TaskStatus { @@ -178,4 +176,18 @@ constructor(val fhirEngine: FhirEngine, val transformSupportServices: TransformS else -> Task.TaskStatus.COMPLETED } } + + private fun taskStatusToCarePlanActivityStatus( + status: Task.TaskStatus, + ): CarePlan.CarePlanActivityStatus { + return when (status) { + Task.TaskStatus.FAILED, + Task.TaskStatus.CANCELLED, -> CarePlan.CarePlanActivityStatus.CANCELLED + Task.TaskStatus.COMPLETED, + Task.TaskStatus.ONHOLD, + Task.TaskStatus.INPROGRESS, + Task.TaskStatus.ENTEREDINERROR, -> CarePlan.CarePlanActivityStatus.fromCode(status.toCode()) + else -> CarePlan.CarePlanActivityStatus.NULL + } + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt new file mode 100644 index 0000000000..82107233de --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt @@ -0,0 +1,431 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.task + +import android.content.Context +import ca.uhn.fhir.rest.gclient.TokenClientParam +import ca.uhn.fhir.rest.param.ParamPrefixEnum +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.get +import com.google.android.fhir.search.Operation +import com.google.android.fhir.search.search +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDate +import java.time.ZoneId +import java.util.Calendar +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton +import org.hl7.fhir.r4.model.Appointment +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.IdType +import org.hl7.fhir.r4.model.Meta +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Period +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.Task +import org.joda.time.DateTime +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.domain.model.HealthStatus +import org.smartregister.fhircore.engine.util.ReasonConstants +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.SystemConstants +import org.smartregister.fhircore.engine.util.extension.activeCarePlans +import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.extractHealthStatusFromMeta +import org.smartregister.fhircore.engine.util.extension.extractId +import org.smartregister.fhircore.engine.util.extension.getCarePlanId +import org.smartregister.fhircore.engine.util.extension.hasPastEnd +import org.smartregister.fhircore.engine.util.extension.plusDays +import org.smartregister.fhircore.engine.util.extension.referenceValue +import org.smartregister.fhircore.engine.util.extension.toCoding +import timber.log.Timber + +@Singleton +class FhirResourceUtil +@Inject +constructor( + @ApplicationContext val appContext: Context, + private val fhirEngine: FhirEngine, + val sharedPreferencesHelper: SharedPreferencesHelper, + val defaultRepository: DefaultRepository, +) { + + private val currentPractitioner by lazy { + sharedPreferencesHelper.read( + key = SharedPreferenceKey.PRACTITIONER_ID.name, + defaultValue = null, + ) + } + + suspend fun expireOverdueTasks() { + Timber.i("Starting task scheduler") + val carePlanMap = mutableMapOf() + val tasksToUpdate = mutableListOf() + + fhirEngine + .search { + filter( + Task.STATUS, + { value = of(Task.TaskStatus.REQUESTED.toCoding()) }, + { value = of(Task.TaskStatus.READY.toCoding()) }, + { value = of(Task.TaskStatus.ACCEPTED.toCoding()) }, + { value = of(Task.TaskStatus.INPROGRESS.toCoding()) }, + { value = of(Task.TaskStatus.RECEIVED.toCoding()) }, + ) + + filter( + Task.PERIOD, + { + prefix = ParamPrefixEnum.ENDS_BEFORE + value = of(DateTimeType(Date())) + }, + ) + } + .map { it.resource } + .filter { + it.hasPastEnd() && + it.status in + arrayOf( + Task.TaskStatus.REQUESTED, + Task.TaskStatus.READY, + Task.TaskStatus.ACCEPTED, + Task.TaskStatus.INPROGRESS, + Task.TaskStatus.RECEIVED, + ) + } + .onEach { task -> + task.status = Task.TaskStatus.FAILED + + val carePlanId = task.getCarePlanId() + + val carePlan = + carePlanId?.let { id -> + if (carePlanMap.containsKey(id)) { + carePlanMap[id] + } else { + runCatching { fhirEngine.get(id) }.getOrNull() + } + } + + if (carePlan != null) { + kotlin + .runCatching { + val index = + carePlan.activity.indexOfFirst { activity -> + activity.outcomeReference.firstOrNull()?.reference == task.referenceValue() + } + if (index != -1) { + val item = carePlan.activity?.get(index) + item?.detail?.status = CarePlan.CarePlanActivityStatus.STOPPED + carePlan.activity[index] = item + Timber.d("Updating carePlan: ${carePlan.referenceValue()}") + carePlanMap[carePlanId] = carePlan + } + } + .onFailure { + Timber.e( + "${carePlan.referenceValue()} CarePlan was not found. In consistent data ${it.message}", + ) + } + } + + Timber.d("Updating task: ${task.referenceValue()}") + tasksToUpdate.add(task) + } + + Timber.d("Going to expire tasks = ${tasksToUpdate.size} and carePlans = ${carePlanMap.size}") + fhirEngine.update(*(tasksToUpdate + carePlanMap.values).toTypedArray()) + + Timber.i("Done task scheduling") + } + + suspend fun handleMissedAppointment() { + Timber.i("Checking missed Appointments") + val tracingTasksToAdd = mutableListOf() + val missedAppointments = + fhirEngine + .search { + filter( + Appointment.STATUS, + { value = of(Appointment.AppointmentStatus.BOOKED.toCode()) }, + { value = of(Appointment.AppointmentStatus.WAITLIST.toCode()) }, + operation = Operation.OR, + ) + filter( + Appointment.DATE, + { + value = of(DateTimeType.today()) + prefix = ParamPrefixEnum.LESSTHAN + }, + ) + } + .mapNotNull { + val appointment = it.resource + + if ( + !(appointment.hasStart() && + appointment.start.before(DateTime().withTimeAtStartOfDay().toDate()) && + (appointment.status == Appointment.AppointmentStatus.BOOKED || + appointment.status == Appointment.AppointmentStatus.WAITLIST)) + ) { + return@mapNotNull null + } + + val today = LocalDate.now() + val missedAppointmentInRange = + LocalDate.from(appointment.start.plusDays(7).toInstant().atZone(ZoneId.systemDefault())) + .let { missedAppointmentDate -> + today.isAfter(missedAppointmentDate) || today.isEqual(missedAppointmentDate) + } + val missedMilestoneInRange = + LocalDate.from(appointment.start.plusDays(1).toInstant().atZone(ZoneId.systemDefault())) + .let { missedMilestoneDate -> + today.isAfter(missedMilestoneDate) || today.isEqual(missedMilestoneDate) + } + + if ((missedAppointmentInRange) || (missedMilestoneInRange)) { + if (missedMilestoneInRange) { + appointment.status = Appointment.AppointmentStatus.WAITLIST + tracingTasksToAdd.addAll(addMissedAppointment(appointment, true)) + } + if(missedAppointmentInRange) { + appointment.status = Appointment.AppointmentStatus.NOSHOW + tracingTasksToAdd.addAll(addMissedAppointment(appointment, false)) + } + } else { + appointment.status = Appointment.AppointmentStatus.WAITLIST + } + + appointment + } + + if (tracingTasksToAdd.isNotEmpty()) { + defaultRepository.create(addResourceTags = true, *tracingTasksToAdd.toTypedArray()) + } + + if (missedAppointments.isNotEmpty()) { + fhirEngine.update(*missedAppointments.toTypedArray()) + } + + Timber.i( + "Updated ${missedAppointments.size} missed appointments, created tracing tasks: ${tracingTasksToAdd.size}", + ) + } + + suspend fun handleWelcomeServiceAppointmentWorker() { + Timber.i("Checking 'Welcome Service' appointments") + val tracingTasks = mutableListOf() + + val proposedAppointments = + fhirEngine + .search { + filter( + Appointment.STATUS, + { value = of(Appointment.AppointmentStatus.PROPOSED.toCode()) }, + ) + filter(Appointment.REASON_CODE, { value = of(ReasonConstants.WelcomeServiceCode) }) + filter( + Appointment.DATE, + { + value = of(DateTimeType.today()) + prefix = ParamPrefixEnum.LESSTHAN_OR_EQUALS + }, + ) + } + .map { it.resource } + .filter { + it.hasStart() && + it.hasSupportingInformation() && + it.supportingInformation.any { reference -> + reference.referenceElement.resourceType == ResourceType.Appointment.name + } + } + .map { + val supportFinishVisitAppointmentRef = + it.supportingInformation.first { reference -> + reference.referenceElement.resourceType == ResourceType.Appointment.name + } + val supportFinishVisitAppointmentRefId = + IdType(supportFinishVisitAppointmentRef.reference).idPart + val supportFinishVisitAppointment = + fhirEngine.get(supportFinishVisitAppointmentRefId) + Pair(it, supportFinishVisitAppointment) + } + + val proposedAppointmentsToCancel = + proposedAppointments + .filter { + val finishVisitAppointment = it.second + finishVisitAppointment.status == Appointment.AppointmentStatus.FULFILLED + } + .map { it.first } + + val proposedAppointmentsToBook = + proposedAppointments + .filter { + val finishVisitAppointment = it.second + val isValid = + finishVisitAppointment.status in + arrayOf(Appointment.AppointmentStatus.NOSHOW, Appointment.AppointmentStatus.BOOKED) + + if (isValid) { + addToTracingList( + finishVisitAppointment, + ReasonConstants.interruptedTreatmentTracingCode, + ) + ?.let { task -> tracingTasks.add(task) } + } + + isValid + } + .map { it.first } + + defaultRepository.create(addResourceTags = true, *tracingTasks.toTypedArray()) + bookWelcomeService(proposedAppointmentsToBook.toTypedArray()) + cancelWelcomeService(proposedAppointmentsToCancel.toTypedArray()) + + Timber.i( + "proposedAppointmentsToBook: ${proposedAppointmentsToBook.size}, proposedAppointmentsToCancel: ${proposedAppointmentsToCancel.size}, tracing tasks: ${tracingTasks.size}", + ) + } + + private suspend fun bookWelcomeService(appointments: Array) { + appointments.forEach { it.status = Appointment.AppointmentStatus.BOOKED } + fhirEngine.update(*appointments) + } + + private suspend fun cancelWelcomeService(appointments: Array) { + appointments.forEach { it.status = Appointment.AppointmentStatus.CANCELLED } + fhirEngine.update(*appointments) + } + + private suspend fun addMissedAppointment( + appointment: Appointment, + isMilestoneAppointment: Boolean, + ): List { + val tracingTasks = mutableListOf() + val patient = getPatient(appointment) ?: return listOf() + val isEID = + patient.extractHealthStatusFromMeta( + SystemConstants.PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM, + ) == HealthStatus.EXPOSED_INFANT + + if (isEID && isMilestoneAppointment) { + val carePlan = patient.activeCarePlans(fhirEngine).firstOrNull() + val hasMileStoneTest = + carePlan?.activity?.firstOrNull { + it.hasDetail() && + it.detail.code.codingFirstRep.code == "Questionnaire/exposed-infant-milestone-hiv-test" + } != null + val milestoneTracingTaskDoesNotExist = + fhirEngine + .search { + filter( + TokenClientParam("_tag"), + { value = of(ReasonConstants.homeTracingCoding) }, + { value = of(ReasonConstants.phoneTracingCoding) }, + ) + filter(Task.CODE, { value = of(ReasonConstants.missedMilestoneAppointmentTracingCode) }) + filter( + Task.STATUS, + { value = of(Task.TaskStatus.READY.toCode()) }, + { value = of(Task.TaskStatus.INPROGRESS.toCode()) }, + operation = Operation.OR, + ) + } + .isEmpty() + if (hasMileStoneTest && milestoneTracingTaskDoesNotExist) { + addToTracingList(appointment, ReasonConstants.missedMilestoneAppointmentTracingCode)?.let { + tracingTasks.add(it) + } + } + } + if(!isMilestoneAppointment) { + addToTracingList( + appointment, + if (isEID) { + ReasonConstants.missedRoutineAppointmentTracingCode + } else ReasonConstants.missedAppointmentTracingCode, + ) + ?.let { tracingTasks.add(it) } + } + + return tracingTasks + } + + private suspend fun addToTracingList(appointment: Appointment, coding: Coding): Task? { + val patient = getPatient(appointment) ?: return null + return createTracingTask( + patient, + currentPractitioner!!.asReference(ResourceType.Practitioner), + coding, + ) + } + + private fun createTracingTask(patient: Patient, practitioner: Reference, coding: Coding): Task { + val hasPhone = patient.hasTelecom() + val now = Calendar.getInstance().time + return Task().apply { + meta = + Meta().apply { + tag = + mutableListOf( + if (hasPhone) { + ReasonConstants.phoneTracingCoding + } else ReasonConstants.homeTracingCoding, + ) + } + status = Task.TaskStatus.READY + intent = Task.TaskIntent.PLAN + priority = Task.TaskPriority.ROUTINE + authoredOn = now + lastModified = now + code = + CodeableConcept( + Coding("http://snomed.info/sct", "225368008", "Contact tracing (procedure)"), + ) + `for` = patient.asReference() + owner = practitioner + executionPeriod = Period().apply { start = now } + reasonCode = + CodeableConcept( + coding, + ) + .apply { text = coding.display } + } + } + + private suspend fun getPatient(appointment: Appointment): Patient? { + try { + val patientRef = + appointment.participant + .firstOrNull { it.hasActor() && it.actor.reference.contains(ResourceType.Patient.name) } + ?.actor + ?.extractId() ?: return null + return fhirEngine.get(patientRef) + } catch (e: Exception) { + return null + } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirTaskPlanWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirTaskPlanWorker.kt index 8f322eb992..41476bd275 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirTaskPlanWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirTaskPlanWorker.kt @@ -20,19 +20,10 @@ import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.get -import com.google.android.fhir.search.search import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import org.hl7.fhir.r4.model.CarePlan -import org.hl7.fhir.r4.model.ResourceType -import org.hl7.fhir.r4.model.Task -import org.smartregister.fhircore.engine.util.extension.extractId -import org.smartregister.fhircore.engine.util.extension.hasPastEnd -import org.smartregister.fhircore.engine.util.extension.hasStarted -import org.smartregister.fhircore.engine.util.extension.isLastTask -import timber.log.Timber +import kotlinx.coroutines.withContext +import org.smartregister.fhircore.engine.util.DispatcherProvider @HiltWorker class FhirTaskPlanWorker @@ -40,58 +31,15 @@ class FhirTaskPlanWorker constructor( @Assisted val appContext: Context, @Assisted workerParams: WorkerParameters, - val fhirEngine: FhirEngine, + private val fhirResourceUtil: FhirResourceUtil, + val dispatcherProvider: DispatcherProvider, ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { - Timber.i("Starting task scheduler") - - // TODO also filter by date range for better performance - fhirEngine - .search { - filter( - Task.STATUS, - { value = of(Task.TaskStatus.REQUESTED.toCode()) }, - { value = of(Task.TaskStatus.READY.toCode()) }, - { value = of(Task.TaskStatus.ACCEPTED.toCode()) }, - { value = of(Task.TaskStatus.INPROGRESS.toCode()) }, - { value = of(Task.TaskStatus.RECEIVED.toCode()) }, - ) - } - .map { it.resource } - .filter { - it.status in - arrayOf( - Task.TaskStatus.REQUESTED, - Task.TaskStatus.READY, - Task.TaskStatus.ACCEPTED, - Task.TaskStatus.INPROGRESS, - Task.TaskStatus.RECEIVED, - ) - } - .forEach { task -> - if (task.hasPastEnd()) { - task.status = Task.TaskStatus.FAILED - fhirEngine.update(task) - task.basedOn - .find { it.reference.startsWith(ResourceType.CarePlan.name) } - ?.extractId() - ?.takeIf { it.isNotBlank() } - ?.let { - val carePlan = fhirEngine.get(it) - if (carePlan.isLastTask(task)) { - carePlan.status = CarePlan.CarePlanStatus.COMPLETED - fhirEngine.update(carePlan) - } - } - } else if (task.hasStarted() && task.status != Task.TaskStatus.READY) { - task.status = Task.TaskStatus.READY - fhirEngine.update(task) - } - } - - Timber.i("Done task scheduling") - return Result.success() + return withContext(dispatcherProvider.io()) { + fhirResourceUtil.expireOverdueTasks() + Result.success() + } } companion object { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WelcomeServiceBackToCarePlanWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WelcomeServiceBackToCarePlanWorker.kt index 5affbb9e6f..457c5e993b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WelcomeServiceBackToCarePlanWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/WelcomeServiceBackToCarePlanWorker.kt @@ -46,6 +46,8 @@ import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.engine.util.extension.referenceValue import timber.log.Timber +// TODO: Programs needs to revisit workflow, combine with +// ProposedWelcomeServiceAppointmentsWorker.kt @HiltWorker class WelcomeServiceBackToCarePlanWorker @AssistedInject diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppException.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppException.kt index 59631deff2..852385262a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppException.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppException.kt @@ -22,4 +22,4 @@ class InternetConnectionException : AppException() class ServerException : AppException() -class ConfigurationErrorException : AppException() +class ConfigurationErrorException(override val message: String?) : AppException() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt index 1946ea1e0f..54d6fb7d5c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt @@ -99,7 +99,7 @@ fun AppSettingScreen( is DataLoadState.Success -> { Text(text = "Data loaded successfully") } - is DataLoadState.Loading -> { + else -> { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt index 0df80b4d0d..8c74a95ebf 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import java.net.UnknownHostException import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry @@ -32,6 +33,7 @@ import org.smartregister.fhircore.engine.domain.util.DataLoadState import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import retrofit2.HttpException +import timber.log.Timber @HiltViewModel class AppSettingViewModel @@ -52,13 +54,18 @@ constructor( val goToHome = _goToHome fun loadConfigurations() { - viewModelScope.launch { - val loaded = configurationRegistry.loadConfigurations() - if (loaded) { - _goToHome.value = true - _loadState.postValue(DataLoadState.Success(data = true)) - } else { - fetchRemoteConfigurations() + viewModelScope.launch(Dispatchers.IO) { + try { + val loaded = configurationRegistry.loadConfigurations() + if (loaded) { + _goToHome.value = true + _loadState.postValue(DataLoadState.Success(data = true)) + } else { + fetchRemoteConfigurations() + } + } catch (e: Exception) { + Timber.e(e) + _loadState.postValue(DataLoadState.Error(ConfigurationErrorException(e.message))) } } } @@ -69,17 +76,18 @@ constructor( _loadState.postValue(DataLoadState.Loading) configRepository.fetchConfigFromRemote() loadConfigurations() - _loadState.postValue(DataLoadState.Success(data = true)) } catch (unknownHostException: UnknownHostException) { _loadState.postValue(DataLoadState.Error(InternetConnectionException())) } catch (httpException: HttpException) { if ((400..503).contains(httpException.response()!!.code())) { _loadState.postValue(DataLoadState.Error(ServerException())) } else { - _loadState.postValue(DataLoadState.Error(ConfigurationErrorException())) + _loadState.postValue( + DataLoadState.Error(ConfigurationErrorException(httpException.message)), + ) } } catch (e: Exception) { - _loadState.postValue(DataLoadState.Error(ConfigurationErrorException())) + _loadState.postValue(DataLoadState.Error(ConfigurationErrorException(e.message))) } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt index 733d20484f..d4410e8408 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt @@ -40,6 +40,7 @@ import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceS import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse import org.smartregister.fhircore.engine.data.remote.model.response.UserClaimInfo import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator +import org.smartregister.fhircore.engine.domain.model.LocationHierarchy import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey @@ -198,6 +199,7 @@ constructor( } } } catch (ex: Exception) { + Timber.e(ex) if (ex is PractitionerNotFoundException) { _loginErrorState.postValue(LoginErrorState.INVALID_CREDENTIALS) } else { @@ -273,14 +275,14 @@ constructor( write(ResourceType.CareTeam.name, careTeams.map { it.id.extractLogicalIdUuid() }) write(ResourceType.Organization.name, organisationIds) write(ResourceType.Location.name, locations.map { it.id.extractLogicalIdUuid() }) - write( - SharedPreferenceKey.PRACTITIONER_LOCATION_HIERARCHIES.name, - locationHierarchies, - ) write( key = SharedPreferenceKey.PRACTITIONER_ID.name, value = practitionerId, ) + write( + key = SharedPreferenceKey.PRACTITIONER_LOCATION_HIERARCHIES.name, + value = locationHierarchies.map { LocationHierarchy.fromLocationHierarchy(it) }, + ) write(SharedPreferenceKey.PRACTITIONER_DETAILS.name, practitionerDetails) write( SharedPreferenceKey.USER_CLAIM_INFO.name, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/ExtractionProgress.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/ExtractionProgress.kt index 54ebe2e16e..0bae4c509d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/ExtractionProgress.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/ExtractionProgress.kt @@ -16,10 +16,16 @@ package org.smartregister.fhircore.engine.ui.questionnaire +import java.lang.Exception +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Resource sealed class ExtractionProgress { - class Success(val extras: List? = null) : ExtractionProgress() + class Success( + val questionnaireResponse: QuestionnaireResponse, + val extras: List? = null, + ) : ExtractionProgress() - object Failed : ExtractionProgress() + class Failed(val questionnaireResponse: QuestionnaireResponse, val exception: Exception) : + ExtractionProgress() } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt index 9116e779e4..50870eca08 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt @@ -58,7 +58,6 @@ import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.AlertDialogue.showConfirmAlert import org.smartregister.fhircore.engine.ui.base.AlertDialogue.showProgressAlert import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity -import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.DEFAULT_PROVIDER import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.extension.FieldType import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString @@ -146,6 +145,15 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList } }, ) + + questionnaireViewModel.extractionProgress.observe(this) { result -> + if (result is ExtractionProgress.Success) { + onExtractionSuccess(result.questionnaireResponse, result.extras) + } else { + result as ExtractionProgress.Failed + onExtractionFailed(result.questionnaireResponse, result.exception) + } + } } fun updateViews() { @@ -187,9 +195,11 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList .setQuestionnaire(questionnaireString) .showReviewPageBeforeSubmit(questionnaire.isPaginated) .setShowSubmitButton(true) - .setCustomQuestionnaireItemViewHolderFactoryMatchersProvider(DEFAULT_PROVIDER) + .setCustomQuestionnaireItemViewHolderFactoryMatchersProvider( + QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.DEFAULT_PROVIDER, + ) .setIsReadOnly(questionnaireType.isReadOnly()) - questionnaireResponse?.let { + questionnaireResponse.let { it.distinctifyLinkId() // Timber.e(it.encodeResourceToString()) questionnaireFragmentBuilder.setQuestionnaireResponse(it.encodeResourceToString()) @@ -328,23 +338,22 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList } open fun showFormSubmissionConfirmAlert() { - if (questionnaire.experimental) { - showConfirmAlert( - context = this, - message = R.string.questionnaire_alert_test_only_message, - title = R.string.questionnaire_alert_test_only_title, - confirmButtonListener = { handleQuestionnaireSubmit() }, - confirmButtonText = R.string.questionnaire_alert_test_only_button_title, - ) - } else { - showConfirmAlert( - context = this, - message = R.string.questionnaire_alert_submit_message, - title = R.string.questionnaire_alert_submit_title, - confirmButtonListener = { handleQuestionnaireSubmit() }, - confirmButtonText = R.string.questionnaire_alert_submit_button_title, - ) - } + showConfirmAlert( + context = this, + message = + if (questionnaire.experimental) { + R.string.questionnaire_alert_test_only_message + } else R.string.questionnaire_alert_submit_message, + title = + if (questionnaire.experimental) { + R.string.questionnaire_alert_test_only_title + } else R.string.questionnaire_alert_submit_title, + confirmButtonListener = { handleQuestionnaireSubmit() }, + confirmButtonText = + if (questionnaire.experimental) { + R.string.questionnaire_alert_test_only_button_title + } else R.string.questionnaire_alert_submit_button_title, + ) } suspend fun getQuestionnaireResponse(): QuestionnaireResponse { @@ -360,50 +369,45 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList } open fun handleQuestionnaireSubmit() { - saveProcessingAlertDialog = showProgressAlert(this, R.string.form_progress_message) - - lifecycleScope.launch { - val questionnaireResponse = getQuestionnaireResponse() - val isQuestionnaireResponseValid: Boolean - withContext(dispatcherProvider.unconfined()) { - isQuestionnaireResponseValid = validQuestionnaireResponse(questionnaireResponse) - } - - if (!isQuestionnaireResponseValid) { - saveProcessingAlertDialog.dismiss() - - AlertDialogue.showErrorAlert( - this@QuestionnaireActivity, - R.string.questionnaire_alert_invalid_message, - R.string.questionnaire_alert_invalid_title, - ) - return@launch - } - handleQuestionnaireResponse(questionnaireResponse) - - questionnaireViewModel.extractionProgress.observe(this@QuestionnaireActivity) { result -> - if (result is ExtractionProgress.Success) { - onPostSave(true, questionnaireResponse, result.extras) - } else { - onPostSave(false, questionnaireResponse) + saveProcessingAlertDialog = + showProgressAlert(this@QuestionnaireActivity, R.string.form_progress_message) + val doHandleQuestionnaireResponse = suspend { + getQuestionnaireResponse() + .takeIf { validQuestionnaireResponse(it) } + ?.let { handleQuestionnaireResponse(it) } + ?: saveProcessingAlertDialog.dismiss().also { + AlertDialogue.showErrorAlert( + this@QuestionnaireActivity, + R.string.questionnaire_alert_invalid_message, + R.string.questionnaire_alert_invalid_title, + ) } - } } + lifecycleScope.launch { doHandleQuestionnaireResponse() } } - fun onPostSave( - result: Boolean, + private fun onExtractionSuccess( questionnaireResponse: QuestionnaireResponse, extras: List? = null, ) { dismissSaveProcessing() - if (result) { - // Put Sync Here - syncBroadcaster.runSync() - postSaveSuccessful(questionnaireResponse, extras) - } else { - Timber.e("An error occurred during extraction") - } + syncBroadcaster.runSync() + postSaveSuccessful(questionnaireResponse, extras) + } + + private fun onExtractionFailed(questionnaireResponse: QuestionnaireResponse, err: Throwable) { + dismissSaveProcessing() + Timber.e("An error occurred during '${questionnaireResponse.questionnaire}' extraction: $err") + showConfirmAlert( + context = this, + message = R.string.error_extraction, + title = + if (questionnaire.experimental) { + R.string.questionnaire_alert_test_only_title + } else R.string.questionnaire_alert_submit_title, + confirmButtonListener = { handleQuestionnaireSubmit() }, + confirmButtonText = R.string.retry_extraction, + ) } open fun populateInitialValues(questionnaire: Questionnaire) = Unit @@ -470,6 +474,7 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList resourceId = intent.getStringExtra(QUESTIONNAIRE_ARG_PATIENT_KEY), groupResourceId = intent.getStringExtra(QUESTIONNAIRE_ARG_GROUP_KEY), questionnaireType = questionnaireType, + backReference = intent.getStringExtra(QUESTIONNAIRE_BACK_REFERENCE_KEY), ) } @@ -512,7 +517,6 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList const val QUESTIONNAIRE_RESPONSE = "questionnaire-response" const val QUESTIONNAIRE_BACK_REFERENCE_KEY = "questionnaire-back-reference" const val QUESTIONNAIRE_ARG_BARCODE_KEY = "patient-barcode" - const val WHO_IDENTIFIER_SYSTEM = "WHO-HCID" const val QUESTIONNAIRE_AGE = "PR-age" const val QUESTIONNAIRE_LAUNCH_CONTEXTS = "org.smartregister.fhircore.engine.ui.questionnaire.launchContext" @@ -546,7 +550,13 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList resourcesList.toCollection(ArrayList()), ) } - launchContexts + val actualContexts = launchContexts.toMutableMap() + if (launchContexts.isEmpty()) { + populationResources.forEach { + actualContexts[it.resourceType.toString().lowercase()] = it + } + } + actualContexts .takeIf { it.isNotEmpty() } ?.let { kv -> val launchContextsBundlePairs = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt index 9b5367e9c5..ec399ad721 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt @@ -20,22 +20,23 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment import com.google.android.fhir.datacapture.QuestionnaireItemViewHolderFactoryMatchersProviderFactory import com.google.android.fhir.datacapture.contrib.views.barcode.BarCodeReaderViewHolderFactory import com.google.android.fhir.datacapture.extensions.asStringValue +import org.smartregister.fhircore.engine.ui.questionnaire.items.CustomQuestItemDataProvider +import org.smartregister.fhircore.engine.ui.questionnaire.items.LocationPickerViewHolderFactory -object QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl : - QuestionnaireItemViewHolderFactoryMatchersProviderFactory { +class QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl( + private val customQuestItemDataProvider: CustomQuestItemDataProvider, +) : QuestionnaireItemViewHolderFactoryMatchersProviderFactory { override fun get( provider: String, ): QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatchersProvider { // Note: Returns irrespective of the 'provider' passed - return QuestionnaireItemViewHolderFactoryMatchersProviderImpl + return QuestionnaireItemViewHolderFactoryMatchersProviderImpl(customQuestItemDataProvider) } - object QuestionnaireItemViewHolderFactoryMatchersProviderImpl : - QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatchersProvider() { - private const val BARCODE_URL = - "https://fhir.labs.smartregister.org/barcode-type-widget-extension" - private const val BARCODE_NAME = "barcode" + class QuestionnaireItemViewHolderFactoryMatchersProviderImpl( + private val customQuestItemDataProvider: CustomQuestItemDataProvider, + ) : QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatchersProvider() { override fun get(): List { return listOf( @@ -46,9 +47,30 @@ object QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl : if (it == null) false else it.value.asStringValue() == BARCODE_NAME } }, + QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatcher( + LocationPickerViewHolderFactory( + customQuestItemDataProvider = customQuestItemDataProvider, + ), + ) { questionnaireItem -> + questionnaireItem + .getExtensionByUrl(LocationPickerViewHolderFactory.WIDGET_EXTENSION) + .let { + if (it == null) { + false + } else it.value.asStringValue() == LocationPickerViewHolderFactory.WIDGET_TYPE + } + }, ) } + + companion object { + private const val BARCODE_URL = + "https://fhir.labs.smartregister.org/barcode-type-widget-extension" + private const val BARCODE_NAME = "barcode" + } } - const val DEFAULT_PROVIDER = "default" + companion object { + const val DEFAULT_PROVIDER = "default" + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt index 78cbb7e23b..e3663eddbd 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt @@ -39,10 +39,15 @@ import java.util.Calendar import java.util.Date import java.util.UUID import javax.inject.Inject -import kotlinx.coroutines.Dispatchers +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hl7.fhir.r4.context.IWorkerContext +import org.hl7.fhir.r4.context.SimpleWorkerContext import org.hl7.fhir.r4.model.Appointment import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.CarePlan @@ -51,7 +56,6 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Group -import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient @@ -81,7 +85,6 @@ import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.filterByResourceTypeId import org.smartregister.fhircore.engine.util.extension.find -import org.smartregister.fhircore.engine.util.extension.findSubject import org.smartregister.fhircore.engine.util.extension.isExtractionCandidate import org.smartregister.fhircore.engine.util.extension.isIn import org.smartregister.fhircore.engine.util.extension.prepareQuestionsForReadingOrEditing @@ -93,6 +96,7 @@ import org.smartregister.fhircore.engine.util.helper.TransformSupportServices import timber.log.Timber @HiltViewModel +@OptIn(FlowPreview::class) open class QuestionnaireViewModel @Inject constructor( @@ -100,6 +104,7 @@ constructor( val defaultRepository: DefaultRepository, val configurationRegistry: ConfigurationRegistry, val transformSupportServices: TransformSupportServices, + val simpleWorkerContext: SimpleWorkerContext, val dispatcherProvider: DispatcherProvider, val sharedPreferencesHelper: SharedPreferencesHelper, var tracer: PerformanceReporter, @@ -107,7 +112,6 @@ constructor( @Inject lateinit var fhirCarePlanGenerator: FhirCarePlanGenerator val extractionProgress = MutableLiveData() - val questionnaireResponseLiveData = MutableLiveData(null) val extractionProgressMessage = MutableLiveData() @@ -125,6 +129,17 @@ constructor( ?.extractLogicalIdUuid() } + private val extractAndSaveRequestStateFlow: MutableStateFlow Unit> = + MutableStateFlow {} + + init { + viewModelScope.launch(dispatcherProvider.io()) { + extractAndSaveRequestStateFlow.debounce(800.milliseconds).collect { + it.invoke() // invoke request + } + } + } + suspend fun loadQuestionnaire(id: String, type: QuestionnaireType): Questionnaire? = defaultRepository.loadResource(id)?.apply { if (type.isReadOnly() || type.isEditMode()) { @@ -214,15 +229,16 @@ constructor( arrayListOf( Encounter.EncounterParticipantComponent().apply { individual = practitionerRef }, ) - } else if (resource is Patient) { - if (resource.hasGeneralPractitioner()) { - if (!resource.generalPractitioner.contains(practitionerRef)) { - resource.addGeneralPractitioner(practitionerRef) - } - } else { - resource.generalPractitioner = arrayListOf(practitionerRef) - } } + // else if (resource is Patient) { + // if (resource.hasGeneralPractitioner()) { + // if (!resource.generalPractitioner.contains(practitionerRef)) { + // resource.addGeneralPractitioner(practitionerRef) + // } + // } else { + // resource.generalPractitioner = arrayListOf(practitionerRef) + // } + // } } } @@ -252,144 +268,150 @@ constructor( questionnaireResponse: QuestionnaireResponse, questionnaireType: QuestionnaireType = QuestionnaireType.DEFAULT, questionnaire: Questionnaire, + backReference: String?, ) { - viewModelScope.launch(dispatcherProvider.io()) { - tracer.startTrace(QUESTIONNAIRE_TRACE) - // important to set response subject so that structure map can handle subject for all entities - handleQuestionnaireResponseSubject(resourceId, questionnaire, questionnaireResponse) - val extras = mutableListOf() - if (questionnaire.isExtractionCandidate()) { - val bundle = performExtraction(context, questionnaire, questionnaireResponse) - questionnaireResponse.contained = mutableListOf() - bundle.entry.forEach { bundleEntry -> - // add organization to entities representing individuals in registration questionnaire - if (bundleEntry.resource.resourceType.isIn(ResourceType.Patient, ResourceType.Group)) { - if (questionnaireConfig.setOrganizationDetails) { - appendOrganizationInfo(bundleEntry.resource) - } - // if it is new registration set response subject - if (resourceId == null) { - questionnaireResponse.subject = bundleEntry.resource.asReference() - } - } - if (questionnaireConfig.setPractitionerDetails) { - appendPractitionerInfo(bundleEntry.resource) - } + val request = suspend { + try { + val extras = + doExtractAndSaveResources( + context, + resourceId, + groupResourceId, + questionnaireResponse, + questionnaireType, + questionnaire, + backReference, + ) + extractionProgress.postValue(ExtractionProgress.Success(questionnaireResponse, extras)) + } catch (e: Exception) { + Timber.e(e) + extractionProgress.postValue(ExtractionProgress.Failed(questionnaireResponse, e)) + } + } + + extractAndSaveRequestStateFlow.value = request + } - if ( - questionnaireType != QuestionnaireType.EDIT && - bundleEntry.resource.resourceType.isIn( - ResourceType.Patient, - ResourceType.RelatedPerson, - ) - ) { - groupResourceId?.let { - appendPatientsAndRelatedPersonsToGroups( - resource = bundleEntry.resource, - groupResourceId = it, - ) - } + private suspend fun doExtractAndSaveResources( + context: Context, + resourceId: String?, + groupResourceId: String? = null, + questionnaireResponse: QuestionnaireResponse, + questionnaireType: QuestionnaireType = QuestionnaireType.DEFAULT, + questionnaire: Questionnaire, + backReference: String?, + ): List { + tracer.startTrace(QUESTIONNAIRE_TRACE) + // important to set response subject so that structure map can handle subject for all entities + handleQuestionnaireResponseSubject(resourceId, questionnaire, questionnaireResponse) + Timber.e(jsonParser.encodeResourceToString(questionnaireResponse)) + val extras = mutableListOf() + if (questionnaire.isExtractionCandidate()) { + val bundle = performExtraction(context, questionnaire, questionnaireResponse) + questionnaireResponse.contained = mutableListOf() + bundle.entry.forEach { bundleEntry -> + // add organization to entities representing individuals in registration questionnaire + if (bundleEntry.resource.resourceType.isIn(ResourceType.Patient, ResourceType.Group)) { + if (questionnaireConfig.setOrganizationDetails) { + appendOrganizationInfo(bundleEntry.resource) } + // if it is new registration set response subject + if (resourceId == null) { + questionnaireResponse.subject = bundleEntry.resource.asReference() + } + } + if (questionnaireConfig.setPractitionerDetails) { + appendPractitionerInfo(bundleEntry.resource) + } - // response MUST have subject by far otherwise flow has issues - if (!questionnaire.experimental) questionnaireResponse.assertSubject() - - // TODO https://github.com/opensrp/fhircore/issues/900 - // for edit mode replace client and resource subject ids. - // Ideally ResourceMapper should allow this internally via structure-map - if (questionnaireType.isEditMode()) { - if (bundleEntry.resource.resourceType.isIn(ResourceType.Patient, ResourceType.Group)) { - bundleEntry.resource.id = questionnaireResponse.subject.extractId() - } else { - bundleEntry.resource.setPropertySafely("subject", questionnaireResponse.subject) - bundleEntry.resource.setPropertySafely("patient", questionnaireResponse.subject) - } + if ( + questionnaireType != QuestionnaireType.EDIT && + bundleEntry.resource.resourceType.isIn( + ResourceType.Patient, + ResourceType.RelatedPerson, + ) + ) { + groupResourceId?.let { + appendPatientsAndRelatedPersonsToGroups( + resource = bundleEntry.resource, + groupResourceId = it, + ) } - questionnaireResponse.contained.add(bundleEntry.resource) + } - if (bundleEntry.resource is Encounter) extras.add(bundleEntry.resource) + // response MUST have subject by far otherwise flow has issues + if (!questionnaire.experimental) questionnaireResponse.assertSubject() - if ( - (bundleEntry.resource is CarePlan || bundleEntry.resource is Patient) && - bundleEntry.resource.meta.tag.isNotEmpty() - ) { - carePlanAndPatientMetaExtraction(bundleEntry.resource) + // TODO https://github.com/opensrp/fhircore/issues/900 + // for edit mode replace client and resource subject ids. + // Ideally ResourceMapper should allow this internally via structure-map + if (questionnaireType.isEditMode()) { + if (bundleEntry.resource.resourceType.isIn(ResourceType.Patient, ResourceType.Group)) { + bundleEntry.resource.id = questionnaireResponse.subject.extractId() + } else { + bundleEntry.resource.setPropertySafely("subject", questionnaireResponse.subject) + bundleEntry.resource.setPropertySafely("patient", questionnaireResponse.subject) } } + questionnaireResponse.contained.add(bundleEntry.resource) - if (questionnaire.experimental) { - Timber.w( - "${questionnaire.name}(${questionnaire.logicalId}) is experimental and not save any data", - ) - } else { - saveBundleResources(bundle) - } + if (bundleEntry.resource is Encounter) extras.add(bundleEntry.resource) - if (questionnaireType.isEditMode() && editQuestionnaireResponse != null) { - questionnaireResponse.retainMetadata(editQuestionnaireResponse!!) + if ( + (bundleEntry.resource is CarePlan || bundleEntry.resource is Patient) && + bundleEntry.resource.meta.tag.isNotEmpty() + ) { + carePlanAndPatientMetaExtraction(bundleEntry.resource) } + } - saveQuestionnaireResponse(questionnaire, questionnaireResponse) - questionnaireResponseLiveData.postValue(questionnaireResponse) - // TODO https://github.com/opensrp/fhircore/issues/900 - // reassess following i.e. deleting/updating older resources because one resource - // might have generated other flow in subsequent followups - if (questionnaireType.isEditMode() && editQuestionnaireResponse != null) { - editQuestionnaireResponse!!.deleteRelatedResources(defaultRepository) - } - extractCarePlan(questionnaireResponse, bundle) + if (questionnaire.experimental) { + Timber.w( + "${questionnaire.name}(${questionnaire.logicalId}) is experimental and not save any data", + ) } else { - saveQuestionnaireResponse(questionnaire, questionnaireResponse) + saveBundleResources(bundle) } - tracer.stopTrace(QUESTIONNAIRE_TRACE) - viewModelScope.launch(Dispatchers.Main) { - extractionProgress.postValue(ExtractionProgress.Success(extras)) + + if (questionnaireType.isEditMode() && editQuestionnaireResponse != null) { + questionnaireResponse.retainMetadata(editQuestionnaireResponse!!) } + + saveQuestionnaireResponse(questionnaire, questionnaireResponse) + // TODO https://github.com/opensrp/fhircore/issues/900 + // reassess following i.e. deleting/updating older resources because one resource + // might have generated other flow in subsequent followups + if (questionnaireType.isEditMode() && editQuestionnaireResponse != null) { + editQuestionnaireResponse!!.deleteRelatedResources(defaultRepository) + } + } else { + saveQuestionnaireResponse(questionnaire, questionnaireResponse) } + updateCarePlanAndTask(extras, backReference) + tracer.stopTrace(QUESTIONNAIRE_TRACE) + return extras } suspend fun carePlanAndPatientMetaExtraction(source: Resource) { try { /** Get a FHIR [Resource] in the local storage. */ var resource = fhirEngine.get(source.resourceType, source.id) - /** Increment [Resource.meta] versionId of [source]. */ - val versionId = resource.meta.versionId.toInt().plus(1).toString() - /** Append passed [Resource.meta] to the [source]. */ - resource.addTags(source.meta.tag) - /** Assign [Resource.meta] versionId of [source]. */ - resource = resource.copy().apply { meta.versionId = versionId } - /** Delete a FHIR [source] in the local storage. */ - fhirEngine.delete(resource.resourceType, resource.id) - /** Recreate a FHIR [source] in the local storage. */ - fhirEngine.create(resource) + resource.meta.versionId?.toInt()?.plus(1)?.let { + /** Append passed [Resource.meta] to the [source]. */ + resource.addTags(source.meta.tag) + /** Assign [Resource.meta] versionId of [source]. */ + resource = resource.copy().apply { meta.versionId = "$it" } + /** Delete a FHIR [source] in the local storage. */ + fhirEngine.delete(resource.resourceType, resource.id) + /** Recreate a FHIR [source] in the local storage. */ + fhirEngine.create(resource) + } } catch (e: Exception) { Timber.e(e) } } - suspend fun extractCarePlan(questionnaireResponse: QuestionnaireResponse, bundle: Bundle) { - val subject = - questionnaireResponse.findSubject(bundle) - ?: defaultRepository.loadResource(questionnaireResponse.subject) - - questionnaireConfig.planDefinitions.forEach { planId -> - val data = - Bundle().apply { - bundle.entry.map { this.addEntry(it) } - - addEntry().resource = questionnaireResponse - } - - kotlin - .runCatching { fhirCarePlanGenerator.generateCarePlan(planId, subject, data) } - .onFailure { - Timber.e(it) - extractionProgressMessage.postValue("Error extracting care plan. ${it.message}") - } - } - } - /** * Sets questionnaireResponse subject with proper subject-type defined in questionnaire with an * existing resourceId or organization or null @@ -408,6 +430,19 @@ constructor( } } + private suspend fun updateCarePlanAndTask(extras: List, backReference: String?) { + if (backReference != null) { + extras.forEach { + if (it is Encounter) { + fhirCarePlanGenerator.completeTask( + backReference.asReference(ResourceType.Task).extractId(), + it.status, + ) + } + } + } + } + suspend fun saveQuestionnaireResponse( questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse, @@ -444,9 +479,9 @@ constructor( questionnaire = questionnaire, questionnaireResponse = questionnaireResponse, StructureMapExtractionContext( - context = context, transformSupportServices = transformSupportServices, structureMapProvider = retrieveStructureMapProvider(), + workerContext = simpleWorkerContext, ), ) } @@ -475,10 +510,15 @@ constructor( return defaultRepository.loadRelatedPersons(patientId) } - suspend fun loadScheduledAppointments(patientId: String): Iterable { + private suspend fun loadScheduledAppointments(patientId: String): Iterable { return fhirEngine .search { - filter(Appointment.STATUS, { value = of(Appointment.AppointmentStatus.BOOKED.toCode()) }) + filter( + Appointment.STATUS, + { value = of(Appointment.AppointmentStatus.BOOKED.toCode()) }, + { value = of(Appointment.AppointmentStatus.WAITLIST.toCode()) }, + operation = Operation.OR, + ) } .map { it.resource } // filter on patient subject @@ -490,7 +530,8 @@ constructor( } } .filter { - it.status == Appointment.AppointmentStatus.BOOKED && + (it.status == Appointment.AppointmentStatus.BOOKED || + it.status == Appointment.AppointmentStatus.WAITLIST) && it.hasStart() && it.start.after( Date.from( @@ -596,50 +637,23 @@ constructor( return bundle } - fun getPopulationResourcesFromIntent( + private fun getPopulationResourcesFromIntent( intent: Intent, - questionnaireLogicalId: String, ): List { - val resourcesList = mutableListOf() - - intent.getStringArrayListExtra(QuestionnaireActivity.QUESTIONNAIRE_POPULATION_RESOURCES)?.run { - val bundle = Bundle() - forEach { - val resource = jsonParser.parseResource(it) as Resource - if (resource !is Bundle) { - resourcesList.add(jsonParser.parseResource(it) as Resource) - } else { - bundle.entry.addAll(extractRelevantObservation(resource, questionnaireLogicalId).entry) - } - } - resourcesList.add(bundle) - } - - return resourcesList + return intent + .getStringArrayListExtra(QuestionnaireActivity.QUESTIONNAIRE_POPULATION_RESOURCES) + ?.map { jsonParser.parseResource(it) as Resource } ?: listOf() } open suspend fun getPopulationResources( intent: Intent, questionnaireLogicalId: String, ): Array { - val resourcesList = - getPopulationResourcesFromIntent(intent, questionnaireLogicalId).toMutableList() + val resourcesList = getPopulationResourcesFromIntent(intent).toMutableList() intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_ARG_PATIENT_KEY)?.let { patientId -> - loadPatient(patientId)?.apply { - if (identifier.isEmpty()) { - identifier = - mutableListOf( - Identifier().apply { - value = logicalId - use = Identifier.IdentifierUse.OFFICIAL - system = QuestionnaireActivity.WHO_IDENTIFIER_SYSTEM - }, - ) - } - - resourcesList.add(this) - } ?: defaultRepository.loadResource(patientId)?.apply { resourcesList.add(this) } + loadPatient(patientId)?.apply { resourcesList.add(this) } + ?: defaultRepository.loadResource(patientId)?.apply { resourcesList.add(this) } val bundleIndex = resourcesList.indexOfFirst { x -> x is Bundle } if (bundleIndex != -1) { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/CustomQuestItemDataProvider.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/CustomQuestItemDataProvider.kt new file mode 100644 index 0000000000..c49e8c92c6 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/CustomQuestItemDataProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.questionnaire.items + +import com.google.gson.reflect.TypeToken +import javax.inject.Inject +import org.smartregister.fhircore.engine.domain.model.LocationHierarchy +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import timber.log.Timber + +class CustomQuestItemDataProvider +@Inject +constructor(val sharedPreferencesHelper: SharedPreferencesHelper) { + + fun fetchLocationHierarchies(): List { + return try { + val type = object : TypeToken>() {}.type + sharedPreferencesHelper.readJsonArray>( + SharedPreferenceKey.PRACTITIONER_LOCATION_HIERARCHIES.name, + type, + ) + } catch (e: Exception) { + Timber.e(e) + listOf() + } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/LocationPickerViewHolderFactory.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/LocationPickerViewHolderFactory.kt new file mode 100644 index 0000000000..5f8d76d96e --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/LocationPickerViewHolderFactory.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.questionnaire.items + +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage +import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderDelegate +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.ui.questionnaire.items.location.LocationPickerView + +class LocationPickerViewHolderFactory( + val customQuestItemDataProvider: CustomQuestItemDataProvider, +) : QuestionnaireItemViewHolderFactory(R.layout.custom_quest_location_picker_item) { + + private lateinit var context: AppCompatActivity + + override fun getQuestionnaireItemViewHolderDelegate(): QuestionnaireItemViewHolderDelegate = + object : QuestionnaireItemViewHolderDelegate { + + private lateinit var locationPickerView: LocationPickerView + override lateinit var questionnaireViewItem: QuestionnaireViewItem + + override fun bind(questionnaireViewItem: QuestionnaireViewItem) { + locationPickerView.headerView?.bind(questionnaireViewItem) + locationPickerView.setRequiredOrOptionalText( + getRequiredOrOptionalText(questionnaireViewItem, context), + ) + locationPickerView.setOnLocationChanged { value -> + context.lifecycleScope.launch { + if (value != null) { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(value), + ) + } else { + questionnaireViewItem.clearAnswer() + } + } + } + val initialAnswer = questionnaireViewItem.answers.singleOrNull()?.valueStringType + locationPickerView.initLocation(initialAnswer?.value) + if (questionnaireViewItem.draftAnswer == null) { + locationPickerView.showError( + getValidationErrorMessage( + context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ), + ) + } + } + + override fun init(itemView: View) { + context = itemView.context.tryUnwrapContext()!! + locationPickerView = LocationPickerView(itemView.context, itemView, context.lifecycleScope) + locationPickerView.setCustomDataProvider(customQuestItemDataProvider) + } + + override fun setReadOnly(isReadOnly: Boolean) { + locationPickerView.setEnabled(!isReadOnly) + } + } + + companion object { + const val WIDGET_EXTENSION = "https://d-tree.org/fhir/extensions/location-widget" + const val WIDGET_TYPE = "location-widget" + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationHierarchyAdapter.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationHierarchyAdapter.kt new file mode 100644 index 0000000000..43d4afd9e1 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationHierarchyAdapter.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.questionnaire.items.location + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import org.smartregister.fhircore.engine.domain.model.LocationHierarchy + +class LocationHierarchyAdapter( + context: Context, + locations: List, +) : ArrayAdapter(context, 0, locations) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = + convertView + ?: LayoutInflater.from(context) + .inflate(com.google.android.fhir.datacapture.R.layout.drop_down_list_item, parent, false) + val textView = + view.findViewById(com.google.android.fhir.datacapture.R.id.answer_option_textview) + + val location = getItem(position) + textView.text = location?.name ?: "" + + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = + convertView + ?: LayoutInflater.from(context) + .inflate(android.R.layout.simple_spinner_dropdown_item, parent, false) + val textView = view.findViewById(android.R.id.text1) + + val location = getItem(position) + textView.text = location?.name ?: "" + + return view + } + + fun updateLocations(locations: List) { + clear() + addAll(locations) + notifyDataSetChanged() + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationPickerView.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationPickerView.kt new file mode 100644 index 0000000000..39b0e5125a --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationPickerView.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.questionnaire.items.location + +import android.content.Context +import android.text.Editable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AutoCompleteTextView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.cardview.widget.CardView +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.LifecycleCoroutineScope +import com.google.android.fhir.datacapture.views.HeaderView +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.StringType +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.domain.model.LocationHierarchy +import org.smartregister.fhircore.engine.ui.questionnaire.items.CustomQuestItemDataProvider +import timber.log.Timber + +class LocationPickerView( + private val context: Context, + itemView: View, + private val lifecycleScope: LifecycleCoroutineScope, +) { + private var customQuestItemDataProvider: CustomQuestItemDataProvider? = null + private var rootLayout: LinearLayout? = null + private val dropdownMap = mutableMapOf() + + private var selectedHierarchy: LocationData? = null + private var physicalLocator: String? = null + + private var onLocationChanged: ((StringType?) -> Unit)? = null + + private var cardView: CardView? = null + private var locationNameText: TextView? = null + private var physicalLocatorInputLayout: TextInputLayout? = null + private var physicalLocatorInputEditText: TextInputEditText? = null + var headerView: HeaderView? = null + + val helperText: TextView + private var errorView: LinearLayout + private var errorText: TextView + + private var initialValue: String? = null + + init { + cardView = itemView.findViewById(R.id.location_picker_view) + locationNameText = cardView?.findViewById(R.id.location) + headerView = itemView.findViewById(R.id.header) + physicalLocatorInputLayout = itemView.findViewById(R.id.physical_locator_input_layout) + physicalLocatorInputEditText = itemView.findViewById(R.id.physical_locator_edit_text) + + helperText = itemView.findViewById(R.id.location_helper_text) + errorView = itemView.findViewById(R.id.item_error_view) + errorText = itemView.findViewById(R.id.error_text) + + cardView?.setOnClickListener { showDropdownDialog() } + physicalLocatorInputEditText?.doAfterTextChanged { editable: Editable? -> + lifecycleScope.launch { + physicalLocator = editable.toString().ifBlank { null } + onUpdate() + } + } + } + + fun setOnLocationChanged(listener: ((StringType?) -> Unit)?) { + onLocationChanged = listener + } + + fun setEnabled(enabled: Boolean) { + cardView?.isEnabled = enabled + } + + private fun showDropdownDialog() { + val dialogView = + LayoutInflater.from(context).inflate(R.layout.custom_location_picker_view, null) + + val builder = AlertDialog.Builder(context) + builder.setOnCancelListener { resetState() } + builder.setPositiveButton("Select") { _, _ -> + onLocationSelected() + resetState() + } + builder.setNegativeButton("Cancel") { _, _ -> resetState() } + + builder.setView(dialogView) + + rootLayout = dialogView.findViewById(R.id.location_picker_view) + val dialog = builder.create() + initData() + dialog.show() + } + + private fun onLocationSelected() { + onUpdate() + locationNameText?.text = selectedHierarchy?.name ?: "-" + } + + private fun onUpdate() { + Timber.e(physicalLocator) + if (physicalLocator == null || selectedHierarchy == null) { + onLocationChanged?.invoke(StringType(null)) + return + } + val strValue = + "${selectedHierarchy?.identifier ?: "-"}|${selectedHierarchy?.name ?: "-"}|${physicalLocator ?: "-"}" + onLocationChanged?.invoke(StringType(strValue)) + } + + private fun resetState() { + dropdownMap.clear() + } + + private fun initData() { + customQuestItemDataProvider?.let { + val locations = it.fetchLocationHierarchies() + updateLocationData(locations = locations, isDefault = true) + } + } + + private fun updateLocationData( + locations: List, + isDefault: Boolean = false, + parent: LocationHierarchy? = null, + ) { + rootLayout?.let { rootLayout -> + val mainLayout = + LayoutInflater.from(context).inflate(R.layout.custom_material_spinner, rootLayout, false) + val dropdown = mainLayout.findViewById(R.id.menu_auto_complete) + val layoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + layoutParams.bottomMargin = 16 + mainLayout.layoutParams = layoutParams + + if (parent != null) { + val helperText = mainLayout.findViewById(R.id.helper_text) + helperText.visibility = View.VISIBLE + helperText.text = context.getString(R.string.select_locations_in, parent.name) + } + + val adapter = LocationHierarchyAdapter(context, locations) + dropdown.setAdapter(adapter) + + dropdown.setOnItemClickListener { _, _, position, _ -> + val selectedLocation = adapter.getItem(position) + onOptionSelected(selectedLocation, dropdown) + } + + rootLayout.addView(mainLayout) + + if (locations.size == 1) { + val selected = locations.first() + dropdown.setText(selected.name, false) + onOptionSelected(selected, dropdown) + if (isDefault) { + dropdown.isEnabled = false + } + } + } + } + + private fun onOptionSelected( + selectedLocation: LocationHierarchy?, + dropdown: AutoCompleteTextView, + ) { + if (selectedLocation != null && selectedLocation.children.isNotEmpty()) { + if (dropdownMap.containsKey(selectedLocation.identifier)) { + (dropdownMap[selectedLocation.identifier]?.adapter as LocationHierarchyAdapter?) + ?.updateLocations(selectedLocation.children) + } else { + dropdownMap[selectedLocation.identifier] = dropdown + updateLocationData(selectedLocation.children, parent = selectedLocation) + } + } else if (selectedLocation != null) { + this.selectedHierarchy = LocationData.fromHierarchy(selectedLocation) + } + } + + fun setCustomDataProvider(customQuestItemDataProvider: CustomQuestItemDataProvider) { + this.customQuestItemDataProvider = customQuestItemDataProvider + } + + fun initLocation(initialAnswer: String?) { + Timber.e("$initialAnswer $initialValue") + if (initialAnswer != null && initialValue == null) { + val elements = initialAnswer.split("|") + val locationId = elements.getOrNull(0) + val locationName = + elements.getOrNull(1)?.let { + locationNameText?.text = it + it + } + if (locationId != null && locationName != null) { + selectedHierarchy = LocationData(locationId, locationName) + } + elements.getOrNull(2)?.let { + physicalLocator = it + if (it != "-") { + physicalLocatorInputEditText?.setText(it) + } + } + initialValue = initialAnswer + } + } + + fun showError(validationErrorMessage: String?) { + if (validationErrorMessage == null) { + errorView.visibility = View.GONE + return + } + + errorView.visibility = View.VISIBLE + errorText.text = validationErrorMessage + } + + fun setRequiredOrOptionalText(requiredOrOptionalText: String?) { + physicalLocatorInputLayout?.let { it.helperText = requiredOrOptionalText } + if (requiredOrOptionalText == null) { + helperText.visibility = View.GONE + return + } + helperText.text = requiredOrOptionalText + helperText.visibility = View.VISIBLE + } +} + +data class LocationData(val identifier: String, val name: String) { + companion object { + fun fromHierarchy(location: LocationHierarchy): LocationData { + return LocationData(location.identifier, location.name) + } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/DevViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/DevViewModel.kt index 7a2b3bf130..c1e9271d46 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/DevViewModel.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/DevViewModel.kt @@ -21,28 +21,49 @@ import android.content.Intent import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager import com.google.android.fhir.FhirEngine import com.google.android.fhir.logicalId import com.google.android.fhir.search.search -import com.google.android.fhir.testing.jsonParser import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.BufferedInputStream +import java.io.BufferedOutputStream import java.io.File +import java.io.FileInputStream import java.io.FileWriter +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.StructureMap +import org.smartregister.fhircore.engine.appointment.MissedFHIRAppointmentsWorker +import org.smartregister.fhircore.engine.appointment.ProposedWelcomeServiceAppointmentsWorker +import org.smartregister.fhircore.engine.auditEvent.AuditEventWorker import org.smartregister.fhircore.engine.auth.AccountAuthenticator import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry -import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService -import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService +import org.smartregister.fhircore.engine.data.local.purger.ResourcePurgerWorker +import org.smartregister.fhircore.engine.domain.util.DataLoadState import org.smartregister.fhircore.engine.sync.SyncBroadcaster +import org.smartregister.fhircore.engine.task.FhirTaskPlanWorker +import org.smartregister.fhircore.engine.task.WelcomeServiceBackToCarePlanWorker import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.annotation.ExcludeFromJacocoGeneratedReport import org.smartregister.fhircore.engine.util.extension.asDdMmmYyyy -import org.smartregister.fhircore.engine.util.extension.practitionerEndpointUrl -import org.smartregister.model.practitioner.PractitionerDetails +import timber.log.Timber @ExcludeFromJacocoGeneratedReport @HiltViewModel @@ -54,34 +75,77 @@ constructor( val secureSharedPreference: SecureSharedPreference, val sharedPreferencesHelper: SharedPreferencesHelper, val configurationRegistry: ConfigurationRegistry, - val keycloakService: KeycloakService, - val fhirResourceService: FhirResourceService, val fhirEngine: FhirEngine, + @ApplicationContext val appContext: Context, ) : ViewModel() { + val resourceSaveState = MutableStateFlow>(DataLoadState.Idle) + suspend fun createResourceReport(context: Context) { - try { - generateReport(context) - val file = File(context.filesDir, "log_data.txt") - val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + viewModelScope.launch(Dispatchers.IO) { + try { + resourceSaveState.value = DataLoadState.Loading + val generalReport = + File(context.filesDir, "general.txt").also { + generateReport(it, generateGeneralResource()) + } + val resourceReport = + File(context.filesDir, "resource.txt").also { generateReport(it, getResourcesToReport()) } + val localChanges = + File(context.filesDir, "changes.txt").also { + generateReport(it, getLocalResourcesReport()) + } + + val zipFile = File(context.filesDir, "report.zip") + + zipReports(zipFile, listOf(generalReport, resourceReport, localChanges)) + val zipFileUri = + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", zipFile) - val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.type = "text/plain" - shareIntent.putExtra(Intent.EXTRA_STREAM, fileUri) - shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.type = "application/x-zip" + shareIntent.putExtra(Intent.EXTRA_STREAM, zipFileUri) + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - val chooser = Intent.createChooser(shareIntent, "Share Log Data") + val chooser = Intent.createChooser(shareIntent, "Share Log Data") + + if (shareIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(chooser) + } + resourceSaveState.value = DataLoadState.Success(true) + } catch (e: Exception) { + Timber.e(e) + resourceSaveState.value = DataLoadState.Error(e) + } + } + } - if (shareIntent.resolveActivity(context.packageManager) != null) { - context.startActivity(chooser) + private suspend fun zipReports(zipFile: File, files: List) { + withContext(Dispatchers.IO) { + ZipOutputStream(BufferedOutputStream(zipFile.outputStream())).use { out -> + for (file in files) { + FileInputStream(file).use { fi -> + BufferedInputStream(fi).use { origin -> + val entry = ZipEntry(file.name) + out.putNextEntry(entry) + origin.copyTo(out, 1024) + } + } + } } - } catch (e: Exception) { - e.printStackTrace() } } - private suspend fun generateReport(context: Context) { - val data = getResourcesToReport() + private suspend fun generateReport(file: File, value: String) { + withContext(Dispatchers.IO) { + val fileWriter = FileWriter(file) + fileWriter.write(value) + fileWriter.close() + } + } + + private suspend fun getResourcesToReport(): String { + val data = getResourcesToVersions() var log = "" data.entries.forEach { group -> @@ -94,47 +158,172 @@ constructor( "" + "\n-----------------------------------------------------\n" } + return log + } - val fileName = "log_data.txt" - val file = File(context.filesDir, fileName) - val fileWriter = FileWriter(file) - fileWriter.write(log) - fileWriter.close() - } - - suspend fun getResourcesToReport(): Map> { - val questionnaire = - fhirEngine - .search {} - .map { it.resource } - .map { ResourceField(it.logicalId, it.meta.versionId, it.meta.lastUpdated.asDdMmmYyyy()) } - val structureMaps = - fhirEngine - .search {} - .map { it.resource } - .map { ResourceField(it.logicalId, it.meta.versionId, it.meta.lastUpdated.asDdMmmYyyy()) } - - return mapOf(Pair("Questionnaire", questionnaire), Pair("StructureMap", structureMaps)) - } - - // TODO: Fix refetch logic - fun fetchDetails() { - try { - viewModelScope.launch @ExcludeFromJacocoGeneratedReport { - val userInfo = keycloakService.fetchUserInfo().body() - if (userInfo != null && !userInfo.keycloakUuid.isNullOrEmpty()) { - val bundle = - fhirResourceService.getResource(url = userInfo.keycloakUuid!!.practitionerEndpointUrl()) - val practitionerDetails = bundle.entry.first().resource as PractitionerDetails - - val data = jsonParser.encodeResourceToString(practitionerDetails) - println(data) - } + private suspend fun getLocalResourcesReport(): String { + val changes = fhirEngine.getUnsyncedLocalChanges() + + val raw = + changes.joinToString { + """ + |_______________________________ + |resourceType: ${it.resourceType}, + |resourceId: ${it.resourceId}, + |versionId: ${it.versionId}, + |timestamp: ${it.timestamp}, + |type: ${it.resourceType}, + |token: "${it.token.ids}", + |payload: "${it.payload}", + |_______________________________ + | + """ + .trimMargin() } - } catch (e: Exception) { - println(e) + + return "\nChanges: ${changes.size}\n$raw" + } + + private suspend fun generateGeneralResource(): String { + val all = sharedPreferencesHelper.prefs.all + + val pref = + all.entries.joinToString { + """ + ----------Prefs------- + ${it.key}: "${it.value}" + ---------------------- + """ + .trimIndent() + } + + val workers = + listOf( + FhirTaskPlanWorker.WORK_ID, + MissedFHIRAppointmentsWorker.NAME, + ProposedWelcomeServiceAppointmentsWorker.NAME, + ResourcePurgerWorker.NAME, + AuditEventWorker.NAME, + WelcomeServiceBackToCarePlanWorker.NAME, + "${SyncBroadcaster::class.java.name}-oneTimeSync", + "${SyncBroadcaster::class.java.name}-periodicSync", + SyncBroadcaster::class.java.name, + ) + .mapNotNull { tag -> + WorkManager.getInstance(appContext) + .getWorkInfosByTag(tag) + .get() + ?.let { list -> + list.joinToString { info -> + """-----$tag----- + |Name: ${info.state.name}, + |Data: {${info.outputData.keyValueMap.entries.joinToString { "${it.key}: ${it.value}" }}} + |Tag: ${info.tags} + |Attempts: ${info.runAttemptCount} + | + """ + .trimMargin() + } + } + ?.ifBlank { null } + } + + return """ + |$pref + |-------- workers -------- + |$workers + |------------------------- + | + """ + .trimMargin() + } + + suspend fun getResourcesToVersions(): Map> { + return mapOf( + Pair("Questionnaire", getAll()), + Pair("StructureMap", getAll()), + Pair("Binary", getAll()), + ) + } + + private suspend inline fun getAll(): List { + return fhirEngine + .search {} + .map { it.resource } + .map { ResourceField(it.logicalId, it.meta.versionId, it.meta.lastUpdated.asDdMmmYyyy()) } + } + + fun missedTask(context: Context) { + viewModelScope.launch { + WorkManager.getInstance(context) + .enqueueUniqueWork( + FhirTaskPlanWorker.WORK_ID, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().build(), + ) + } + } + + fun missedAppointment(context: Context) { + viewModelScope.launch { + WorkManager.getInstance(context) + .enqueueUniqueWork( + MissedFHIRAppointmentsWorker.NAME, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().build(), + ) + } + } + + fun interruptedResource(context: Context) { + viewModelScope.launch { + WorkManager.getInstance(context) + .enqueueUniqueWork( + ProposedWelcomeServiceAppointmentsWorker.NAME, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().build(), + ) } } + + fun resourcePurger(context: Context) { + viewModelScope.launch { + WorkManager.getInstance(context) + .enqueueUniqueWork( + ResourcePurgerWorker.NAME, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder().putBoolean(ResourcePurgerWorker.ONE_TIME_SYNC_KEY, true).build(), + ) + .build(), + ) + } + } + + fun observeMissedAppointment(context: Context): Flow> { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow(MissedFHIRAppointmentsWorker.NAME) + .map { list -> list.map { it.state } } + } + + fun observeMissedTask(context: Context): Flow> { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow(FhirTaskPlanWorker.WORK_ID) + .map { list -> list.map { it.state } } + } + + fun observeInterrupted(context: Context): Flow> { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow(ProposedWelcomeServiceAppointmentsWorker.NAME) + .map { list -> list.map { it.state } } + } + + fun observeResourcePurgerWorker(context: Context): Flow> { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkFlow(ResourcePurgerWorker.NAME) + .map { list -> list.map { it.state } } + } } @ExcludeFromJacocoGeneratedReport diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreen.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreen.kt index 3240ce4044..39b2068891 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreen.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreen.kt @@ -16,15 +16,8 @@ package org.smartregister.fhircore.engine.ui.settings -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement 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.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider @@ -34,27 +27,22 @@ import androidx.compose.material.IconButton import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Scaffold -import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.CleaningServices import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Logout +import androidx.compose.material.icons.rounded.Report import androidx.compose.material.icons.rounded.Sync import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import java.text.SimpleDateFormat @@ -62,8 +50,9 @@ import java.util.Locale import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.ui.settings.views.DevMenu +import org.smartregister.fhircore.engine.ui.settings.views.ReportBottomSheet +import org.smartregister.fhircore.engine.ui.settings.views.UserProfileRow import org.smartregister.fhircore.engine.ui.settings.views.ViewResourceReport -import org.smartregister.fhircore.engine.ui.theme.BlueTextColor import org.smartregister.fhircore.engine.ui.theme.DividerColor import org.smartregister.fhircore.engine.util.SharedPreferenceKey.LAST_PURGE_KEY @@ -79,6 +68,7 @@ fun SettingsScreen( ) { val context = LocalContext.current val devMenuSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val reportsSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val viewResSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() @@ -87,9 +77,9 @@ fun SettingsScreen( sheetContent = { ViewResourceReport(devViewModel) }, ) { ModalBottomSheetLayout( - sheetState = devMenuSheetState, + sheetState = reportsSheetState, sheetContent = { - DevMenu(viewModel = devViewModel) { + ReportBottomSheet(devViewModel) { scope.launch { devMenuSheetState.hide() viewResSheetState.show() @@ -97,101 +87,79 @@ fun SettingsScreen( } }, ) { - Scaffold( - topBar = { - TopAppBar( - title = {}, - navigationIcon = { - IconButton(onClick = { navController?.popBackStack() }) { - Icon(Icons.Default.ArrowBack, "") - } - }, - ) - }, - ) { paddingValues -> - Column( - modifier = - modifier - .padding(paddingValues) - .padding(vertical = 20.dp) - .verticalScroll(rememberScrollState()), - ) { - InfoCard(profileData = settingsViewModel.profileData) - Divider(color = DividerColor) - UserProfileRow( - icon = Icons.Rounded.Download, - text = stringResource(R.string.re_fetch_practitioner), - clickListener = settingsViewModel::fetchPractitionerDetails, - modifier = modifier, - ) - UserProfileRow( - icon = Icons.Rounded.Sync, - text = stringResource(id = R.string.sync), - clickListener = settingsViewModel::runSync, - modifier = modifier, - ) - UserProfileRow( - icon = Icons.Rounded.BugReport, - text = stringResource(R.string.dev_menu), - clickListener = { scope.launch { devMenuSheetState.show() } }, - modifier = modifier, - ) - - UserProfileRow( - icon = Icons.Rounded.Logout, - text = stringResource(id = R.string.logout), - clickListener = { settingsViewModel.logoutUser(context) }, - modifier = modifier, - ) - - val timestamp = settingsViewModel.sharedPreferences.read(LAST_PURGE_KEY.name, 0L) - val simpleDateFormat = SimpleDateFormat(SYNC_TIMESTAMP_OUTPUT_FORMAT, Locale.getDefault()) - val text = context.resources.getString(R.string.last_purge) - val dateFormat = simpleDateFormat.format(timestamp) - - if (timestamp > 0L) { + ModalBottomSheetLayout( + sheetState = devMenuSheetState, + sheetContent = { DevMenu(viewModel = devViewModel) }, + ) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = { navController?.popBackStack() }) { + Icon(Icons.Default.ArrowBack, "") + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = + modifier + .padding(paddingValues) + .padding(vertical = 20.dp) + .verticalScroll(rememberScrollState()), + ) { + InfoCard(profileData = settingsViewModel.profileData) + Divider(color = DividerColor) + UserProfileRow( + icon = Icons.Rounded.Download, + text = stringResource(R.string.re_fetch_practitioner), + clickListener = settingsViewModel::fetchPractitionerDetails, + modifier = modifier, + ) UserProfileRow( - icon = Icons.Rounded.CleaningServices, - text = "$text: $dateFormat", - clickable = false, + icon = Icons.Rounded.Sync, + text = stringResource(id = R.string.sync), + clickListener = settingsViewModel::runSync, modifier = modifier, ) + UserProfileRow( + icon = Icons.Rounded.Report, + text = stringResource(R.string.reports), + clickListener = { scope.launch { reportsSheetState.show() } }, + modifier = modifier, + ) + UserProfileRow( + icon = Icons.Rounded.BugReport, + text = stringResource(R.string.dev_menu), + clickListener = { scope.launch { devMenuSheetState.show() } }, + modifier = modifier, + ) + UserProfileRow( + icon = Icons.Rounded.Logout, + text = stringResource(id = R.string.logout), + clickListener = { settingsViewModel.logoutUser(context) }, + modifier = modifier, + ) + + val timestamp = settingsViewModel.sharedPreferences.read(LAST_PURGE_KEY.name, 0L) + val simpleDateFormat = + SimpleDateFormat(SYNC_TIMESTAMP_OUTPUT_FORMAT, Locale.getDefault()) + val text = context.resources.getString(R.string.last_purge) + val dateFormat = simpleDateFormat.format(timestamp) + + if (timestamp > 0L) { + UserProfileRow( + icon = Icons.Rounded.CleaningServices, + text = "$text: $dateFormat", + clickable = false, + modifier = modifier, + ) + } } } } } } } - -@Composable -fun UserProfileRow( - icon: ImageVector, - text: String, - modifier: Modifier = Modifier, - clickListener: (() -> Unit) = {}, - clickable: Boolean = true, -) { - Row( - modifier = - modifier - .fillMaxWidth() - .clickable(clickable) { clickListener() } - .padding(vertical = 16.dp, horizontal = 20.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row { - Icon(imageVector = icon, "", tint = BlueTextColor) - Spacer(modifier = modifier.width(20.dp)) - Text(text = text, fontSize = 18.sp) - } - if (clickable) { - Icon( - imageVector = Icons.Rounded.ChevronRight, - "", - tint = Color.LightGray, - modifier = modifier.wrapContentWidth(Alignment.End), - ) - } - } - Divider(color = DividerColor) -} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsViewModel.kt index 2f6a1566a1..e7132e749b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsViewModel.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/SettingsViewModel.kt @@ -24,6 +24,7 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.logicalId import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.CareTeam import org.hl7.fhir.r4.model.Location @@ -33,13 +34,11 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.auth.AccountAuthenticator import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.data.local.DefaultRepository -import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService import org.smartregister.fhircore.engine.domain.model.Language import org.smartregister.fhircore.engine.domain.util.DataLoadState import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.ui.login.LoginActivity -import org.smartregister.fhircore.engine.util.LOGGED_IN_PRACTITIONER import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -59,23 +58,22 @@ constructor( val configurationRegistry: ConfigurationRegistry, val fhirEngine: FhirEngine, val defaultRepository: DefaultRepository, - val keycloakService: KeycloakService, val fhirResourceService: FhirResourceService, ) : ViewModel() { - val onLogout = MutableLiveData(null) + private val onLogout = MutableLiveData(null) val language = MutableLiveData(null) val profileData = MutableLiveData>() init { - viewModelScope.launch @ExcludeFromJacocoGeneratedReport { fetchData() } + viewModelScope.launch(Dispatchers.IO) @ExcludeFromJacocoGeneratedReport { fetchData() } } private suspend fun fetchData() { try { - profileData.value = DataLoadState.Loading + profileData.postValue(DataLoadState.Loading) var practitionerName: String? = null sharedPreferences @@ -120,7 +118,7 @@ constructor( val isValid = organizationIds != null || locationIds != null || careTeamIds != null - profileData.value = + profileData.postValue( DataLoadState.Success( ProfileData( userName = practitionerName ?: "", @@ -130,9 +128,10 @@ constructor( isUserValid = isValid, practitionerDetails = null, ), - ) + ), + ) } catch (e: Exception) { - profileData.value = DataLoadState.Error(e) + profileData.postValue(DataLoadState.Error(e)) } } @@ -147,11 +146,5 @@ constructor( } } - fun retrieveUsername(): String? = - sharedPreferences - .read(key = LOGGED_IN_PRACTITIONER, decodeWithGson = true) - ?.nameFirstRep - ?.nameAsSingleString - fun fetchPractitionerDetails() {} } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/DevMenu.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/DevMenu.kt index 0c8f314dc4..27c8c1f4a8 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/DevMenu.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/DevMenu.kt @@ -16,47 +16,72 @@ package org.smartregister.fhircore.engine.ui.settings.views -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.Button -import androidx.compose.material.Text +import androidx.compose.foundation.layout.size +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircleOutline +import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import androidx.work.WorkInfo import org.smartregister.fhircore.engine.ui.settings.DevViewModel import org.smartregister.fhircore.engine.util.annotation.ExcludeFromJacocoGeneratedReport @ExcludeFromJacocoGeneratedReport @Composable -fun DevMenu(viewModel: DevViewModel, viewRes: () -> Unit) { +fun DevMenu(viewModel: DevViewModel) { val context = LocalContext.current - val scope = rememberCoroutineScope() + val missedTasks by viewModel.observeMissedTask(context).collectAsState(listOf()) + val appointmentList by viewModel.observeMissedAppointment(context).collectAsState(listOf()) + val interruptedList by viewModel.observeInterrupted(context).collectAsState(listOf()) + val resourcePurger by viewModel.observeResourcePurgerWorker(context).collectAsState(listOf()) + Column( - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(16.dp).fillMaxWidth(), + modifier = Modifier.padding(16.dp).padding(vertical = 20.dp).fillMaxWidth(), ) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = - @ExcludeFromJacocoGeneratedReport { - scope.launch @ExcludeFromJacocoGeneratedReport { viewModel.createResourceReport(context) } - }, - ) { - Text(text = "Export Report Resources") - } - Button(modifier = Modifier.fillMaxWidth(), onClick = viewRes) { - Text(text = "View Report Resources") - } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = @ExcludeFromJacocoGeneratedReport { viewModel.fetchDetails() }, - ) { - Text(text = "Test Fetch") - } + SectionTitle(text = "Developer Options") + UserProfileRow( + iconAlt = { WorkerStateIcon(states = missedTasks) }, + text = "Run missed task worker", + clickListener = @ExcludeFromJacocoGeneratedReport { viewModel.missedTask(context) }, + ) + UserProfileRow( + iconAlt = { WorkerStateIcon(states = appointmentList) }, + text = "Run missed appointments worker", + clickListener = @ExcludeFromJacocoGeneratedReport { viewModel.missedAppointment(context) }, + ) + UserProfileRow( + iconAlt = { WorkerStateIcon(states = interruptedList) }, + text = "Run interrupted treatment worker", + clickListener = @ExcludeFromJacocoGeneratedReport { viewModel.interruptedResource(context) }, + ) + UserProfileRow( + iconAlt = { WorkerStateIcon(states = resourcePurger) }, + text = "Run Resource Purger Worker", + clickListener = @ExcludeFromJacocoGeneratedReport { viewModel.resourcePurger(context) }, + ) + } +} + +@Composable +fun WorkerStateIcon(states: List) { + val state = states.firstOrNull() + + when (state) { + WorkInfo.State.RUNNING -> CircularProgressIndicator(modifier = Modifier.size(18.dp)) + WorkInfo.State.SUCCEEDED -> + Icon(Icons.Outlined.CheckCircleOutline, contentDescription = "", tint = Color.Green) + WorkInfo.State.FAILED -> + Icon(Icons.Outlined.ErrorOutline, contentDescription = "", tint = Color.Red) + else -> {} } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/ReportMenu.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/ReportMenu.kt new file mode 100644 index 0000000000..7107f255dd --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/ReportMenu.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.settings.views + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircleOutline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.domain.util.DataLoadState +import org.smartregister.fhircore.engine.ui.settings.DevViewModel +import org.smartregister.fhircore.engine.util.annotation.ExcludeFromJacocoGeneratedReport + +@Composable +fun ReportBottomSheet(viewModel: DevViewModel, viewReport: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val state by viewModel.resourceSaveState.collectAsState() + + Column( + modifier = Modifier.padding(16.dp).padding(vertical = 20.dp).fillMaxWidth(), + ) { + SectionTitle(text = "Application Reports") + if (state !is DataLoadState.Idle) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + when (state) { + is DataLoadState.Success -> { + Icon(Icons.Outlined.CheckCircleOutline, "") + Text(text = "Report Generated") + } + is DataLoadState.Error -> { + Icon(Icons.Outlined.CheckCircleOutline, "") + Text(text = "An error occurred") + } + else -> { + CircularProgressIndicator() + Text(text = "Generated Report") + } + } + } + } + UserProfileRow( + text = "Export Application Report", + clickListener = + @ExcludeFromJacocoGeneratedReport { + scope.launch @ExcludeFromJacocoGeneratedReport { viewModel.createResourceReport(context) } + }, + ) + UserProfileRow( + text = "View Resources Version Report", + clickListener = viewReport, + ) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/Row.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/Row.kt new file mode 100644 index 0000000000..3fad4e0637 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/Row.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.settings.views + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.smartregister.fhircore.engine.ui.theme.BlueTextColor +import org.smartregister.fhircore.engine.ui.theme.DividerColor + +@Composable +fun UserProfileRow( + text: String, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + iconAlt: (@Composable () -> Unit)? = null, + clickListener: (() -> Unit) = {}, + clickable: Boolean = true, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(clickable) { clickListener() } + .padding(vertical = 16.dp, horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row { + if (icon != null) { + Icon(imageVector = icon, "", tint = BlueTextColor) + Spacer(modifier = modifier.width(20.dp)) + } + if (iconAlt != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + iconAlt() + } + } + Text(text = text, fontSize = 18.sp) + } + if (clickable) { + Icon( + imageVector = Icons.Rounded.ChevronRight, + "", + tint = Color.LightGray, + modifier = modifier.wrapContentWidth(Alignment.End), + ) + } + } + Divider(color = DividerColor) +} + +@Composable +fun SectionTitle(text: String) { + Column(modifier = Modifier.padding(bottom = 6.dp)) { + Text(text = text, style = MaterialTheme.typography.h6) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/ViewResources.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/ViewResources.kt index 2d145f97f5..a02f20a161 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/ViewResources.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/settings/views/ViewResources.kt @@ -48,7 +48,7 @@ fun ViewResourceReport(viewModel: DevViewModel) { var data by remember { mutableStateOf(mapOf>()) } LaunchedEffect(viewModel) @ExcludeFromJacocoGeneratedReport { - data = viewModel.getResourcesToReport() + data = viewModel.getResourcesToVersions() } Scaffold { paddingValues -> diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt index 35c24f4a9b..e80588bd46 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt @@ -18,6 +18,8 @@ package org.smartregister.fhircore.engine.util import android.content.Context import android.content.SharedPreferences +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum import com.google.gson.Gson import com.google.gson.JsonIOException import dagger.hilt.android.qualifiers.ApplicationContext @@ -33,6 +35,7 @@ import timber.log.Timber class SharedPreferencesHelper @Inject constructor(@ApplicationContext val context: Context, val gson: Gson) { + private val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val prefs: SharedPreferences by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -90,6 +93,10 @@ constructor(@ApplicationContext val context: Context, val gson: Gson) { } } + inline fun readJsonArray(key: String, typeToken: java.lang.reflect.Type): T { + return gson.fromJson(this.read(key, null), typeToken) + } + /** Write any object by saving it as JSON */ inline fun write(key: String, value: T?, encodeWithGson: Boolean = true) { with(prefs.edit()) { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SystemConstants.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SystemConstants.kt new file mode 100644 index 0000000000..d439ef5dc6 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SystemConstants.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.util + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding + +object SystemConstants { + const val REASON_CODE_SYSTEM = "https://d-tree.org/fhir/reason-code" + const val BASE_URL = "https://d-tree.org" + const val TASK_FILTER_TAG_SYSTEM = "https://d-tree.org/fhir/task-filter-tag" + const val RESOURCE_CREATED_ON_TAG_SYSTEM = "https://d-tree.org/fhir/created-on-tag" + const val TASK_TASK_ORDER_SYSTEM = "https://d-tree.org/fhir/clinic-visit-task-order" + const val PATIENT_TYPE_FILTER_TAG_VIA_META_CODINGS_SYSTEM = + "https://d-tree.org/fhir/patient-meta-tag" + const val CONTACT_TRACING_SYSTEM = "https://d-tree.org/fhir/contact-tracing" + const val OBSERVATION_CODE_SYSTEM = "https://d-tree.org/fhir/observation-codes" + const val CARE_PLAN_REFERENCE_SYSTEM = "https://d-tree.org/fhir/careplan-reference" + const val QUESTIONNAIRE_REFERENCE_SYSTEM = "https://d-tree.org/fhir/procedure-code" +} + +object ReasonConstants { + // TODO: change code to "welcome-service" + val WelcomeServiceCode = + CodeableConcept(Coding(SystemConstants.REASON_CODE_SYSTEM, "Welcome", "Welcome Service")) + .apply { text = "Welcome Service" } + + val homeTracingCoding = + Coding(SystemConstants.CONTACT_TRACING_SYSTEM, "home-tracing", "Home Tracing") + val phoneTracingCoding = + Coding(SystemConstants.CONTACT_TRACING_SYSTEM, "phone-tracing", "Phone Tracing") + + var missedAppointmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "missed-appointment", "Missed Appointment") + var missedMilestoneAppointmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "missed-milestone", "Missed Milestone Appointment") + var missedRoutineAppointmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "missed-routine", "Missed Routine Appointment") + var interruptedTreatmentTracingCode = + Coding(SystemConstants.REASON_CODE_SYSTEM, "interrupted-treatment", "Interrupted Treatment") + + const val TRACING_OUTCOME_CODE = "tracing-outcome" + const val DATE_OF_AGREED_APPOINTMENT = "date-of-agreed-appointment" +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/CarePlanExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/CarePlanExtension.kt index 1d5220b4e7..839b5f73ba 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/CarePlanExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/CarePlanExtension.kt @@ -62,6 +62,26 @@ fun CarePlan.CarePlanActivityComponent.overdue() = CarePlan.CarePlanActivityStatus.NOTSTARTED, ) && detail.ended() +fun CarePlan.CarePlanActivityComponent.canBeCompleted() = + hasDetail().and(detail.status != CarePlan.CarePlanActivityStatus.COMPLETED) + +fun CarePlan.CarePlanActivityComponent.getQuestionnaire() = + detail.code.coding.firstOrNull()?.code?.split("/")?.lastOrNull() + +fun CarePlan.CarePlanActivityComponent.getQuestionnaireName(): String? = detail.description + +fun CarePlan.CarePlanActivityComponent.isGuardianVisit(systemTag: String) = + this.detail.reasonCode + .filter { cd -> cd.coding.firstOrNull { it.system == systemTag } != null } + .any { it.coding.firstOrNull()?.code.equals(GUARDIAN_VISIT_CODE, true) } + +fun CarePlan.CarePlanActivityComponent.shouldShowOnProfile(): Boolean { + return (this.detail.status == CarePlan.CarePlanActivityStatus.SCHEDULED || + this.detail.status == CarePlan.CarePlanActivityStatus.ONHOLD || + this.detail.status == CarePlan.CarePlanActivityStatus.CANCELLED) + .not() +} + fun CarePlan.CarePlanStatus.toCoding() = Coding(this.system, this.toCode(), this.display) fun CarePlan.isLastTask(task: Task) = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt index 9b0192143f..cdbd7e35ba 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt @@ -85,6 +85,13 @@ fun Date.plusYears(years: Int): Date { return clone.time } +fun Date.plusDays(days: Int): Date { + val date = this + val clone = Calendar.getInstance().apply { time = date } + clone.add(Calendar.DATE, days) + return clone.time +} + fun DateType.format(): String = SDF_YYYY_MM_DD.format(value) fun DateTimeType.format(): String = @@ -92,7 +99,7 @@ fun DateTimeType.format(): String = StringBuilder(it).insert(it.length - 2, ":").toString() } -fun DateTimeType.plusDaysAsString(days: Int): String { +fun DateTimeType.plusDays(days: Int): String { val clone = this.copy() clone.add(Calendar.DATE, days) return clone.value.asDdMmmYyyy() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt index a2a4216700..0fcff19cbc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt @@ -21,7 +21,9 @@ import ca.uhn.fhir.util.UrlUtil import com.google.android.fhir.FhirEngine import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get +import com.google.android.fhir.search.Operation import com.google.android.fhir.search.SearchQuery +import com.google.android.fhir.search.filter.TokenParamFilterCriterion import com.google.android.fhir.search.search import com.google.android.fhir.workflow.FhirOperator import org.hl7.fhir.r4.model.Composition @@ -102,3 +104,15 @@ suspend fun FhirEngine.addDateTimeIndex() { Timber.e(ex) } } + +suspend inline fun FhirEngine.getResourcesByIds( + list: List, +): List { + if (list.isEmpty()) return listOf() + val paramQueries: List<(TokenParamFilterCriterion.() -> Unit)> = + list.map { id -> { value = of(id) } } + return this.search { + filter(Resource.RES_ID, *paramQueries.toTypedArray(), operation = Operation.OR) + } + .map { it.resource } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ListExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ListExtension.kt index 2f9fef3258..f48dc66a4c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ListExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ListExtension.kt @@ -16,5 +16,9 @@ package org.smartregister.fhircore.engine.util.extension -fun List.safeSubList(indices: IntRange): List = - this.subList(indices.first, indices.last.coerceAtMost(this.size)) +fun List.safeSubList(indices: IntRange): List { + if (indices.first >= this.size) { + return listOf() + } + return this.subList(indices.first, indices.last.coerceAtMost(this.size)) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt index c5055a0028..a6125dd093 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt @@ -17,10 +17,15 @@ package org.smartregister.fhircore.engine.util.extension import android.content.Context +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.search.Search +import com.google.android.fhir.search.search import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.hl7.fhir.r4.model.Address +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.Enumerations @@ -28,8 +33,11 @@ import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.Immunization import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.codesystems.AdministrativeGender import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.data.domain.PregnancyStatus +import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.HealthStatus import timber.log.Timber @@ -261,6 +269,27 @@ fun List.pregnancyCondition(): Condition { return pregnancyCondition } +suspend fun DefaultRepository.getPregnancyStatus(patientId: String): PregnancyStatus { + val conditions = + patientConditions(patientId) { + filter( + Condition.CODE, + { + value = + of(CodeableConcept().addCoding(Coding("http://snomed.info/sct", "77386006", null))) + }, + { + value = + of(CodeableConcept().addCoding(Coding("http://snomed.info/sct", "413712001", null))) + }, + ) + } + if (conditions.isEmpty()) return PregnancyStatus.None + val isPregnant = conditions.findLast { it.code.codingFirstRep.code == "77386006" } != null + if (isPregnant) return PregnancyStatus.Pregnant + return PregnancyStatus.BreastFeeding +} + fun Enumerations.AdministrativeGender.translateGender(context: Context) = when (this) { Enumerations.AdministrativeGender.MALE -> context.getString(R.string.male) @@ -270,14 +299,16 @@ fun Enumerations.AdministrativeGender.translateGender(context: Context) = fun Patient.extractSecondaryIdentifier(): String? { if (this.hasIdentifier()) { - return this.identifier.firstOrNull { it.use == Identifier.IdentifierUse.SECONDARY }?.value + return this.identifier.lastOrNull { it.use == Identifier.IdentifierUse.SECONDARY }?.value } return null } fun Patient.extractOfficialIdentifier(): String? = if (this.hasIdentifier()) { - this.identifier.firstOrNull { it.use == Identifier.IdentifierUse.OFFICIAL }?.value + this.identifier + .lastOrNull { it.use == Identifier.IdentifierUse.OFFICIAL && it.system != "WHO-HCID" } + ?.value } else { null } @@ -305,3 +336,48 @@ fun Patient.extractHealthStatusFromMeta(filterTag: String): HealthStatus { if (filterTag.isEmpty() || tagList.isEmpty()) return HealthStatus.DEFAULT return tagList.map { it.toHealthStatus() }.minByOrNull { it.priority() }!! } + +suspend fun Patient.activeCarePlans(fhirEngine: FhirEngine): List { + return fhirEngine + .search { + filter(CarePlan.SUBJECT, { value = this@activeCarePlans.referenceValue() }) + filter(CarePlan.STATUS, { value = of(CarePlan.CarePlanStatus.ACTIVE.toCode()) }) + } + .asSequence() + .map { it.resource } + .filter { it.status == CarePlan.CarePlanStatus.ACTIVE } + .sortedBy { it.meta.lastUpdated } + .toList() +} + +suspend fun DefaultRepository.patientConditions( + patientId: String, + filters: (Search.() -> Unit)? = null, +): List { + fhirEngine.search { + filter(Condition.SUBJECT, { value = "${ResourceType.Patient.name}/$patientId" }) + filters?.invoke(this) + filter( + Condition.CLINICAL_STATUS, + { + value = + of( + CodeableConcept() + .addCoding( + Coding( + "https://terminology.hl7.org/CodeSystem/condition-clinical", + "confirmed", + null, + ), + ), + ) + }, + ) + } + return searchResourceFor( + subjectId = patientId, + subjectParam = Condition.SUBJECT, + subjectType = ResourceType.Patient, + filters = listOf(), + ) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index ce2fe9751a..55c668dbb3 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt @@ -47,6 +47,7 @@ import org.hl7.fhir.r4.model.Timing import org.json.JSONException import org.json.JSONObject import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.util.SystemConstants import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import timber.log.Timber @@ -216,6 +217,12 @@ fun Resource.generateMissingVersionId() { meta.versionId = meta.versionId ?: 1.toString() } +fun Resource.generateCreatedOn() { + meta.addTag( + Coding(SystemConstants.RESOURCE_CREATED_ON_TAG_SYSTEM, Date().asDdMmYyyy(), "Created on"), + ) +} + fun Resource.updateLastUpdated() { meta.lastUpdated = Date() } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt index ab4d8c3f5a..105d9afc09 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt @@ -21,6 +21,7 @@ import org.hl7.fhir.r4.model.Task import org.smartregister.fhircore.engine.util.DateUtils import org.smartregister.fhircore.engine.util.DateUtils.isToday import org.smartregister.fhircore.engine.util.DateUtils.today +import org.smartregister.fhircore.engine.util.SystemConstants const val GUARDIAN_VISIT_CODE = "guardian-visit" const val CLINICAL_VISIT_ORDER_CODE_REGEX_FORMAT = "clinic-visit-task-order-\\d*\\.?\\d*\$" @@ -50,11 +51,6 @@ fun Task.clinicVisitOrder(systemTag: String): Double? = .map { it.toDoubleOrNull() } .lastOrNull() -fun Task.isGuardianVisit(systemTag: String) = - this.meta.tag - .filter { it.system.equals(systemTag, true) } - .any { it.code.replace("_", "-").equals(GUARDIAN_VISIT_CODE, true) } - fun Task.isNotCompleted() = this.status != Task.TaskStatus.COMPLETED fun Task.canBeCompleted() = this.hasReasonReference().and(this.isNotCompleted()) @@ -68,3 +64,10 @@ fun Task.extractedTracingCategoryIsPhone(filterTag: String): Boolean { tagList.last().code.equals("phone-tracing") } } + +fun Task.getCarePlanId(): String? { + return meta.tag + .firstOrNull { it.system == SystemConstants.CARE_PLAN_REFERENCE_SYSTEM } + ?.code + ?.substringAfterLast(delimiter = '/', missingDelimiterValue = "") +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt index a07aef06e9..7efff47f74 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt @@ -16,8 +16,11 @@ package org.smartregister.fhircore.engine.util.helper +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.search.search import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.runBlocking import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.context.SimpleWorkerContext import org.hl7.fhir.r4.model.Appointment @@ -48,7 +51,9 @@ import timber.log.Timber * Immunization.Reaction */ @Singleton -class TransformSupportServices @Inject constructor(val simpleWorkerContext: SimpleWorkerContext) : +class TransformSupportServices +@Inject +constructor(val simpleWorkerContext: SimpleWorkerContext, val fhirEngine: FhirEngine) : ITransformerServices { val outputs: MutableList = mutableListOf() @@ -96,6 +101,6 @@ class TransformSupportServices @Inject constructor(val simpleWorkerContext: Simp @Throws(FHIRException::class) override fun performSearch(appContext: Any, url: String): List { - throw FHIRException("performSearch is not supported yet") + return runBlocking { fhirEngine.search(url).map { it.resource } } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/worker/CoreSimpleWorkerContext.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/worker/CoreSimpleWorkerContext.kt new file mode 100644 index 0000000000..e5eb8edba5 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/worker/CoreSimpleWorkerContext.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.util.worker + +import ca.uhn.fhir.context.FhirContext +import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext +import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.Resource + +class CoreSimpleWorkerContext : SimpleWorkerContext() { + private val hapi = + HapiWorkerContext( + FhirContext.forR4Cached(), + FhirContext.forR4Cached().validationSupport, + ) + + init { + setExpansionProfile(Parameters()) + isCanRunWithoutTerminology = true + } + + override fun fetchResourceWithException(theClass: Class?, uri: String?): T? { + if (uri == null) { + return null + } + if ( + uri.startsWith("https://terminology.hl7.org") || + uri.startsWith("http://snomed.info/sct") || + uri.startsWith( + "https://d-tree.org", + ) + ) { + return null + } + return hapi.fetchResourceWithException(theClass, uri) + ?: super.fetchResourceWithException(theClass, uri) + } +} diff --git a/android/engine/src/main/res/layout/custom_location_picker_view.xml b/android/engine/src/main/res/layout/custom_location_picker_view.xml new file mode 100644 index 0000000000..98bd4212ee --- /dev/null +++ b/android/engine/src/main/res/layout/custom_location_picker_view.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/engine/src/main/res/layout/custom_material_spinner.xml b/android/engine/src/main/res/layout/custom_material_spinner.xml new file mode 100644 index 0000000000..5d15282b2b --- /dev/null +++ b/android/engine/src/main/res/layout/custom_material_spinner.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/android/engine/src/main/res/layout/custom_quest_location_picker_item.xml b/android/engine/src/main/res/layout/custom_quest_location_picker_item.xml new file mode 100644 index 0000000000..500cafa35e --- /dev/null +++ b/android/engine/src/main/res/layout/custom_quest_location_picker_item.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 93f804e240..a306db9fef 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -157,4 +157,7 @@ Re-fetch Practitioner Dev Menu http://dicom.nema.org/resources/ontology/DCM + Retry + An error occurred while extracting and saving the Questionnaire + Select a location in %1$s diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt index 2506f18aa6..9f23924d0e 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt @@ -452,6 +452,7 @@ class QuestionnaireActivityTest : ActivityRobolectricTest() { any(), any(), any(), + intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) } } @@ -477,6 +478,7 @@ class QuestionnaireActivityTest : ActivityRobolectricTest() { any(), any(), any(), + intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) } } @@ -511,6 +513,7 @@ class QuestionnaireActivityTest : ActivityRobolectricTest() { any(), any(), any(), + intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt index 6b5e3b8332..08abd68479 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -465,6 +465,8 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceId = "12345", questionnaireResponse = questionnaireResponse, questionnaire = questionnaire, + backReference = + intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) coVerify { defaultRepo.addOrUpdate(resource = patient) } @@ -501,6 +503,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceId = null, questionnaireResponse = questionnaireResponse, questionnaire = questionnaire, + backReference = intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) coVerify { defaultRepo.addOrUpdate(resource = any()) } @@ -526,6 +529,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceId = "12345", questionnaireResponse = QuestionnaireResponse(), questionnaire = questionnaire, + backReference = intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) coVerify(timeout = 2000) { @@ -565,6 +569,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { questionnaireResponse = QuestionnaireResponse(), questionnaireType = QuestionnaireType.EDIT, questionnaire = questionnaire, + backReference = intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) coVerifyOrder { @@ -801,6 +806,8 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceId = null, questionnaireResponse = questionnaireResponse, questionnaire = questionnaire, + backReference = + intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) } @@ -891,6 +898,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { questionnaireResponse = questionnaireResponse, questionnaireType = QuestionnaireType.EDIT, questionnaire = questionnaire, + backReference = intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) verify { questionnaireResponse.retainMetadata(oldQuestionnaireResponse) } @@ -1037,6 +1045,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceId = "0993ldsfkaljlsnldm", questionnaireResponse = questionnaireResponse, questionnaire = questionnaire, + backReference = intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) coVerify(exactly = 1, timeout = 2000) { questionnaireViewModel.saveBundleResources(any()) } @@ -1083,6 +1092,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceId = "0993ldsfkaljlsnldm", questionnaireResponse = questionnaireResponse, questionnaire = questionnaire, + backReference = intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY), ) coVerify(exactly = 1, timeout = 2000) { questionnaireViewModel.saveBundleResources(any()) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreenKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreenKtTest.kt index b019dd3c33..2b5319f38f 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreenKtTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/settings/SettingsScreenKtTest.kt @@ -46,7 +46,7 @@ class SettingsScreenKtTest : RobolectricTest() { @Test @Ignore("Fix failing test") fun testSettingsScreenShowsUserProfileRows() = runTest { - coEvery { devMenuViewModel.getResourcesToReport() } returns emptyMap() + coEvery { devMenuViewModel.getResourcesToVersions() } returns emptyMap() coEvery { settingsViewModel.profileData } returns MutableLiveData() composeRule.setContent { SettingsScreen(settingsViewModel = settingsViewModel, devViewModel = devMenuViewModel) diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 73ee19ca5a..2bf684a958 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -12,6 +12,7 @@ plugins { id("com.google.firebase.firebase-perf") id("com.google.gms.google-services") id("com.google.firebase.crashlytics") + id("com.google.firebase.appdistribution") } kotlin { jvmToolchain(17) } @@ -67,6 +68,10 @@ android { // firebaseCrashlytics { // isNativeSymbolUploadEnabled = false // } + firebaseAppDistribution { + artifactType = "APK" + releaseNotes = "Update" + } } } packaging { @@ -125,49 +130,24 @@ android { flavorDimensions += "apps" productFlavors { - create("quest") { - dimension = "apps" - applicationIdSuffix = ".quest" - versionNameSuffix = "-quest" - isDefault = true - } - create("ecbis") { - dimension = "apps" - applicationIdSuffix = ".ecbis" - versionNameSuffix = "-ecbis" - versionCode = 3 - versionName = "0.0.6" - } - create("g6pd") { - dimension = "apps" - applicationIdSuffix = ".g6pd" - versionNameSuffix = "-g6pd" - } create("mwcore") { dimension = "apps" applicationIdSuffix = ".mwcore" versionNameSuffix = "-mwcore" - versionCode = 37 - versionName = "0.1.26" + versionCode = 1 + versionName = "0.0.1" } create("mwcoreDev") { dimension = "apps" applicationIdSuffix = ".mwcoreDev" versionNameSuffix = "-mwcoreDev" versionCode = 33 - versionName = "0.1.22" - } - create("mwcoreProd") { - dimension = "apps" - applicationIdSuffix = ".mwcoreProd" - versionNameSuffix = "-mwcoreProd" - versionCode = 16 - versionName = "0.1.5" + versionName = "0.1.22-beta11" } - create("afyayangu") { + create("mwcoreStaging") { dimension = "apps" - applicationIdSuffix = ".afyayangu" - versionNameSuffix = "-afyayangu" + applicationIdSuffix = ".mwcoreStaging" + versionNameSuffix = "-mwcoreStaging" versionCode = 1 versionName = "0.0.1" } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt index dc4f053306..92bdbaee40 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt @@ -41,6 +41,7 @@ import org.smartregister.fhircore.engine.data.remote.fhir.resource.ReferenceUrlR import org.smartregister.fhircore.engine.ui.appsetting.AppSettingActivity import org.smartregister.fhircore.engine.ui.login.LoginActivity import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl +import org.smartregister.fhircore.engine.ui.questionnaire.items.CustomQuestItemDataProvider import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.showToast import timber.log.Timber @@ -57,6 +58,8 @@ class QuestApplication : @Inject lateinit var xFhirQueryResolver: FhirXFhirQueryResolver + @Inject lateinit var customQuestItemDataProvider: CustomQuestItemDataProvider + private val launcherActivityName: String? by lazy { val pm = packageManager val launcherIntent = pm.getLaunchIntentForPackage(packageName) @@ -129,7 +132,9 @@ class QuestApplication : urlResolver = referenceUrlResolver, xFhirQueryResolver = xFhirQueryResolver, questionnaireItemViewHolderFactoryMatchersProviderFactory = - QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl, + QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl( + customQuestItemDataProvider, + ), ) return configuration as DataCaptureConfig } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentFilterOptions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentFilterOptions.kt index 437797459d..766f56fda5 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentFilterOptions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentFilterOptions.kt @@ -61,7 +61,9 @@ enum class Reason(val patientCategory: Array) : AppointmentFilt ROUTINE_VISIT(arrayOf(PatientCategory.EXPOSED_INFANT)), VIRAL_LOAD_COLLECTION(arrayOf(PatientCategory.ART_CLIENT)), WELCOME_SERVICE(arrayOf(PatientCategory.ART_CLIENT)), + WELCOME_SERVICE_HVL(arrayOf(PatientCategory.ART_CLIENT)), WELCOME_SERVICE_FOLLOW_UP(arrayOf(PatientCategory.ART_CLIENT)), + TB_HISTORY_REGIMEN(arrayOf(PatientCategory.ART_CLIENT)), ; override fun text(): String = diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentRegisterScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentRegisterScreen.kt index 7138b10bdf..8605324f29 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentRegisterScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appointment/register/AppointmentRegisterScreen.kt @@ -124,8 +124,8 @@ fun FilterAppointmentsModal( ), reason = AppointmentFilter(Reason.ALL_REASONS, Reason.values().asList()), ) - }, - ) + }, + ) AppointmentExposedDropdown( filter = filtersState.patientCategory, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index 8b79082628..b63bfdf8e6 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -28,9 +28,7 @@ import com.google.android.fhir.sync.SyncJobStatus import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlin.math.max -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.app.ConfigService @@ -42,7 +40,6 @@ import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity.Companion.QUESTIONNAIRE_BACK_REFERENCE_KEY -import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity.Companion.QUESTIONNAIRE_RES_ENCOUNTER import org.smartregister.fhircore.engine.ui.theme.AppTheme import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.engine.util.extension.extractId @@ -161,7 +158,7 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { schedulePlan(applicationContext) scheduleCheckForMissedAppointments(applicationContext) scheduleWelcomeServiceAppointments(applicationContext) - scheduleWelcomeServiceToCarePlanForMissedAppointments(applicationContext) +// scheduleWelcomeServiceToCarePlanForMissedAppointments(applicationContext) scheduleResourcePurger(applicationContext) } } @@ -205,16 +202,6 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { appMainViewModel.onTaskComplete(System.currentTimeMillis().toString()) } it.startsWith(ResourceType.Task.name) -> { - lifecycleScope.launch(Dispatchers.IO) { - val encounterStatus = - data.getStringExtra(QUESTIONNAIRE_RES_ENCOUNTER)?.let { code -> - Encounter.EncounterStatus.fromCode(code) - } - fhirCarePlanGenerator.completeTask( - it.asReference(ResourceType.Task).extractId(), - encounterStatus, - ) - } appMainViewModel.onTaskComplete( data.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_ARG_FORM), ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileEvent.kt index 32e4fcd236..332e589cd7 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileEvent.kt @@ -31,6 +31,8 @@ sealed class PatientProfileEvent { data class OpenTaskForm(val context: Context, val taskFormId: String, val taskId: String) : PatientProfileEvent() + data class FinishVisit(val context: Context, val formId: String) : PatientProfileEvent() + data class OverflowMenuClick( val navController: NavHostController, val context: Context, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileScreen.kt index fa0b9efe3e..dc3ee42d30 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileScreen.kt @@ -59,7 +59,6 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import java.util.Locale -import org.hl7.fhir.r4.model.CarePlan import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.ui.components.FormButton import org.smartregister.fhircore.engine.ui.theme.PatientProfileSectionsBackgroundColor @@ -183,11 +182,7 @@ fun PatientProfileScreen( // Patient tasks: List of tasks for the patients if (profileViewData.tasks.isNotEmpty()) { - val appointmentDate = - profileViewData.carePlans - .singleOrNull { it.status == CarePlan.CarePlanStatus.ACTIVE } - ?.period - ?.end + val appointmentDate = profileViewData.currentCarePlan?.period?.end ProfileCard( title = { Row( @@ -285,16 +280,15 @@ fun PatientProfileScreen( } // Finish visit - if (profileViewData.carePlans.isNotEmpty() && profileViewData.tasks.isNotEmpty()) { + if (profileViewData.currentCarePlan != null && profileViewData.tasks.isNotEmpty()) { Button( modifier = Modifier.fillMaxWidth().padding(0.dp), shape = RectangleShape, onClick = { patientProfileViewModel.onEvent( - PatientProfileEvent.OpenTaskForm( + PatientProfileEvent.FinishVisit( context = context, - taskFormId = PatientProfileViewModel.PATIENT_FINISH_VISIT, - taskId = PatientProfileViewModel.PATIENT_FINISH_VISIT, + formId = PatientProfileViewModel.PATIENT_FINISH_VISIT, ), ) }, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileViewModel.kt index f25477b0b1..0d5a22da94 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/profile/PatientProfileViewModel.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.ResourceType -import org.hl7.fhir.r4.model.Task import org.smartregister.fhircore.engine.appfeature.AppFeature import org.smartregister.fhircore.engine.appfeature.model.HealthModule import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry @@ -99,9 +98,9 @@ constructor( val patientProfileViewData: StateFlow get() = _patientProfileViewDataFlow.asStateFlow() - var patientProfileData: ProfileData? = null + private var patientProfileData: ProfileData? = null - val applicationConfiguration: ApplicationConfiguration + private val applicationConfiguration: ApplicationConfiguration get() = configurationRegistry.getAppConfigs() private val isClientVisit: MutableState = mutableStateOf(true) @@ -163,7 +162,7 @@ constructor( syncBroadcaster.runSync() } - fun filterGuardianVisitTasks() { + private fun filterGuardianVisitTasks() { if (patientProfileData != null) { val hivPatientProfileData = patientProfileData as ProfileData.HivProfileData val newProfileData = @@ -330,6 +329,13 @@ constructor( backReference = event.taskId.asReference(ResourceType.Task).reference, populationResources = profile.populationResources, ) + is PatientProfileEvent.FinishVisit -> + QuestionnaireActivity.launchQuestionnaireForResult( + event.context as Activity, + questionnaireId = event.formId, + clientIdentifier = patientId, + populationResources = profile.populationResources, + ) is PatientProfileEvent.OpenChildProfile -> { val urlParams = NavigationArg.bindArgumentsOf( @@ -394,7 +400,7 @@ constructor( val paginatedChildrenRegisterData: MutableStateFlow>> = MutableStateFlow(emptyFlow()) - fun paginateChildrenRegisterData(loadAll: Boolean = true) { + private fun paginateChildrenRegisterData(loadAll: Boolean = true) { paginatedChildrenRegisterData.value = getPager(appFeatureName, healthModule, loadAll).flow.cachedIn(viewModelScope) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/components/RegisterList.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/components/RegisterList.kt index 8135e9a0dc..70604c8ee7 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/components/RegisterList.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/components/RegisterList.kt @@ -61,7 +61,7 @@ fun RegisterList( LazyColumn(modifier = modifier) { items( pagingItems.itemCount, - key = pagingItems.itemKey { it.logicalId }, + key = pagingItems.itemKey { it.customKey ?: it.logicalId }, contentType = pagingItems.itemContentType(), ) { RegisterRowItem(registerViewData = pagingItems[it]!!, onRowClick = onRowClick) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/ProfileViewData.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/ProfileViewData.kt index 6ceec6cfb9..b9ec1d91d4 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/ProfileViewData.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/ProfileViewData.kt @@ -54,7 +54,6 @@ sealed class ProfileViewData( val address: String = "", val identifierKey: String = "", val showIdentifierInProfile: Boolean = false, - val carePlans: List = emptyList(), val conditions: List = emptyList(), val otherPatients: List = emptyList(), val viewChildText: String = "", @@ -63,26 +62,27 @@ sealed class ProfileViewData( val addressDistrict: String = "", val addressTracingCatchment: String = "", val addressPhysicalLocator: String = "", + val currentCarePlan: CarePlan? = null, val phoneContacts: List = emptyList(), val observations: List = emptyList(), val practitioners: List = emptyList(), ) : ProfileViewData(name = name, logicalId = logicalId, identifier = identifier) { val tasksCompleted = - carePlans.isNotEmpty() && + currentCarePlan != null && tasks.isNotEmpty() && - tasks.all { it.subtitleStatus == Task.TaskStatus.COMPLETED.name } + tasks.all { it.subtitleStatus == CarePlan.CarePlanActivityStatus.COMPLETED.name } - val guardiansRelatedPersonResource = guardians.filterIsInstance() + private val guardiansRelatedPersonResource = guardians.filterIsInstance() val populationResources: ArrayList by lazy { val resources = conditions + guardiansRelatedPersonResource + observations val resourcesAsBundle = Bundle().apply { resources.map { this.addEntry().resource = it } } - arrayListOf(*carePlans.toTypedArray(), *practitioners.toTypedArray(), resourcesAsBundle) + val list = arrayListOf(*practitioners.toTypedArray(), resourcesAsBundle) + if (currentCarePlan != null) { + list.add(currentCarePlan) + } + list } - - // todo : apply filter on tracingTask->meta to check patient is valid for Home or Phone Tracing - val validForHomeTrace = false - val validForPhoneTracing = tracingTask.extractedTracingCategoryIsPhone("https://d-tree.org") } data class FamilyProfileViewData( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/RegisterViewData.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/RegisterViewData.kt index 6adc83ea49..309c91a769 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/RegisterViewData.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/RegisterViewData.kt @@ -43,4 +43,5 @@ data class RegisterViewData( val showServiceButton: Boolean = true, val registerType: KClass = RegisterData.DefaultRegisterData::class, val identifier: String = "", + val customKey: String? = null, ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterScreen.kt index a94cb95acb..972baf206d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterScreen.kt @@ -100,8 +100,8 @@ fun FilterTracingRegisterModal( ), age = TracingRegisterUiFilter(AgeFilter.ALL_AGES, AgeFilter.values().asList()), ) - }, - ) + }, + ) TracingRegisterExposedDropdown( filter = filtersState.patientCategory, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/TracingExtension.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/TracingExtension.kt index cefabbbc79..9daba01879 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/TracingExtension.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/TracingExtension.kt @@ -17,17 +17,10 @@ package org.smartregister.fhircore.quest.util.extensions import org.hl7.fhir.r4.model.Task -import org.smartregister.fhircore.engine.data.local.register.dao.HomeTracingRegisterDao -import org.smartregister.fhircore.engine.data.local.register.dao.PhoneTracingRegisterDao +import org.smartregister.fhircore.engine.util.ReasonConstants fun Task.isHomeTracingTask(): Boolean { return this.meta.tag.firstOrNull { - it.`is`(HomeTracingRegisterDao.taskCode.system, HomeTracingRegisterDao.taskCode.code) - } !== null -} - -fun Task.isPhoneTracingTask(): Boolean { - return this.meta.tag.firstOrNull { - it.`is`(PhoneTracingRegisterDao.taskCode.system, PhoneTracingRegisterDao.taskCode.code) + it.`is`(ReasonConstants.homeTracingCoding.system, ReasonConstants.homeTracingCoding.code) } !== null } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/AppointmentFilterMappers.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/AppointmentFilterMappers.kt index f7e2b14a9d..8ccac66e28 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/AppointmentFilterMappers.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/AppointmentFilterMappers.kt @@ -16,7 +16,10 @@ package org.smartregister.fhircore.quest.util.mappers +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding import org.smartregister.fhircore.engine.domain.model.HealthStatus +import org.smartregister.fhircore.engine.util.SystemConstants import org.smartregister.fhircore.quest.ui.appointment.register.PatientCategory import org.smartregister.fhircore.quest.ui.appointment.register.Reason @@ -32,18 +35,67 @@ fun transformPatientCategoryToHealthStatus(patientCategory: PatientCategory) = PatientCategory.SEXUAL_CONTACT -> listOf(HealthStatus.SEXUAL_CONTACT) } -fun transformAppointmentUiReasonToCode(uiReason: Reason) = +fun transformAppointmentUiReasonToCode(uiReason: Reason): CodeableConcept? = when (uiReason) { - Reason.CERVICAL_CANCER_SCREENING -> "VIA" - Reason.DBS_POSITIVE -> "DBS Pos" - Reason.HIV_TEST -> "HIV Test" - Reason.INDEX_CASE_TESTING -> "ICT" - Reason.MILESTONE_HIV_TEST -> "Milestone" - Reason.LINKAGE -> "Linkage" - Reason.REFILL -> "Refill" - Reason.ROUTINE_VISIT -> "Routine" - Reason.VIRAL_LOAD_COLLECTION -> "VL" - Reason.WELCOME_SERVICE -> "Welcome" - Reason.WELCOME_SERVICE_FOLLOW_UP -> "Welcome Service Follow Up" + Reason.CERVICAL_CANCER_SCREENING -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "via", "Cervical Cancer Screening"), + ) + Reason.DBS_POSITIVE -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "DBS Pos", "DBS Positive"), + ) + Reason.HIV_TEST -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "hiv-test", "HIV Test"), + ) + Reason.INDEX_CASE_TESTING -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "ICT", "Index Case Testing"), + ) + Reason.MILESTONE_HIV_TEST -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "Milestone", "Milestone HIV Test"), + ) + Reason.LINKAGE -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "linkage", "Linkage"), + ) + Reason.REFILL -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "Refill", "Refill"), + ) + Reason.ROUTINE_VISIT -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "Routine", "Routine Visit"), + ) + Reason.VIRAL_LOAD_COLLECTION -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "vl", "Viral Load Collection"), + ) + Reason.WELCOME_SERVICE -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "ICT", "Index Case Testing"), + ) + Reason.WELCOME_SERVICE_HVL -> + CodeableConcept( + Coding(SystemConstants.REASON_CODE_SYSTEM, "welcome-service-hvl", "Welcome Service HVL"), + ) + Reason.WELCOME_SERVICE_FOLLOW_UP -> + CodeableConcept( + Coding( + SystemConstants.REASON_CODE_SYSTEM, + "welcome-service-follow-up", + "Index Case Testing", + ), + ) + Reason.TB_HISTORY_REGIMEN -> + CodeableConcept( + Coding( + SystemConstants.REASON_CODE_SYSTEM, + "tb_history_and_regimen", + "Welcome Service Follow Up", + ), + ) else -> null } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/ProfileViewDataMapper.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/ProfileViewDataMapper.kt index 5ad5aaa65e..77299bade9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/ProfileViewDataMapper.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/ProfileViewDataMapper.kt @@ -27,6 +27,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import javax.inject.Inject +import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.Task import org.smartregister.fhircore.engine.domain.model.HealthStatus import org.smartregister.fhircore.engine.domain.model.ProfileData @@ -38,6 +39,8 @@ import org.smartregister.fhircore.engine.ui.theme.SuccessColor import org.smartregister.fhircore.engine.util.extension.asDdMmYyyy import org.smartregister.fhircore.engine.util.extension.canBeCompleted import org.smartregister.fhircore.engine.util.extension.extractId +import org.smartregister.fhircore.engine.util.extension.getQuestionnaire +import org.smartregister.fhircore.engine.util.extension.getQuestionnaireName import org.smartregister.fhircore.engine.util.extension.makeItReadable import org.smartregister.fhircore.engine.util.extension.translateGender import org.smartregister.fhircore.quest.R @@ -87,28 +90,28 @@ class ProfileViewDataMapper @Inject constructor(@ApplicationContext val context: viewChildText = context.getString(R.string.view_children_x, inputModel.otherPatients.size.toString()), observations = inputModel.observations, - carePlans = inputModel.services, guardians = inputModel.guardians, + currentCarePlan = inputModel.currentCarePlan, tasks = inputModel.tasks.map { PatientProfileRowItem( - id = it.logicalId, - actionFormId = if (it.canBeCompleted()) it.reasonReference.extractId() else null, + id = it.outcomeReference.first().extractId(), + actionFormId = if (it.canBeCompleted()) it.getQuestionnaire() else null, title = "", // it.description, subtitle = "", // context.getString(R.string.due_on, // it.executionPeriod.start.makeItReadable()), profileViewSection = PatientProfileViewSection.TASKS, actionButtonIcon = - if (it.status == Task.TaskStatus.COMPLETED) { + if (it.detail.status == CarePlan.CarePlanActivityStatus.COMPLETED) { Icons.Filled.Check } else Icons.Filled.Add, actionIconColor = - if (it.status == Task.TaskStatus.COMPLETED) { + if (it.detail.status == CarePlan.CarePlanActivityStatus.COMPLETED) { SuccessColor - } else it.status.retrieveColorCode(), - actionButtonColor = it.status.retrieveColorCode(), - actionButtonText = it.description, - subtitleStatus = it.status.name, + } else it.detail.status.retrieveColorCode(), + actionButtonColor = it.detail.status.retrieveColorCode(), + actionButtonText = it.getQuestionnaireName(), + subtitleStatus = it.detail.status.name, ) }, practitioners = inputModel.practitioners, @@ -217,7 +220,16 @@ class ProfileViewDataMapper @Inject constructor(@ApplicationContext val context: else -> DefaultColor } - fun HealthStatus.retrieveDisplayIdentifierKey(): String = + private fun CarePlan.CarePlanActivityStatus.retrieveColorCode(): Color = + when (this) { + CarePlan.CarePlanActivityStatus.NOTSTARTED -> InfoColor + CarePlan.CarePlanActivityStatus.CANCELLED -> OverdueColor + CarePlan.CarePlanActivityStatus.STOPPED -> OverdueColor + CarePlan.CarePlanActivityStatus.COMPLETED -> DefaultColor + else -> DefaultColor + } + + private fun HealthStatus.retrieveDisplayIdentifierKey(): String = when (this) { HealthStatus.EXPOSED_INFANT -> "HCC Number" HealthStatus.CHILD_CONTACT, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/RegisterViewDataMapper.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/RegisterViewDataMapper.kt index 9e7e93322f..809db5b6f7 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/RegisterViewDataMapper.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/mappers/RegisterViewDataMapper.kt @@ -133,6 +133,7 @@ class RegisterViewDataMapper @Inject constructor(@ApplicationContext val context is RegisterData.AppointmentRegisterData -> RegisterViewData( logicalId = inputModel.logicalId, + customKey = inputModel.appointmentLogicalId, title = inputModel.name, subtitle = "${inputModel.reasons.joinToString()}, ${inputModel.age}", registerType = RegisterData.AppointmentRegisterData::class, diff --git a/android/quest/src/mwcore/res/drawable/ic_app_logo.png b/android/quest/src/mwcore/res/drawable/ic_app_logo.png index 92a1bc16b5..8bee3502ca 100644 Binary files a/android/quest/src/mwcore/res/drawable/ic_app_logo.png and b/android/quest/src/mwcore/res/drawable/ic_app_logo.png differ diff --git a/android/quest/src/mwcore/res/drawable/ic_launcher.png b/android/quest/src/mwcore/res/drawable/ic_launcher.png index ea51553050..8bee3502ca 100644 Binary files a/android/quest/src/mwcore/res/drawable/ic_launcher.png and b/android/quest/src/mwcore/res/drawable/ic_launcher.png differ diff --git a/android/quest/src/mwcore/res/values/strings.xml b/android/quest/src/mwcore/res/values/strings.xml index 036fc868c2..db2648386e 100644 --- a/android/quest/src/mwcore/res/values/strings.xml +++ b/android/quest/src/mwcore/res/values/strings.xml @@ -1,5 +1,5 @@ - Tingathe Staging + Tingathe Production Gender org.smartregister.fhircore.mwcore Unassigned diff --git a/android/quest/src/mwcoreProd/res/drawable/ic_app_logo.png b/android/quest/src/mwcoreProd/res/drawable/ic_app_logo.png deleted file mode 100644 index 8bee3502ca..0000000000 Binary files a/android/quest/src/mwcoreProd/res/drawable/ic_app_logo.png and /dev/null differ diff --git a/android/quest/src/mwcoreProd/res/drawable/ic_launcher.png b/android/quest/src/mwcoreProd/res/drawable/ic_launcher.png deleted file mode 100644 index 8bee3502ca..0000000000 Binary files a/android/quest/src/mwcoreProd/res/drawable/ic_launcher.png and /dev/null differ diff --git a/android/quest/src/mwcoreStaging/res/drawable/ic_app_logo.png b/android/quest/src/mwcoreStaging/res/drawable/ic_app_logo.png new file mode 100644 index 0000000000..92a1bc16b5 Binary files /dev/null and b/android/quest/src/mwcoreStaging/res/drawable/ic_app_logo.png differ diff --git a/android/quest/src/mwcoreStaging/res/drawable/ic_launcher.png b/android/quest/src/mwcoreStaging/res/drawable/ic_launcher.png new file mode 100644 index 0000000000..ea51553050 Binary files /dev/null and b/android/quest/src/mwcoreStaging/res/drawable/ic_launcher.png differ diff --git a/android/quest/src/mwcoreProd/res/values/strings.xml b/android/quest/src/mwcoreStaging/res/values/strings.xml similarity index 81% rename from android/quest/src/mwcoreProd/res/values/strings.xml rename to android/quest/src/mwcoreStaging/res/values/strings.xml index c15ba52c19..036fc868c2 100644 --- a/android/quest/src/mwcoreProd/res/values/strings.xml +++ b/android/quest/src/mwcoreStaging/res/values/strings.xml @@ -1,5 +1,5 @@ - Tingathe + Tingathe Staging Gender org.smartregister.fhircore.mwcore Unassigned