diff --git a/android/dataclerk/.gitignore b/android/dataclerk/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/android/dataclerk/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/dataclerk/build.gradle b/android/dataclerk/build.gradle
new file mode 100644
index 0000000000..0414d1490e
--- /dev/null
+++ b/android/dataclerk/build.gradle
@@ -0,0 +1,185 @@
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+ id 'de.mannodermaus.android-junit5' version '1.9.3.0'
+ id 'org.jetbrains.dokka'
+ id 'org.jetbrains.kotlin.plugin.serialization'
+ id 'jacoco'
+ id 'dagger.hilt.android.plugin'
+ id 'org.jetbrains.kotlin.android'
+ id 'com.google.firebase.firebase-perf'
+ id 'com.google.gms.google-services'
+ id 'com.google.firebase.crashlytics'
+}
+
+apply from: '../properties.gradle'
+
+android {
+ namespace 'org.dtree.fhircore.dataclerk'
+ compileSdkVersion sdk_versions.compile_sdk
+
+ defaultConfig {
+ applicationId "org.dtree.fhircore.dataclerk"
+ minSdkVersion sdk_versions.min_sdk
+ targetSdkVersion sdk_versions.target_sdk
+ versionCode 3
+ versionName "1.0.0"
+ multiDexEnabled true
+
+ buildConfigField("boolean", 'SKIP_AUTH_CHECK', "false")
+ buildConfigField("String", 'FHIR_BASE_URL', "\"${FHIR_BASE_URL}\"")
+ buildConfigField("String", 'OAUTH_BASE_URL', "\"${OAUTH_BASE_URL}\"")
+ buildConfigField("String", 'OAUTH_CIENT_ID', "\"${OAUTH_CIENT_ID}\"")
+ buildConfigField("String", 'OAUTH_CLIENT_SECRET', "\"${OAUTH_CLIENT_SECRET}\"")
+ buildConfigField("String", 'OAUTH_SCOPE', "\"${OAUTH_SCOPE}\"")
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ 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")
+// }
+// }
+
+ buildTypes {
+ debug {
+// testCoverageEnabled 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
+// firebaseCrashlytics {
+// nativeSymbolUploadEnabled false
+// }
+ }
+ }
+
+ compileOptions {
+ coreLibraryDesugaringEnabled true
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11.toString()
+ freeCompilerArgs = ['-Xjvm-default=all-compatibility', '-opt-in=kotlin.RequiresOptIn']
+ }
+
+ buildFeatures {
+ compose true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion '1.4.8'
+ }
+
+ testOptions {
+ execution 'ANDROIDX_TEST_ORCHESTRATOR'
+ animationsDisabled true
+
+ unitTests {
+ includeAndroidResources = true
+ returnDefaultValues = true
+ all {
+ beforeTest { testDescriptor ->
+ println "${testDescriptor.className} > ${testDescriptor.name} STARTED"
+ }
+ }
+ }
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+
+ configurations.all {
+ resolutionStrategy {
+ force "ca.uhn.hapi.fhir:org.hl7.fhir.utilities:5.5.7"
+ }
+ }
+
+ packagingOptions {
+ exclude 'META-INF/ASL-2.0.txt'
+ exclude 'META-INF/LGPL-3.0.txt'
+ exclude 'license.html'
+ exclude 'readme.html'
+ exclude 'META-INF/DEPENDENCIES'
+ exclude 'META-INF/LICENSE'
+ exclude 'META-INF/LICENSE.txt'
+ exclude 'META-INF/license.txt'
+ exclude 'META-INF/license.html'
+ exclude 'META-INF/LICENSE.md'
+ exclude 'META-INF/NOTICE'
+ exclude 'META-INF/NOTICE.txt'
+ exclude 'META-INF/NOTICE.md'
+ exclude 'META-INF/notice.txt'
+ exclude 'META-INF/ASL2.0'
+ exclude 'META-INF/ASL-2.0.txt'
+ exclude 'META-INF/LGPL-3.0.txt'
+ exclude 'META-INF/sun-jaxb.episode'
+ exclude("META-INF/*.kotlin_module")
+ exclude("META-INF/AL2.0")
+ exclude("META-INF/LGPL2.1")
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring deps.desugar
+ implementation(project(":engine"))
+
+ implementation 'androidx.core:core-ktx:1.8.0'
+ implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
+ implementation 'androidx.activity:activity-compose:1.5.1'
+
+ implementation deps.accompanist.swiperefresh
+
+ implementation platform('androidx.compose:compose-bom:2022.10.00')
+ implementation 'androidx.compose.ui:ui'
+ implementation 'androidx.compose.ui:ui-graphics'
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ implementation 'androidx.compose.material3:material3'
+ implementation "androidx.paging:paging-compose:3.2.0-rc01"
+
+ //Hilt - Dependency Injection
+ implementation "com.google.dagger:hilt-android:$hiltVersion"
+ kapt "com.google.dagger:hilt-compiler:$hiltVersion"
+
+ // analytics
+ implementation platform('com.google.firebase:firebase-bom:31.2.0')
+
+ implementation 'com.google.firebase:firebase-perf-ktx'
+ 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:2022.10.00')
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+}
+
+kapt {
+ correctErrorTypes true
+}
+
+hilt {
+ enableAggregatingTask = true
+}
\ No newline at end of file
diff --git a/android/dataclerk/proguard-rules.pro b/android/dataclerk/proguard-rules.pro
new file mode 100644
index 0000000000..481bb43481
--- /dev/null
+++ b/android/dataclerk/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/dataclerk/release/output-metadata.json b/android/dataclerk/release/output-metadata.json
new file mode 100644
index 0000000000..0c298c8b8c
--- /dev/null
+++ b/android/dataclerk/release/output-metadata.json
@@ -0,0 +1,20 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "org.dtree.fhircore.dataclerk",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 2,
+ "versionName": "0.0.2",
+ "outputFile": "dataclerk-release.apk"
+ }
+ ],
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/android/dataclerk/src/androidTest/java/org/dtree/fhircore/dataclerk/ExampleInstrumentedTest.kt b/android/dataclerk/src/androidTest/java/org/dtree/fhircore/dataclerk/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000000..949d82692c
--- /dev/null
+++ b/android/dataclerk/src/androidTest/java/org/dtree/fhircore/dataclerk/ExampleInstrumentedTest.kt
@@ -0,0 +1,38 @@
+/*
+ * 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 androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("org.dtree.fhircore.dataclerk", appContext.packageName)
+ }
+}
diff --git a/android/dataclerk/src/main/AndroidManifest.xml b/android/dataclerk/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..62df52fa4b
--- /dev/null
+++ b/android/dataclerk/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/dataclerk/src/main/ic_launcher-playstore.png b/android/dataclerk/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000000..0d16ea3ccb
Binary files /dev/null and b/android/dataclerk/src/main/ic_launcher-playstore.png differ
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
new file mode 100644
index 0000000000..c14ce58729
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt
@@ -0,0 +1,71 @@
+/*
+ * 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 android.app.Application
+import android.util.Log
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import com.google.android.fhir.datacapture.DataCaptureConfig
+import com.google.firebase.crashlytics.ktx.crashlytics
+import com.google.firebase.ktx.Firebase
+import com.google.firebase.perf.ktx.performance
+import dagger.hilt.android.HiltAndroidApp
+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 timber.log.Timber
+
+@HiltAndroidApp
+class DataClerkApplication : Application(), DataCaptureConfig.Provider, Configuration.Provider {
+ private var configuration: DataCaptureConfig? = null
+
+ @Inject lateinit var workerFactory: HiltWorkerFactory
+
+ @Inject lateinit var referenceUrlResolver: ReferenceUrlResolver
+
+ @Inject lateinit var xFhirQueryResolver: QuestXFhirQueryResolver
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (BuildConfig.DEBUG) {
+ Firebase.performance.isPerformanceCollectionEnabled = false
+ Firebase.crashlytics.setCrashlyticsCollectionEnabled(false)
+ Timber.plant(Timber.DebugTree())
+ }
+ }
+
+ override fun getDataCaptureConfig(): DataCaptureConfig {
+ configuration =
+ configuration
+ ?: DataCaptureConfig(
+ urlResolver = referenceUrlResolver,
+ xFhirQueryResolver = xFhirQueryResolver,
+ questionnaireItemViewHolderFactoryMatchersProviderFactory =
+ QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl
+ )
+ return configuration as DataCaptureConfig
+ }
+
+ override fun getWorkManagerConfiguration(): Configuration =
+ 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
new file mode 100644
index 0000000000..c2bdf0b211
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt
@@ -0,0 +1,88 @@
+/*
+ * 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 android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.ResourceType
+import org.smartregister.fhircore.engine.configuration.app.AuthConfiguration
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.sync.ResourceTag
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
+
+@Singleton
+class DataClerkConfigService @Inject constructor(@ApplicationContext val context: Context) :
+ ConfigService {
+
+ override fun provideAuthConfiguration() =
+ AuthConfiguration(
+ fhirServerBaseUrl = BuildConfig.FHIR_BASE_URL,
+ oauthServerBaseUrl = BuildConfig.OAUTH_BASE_URL,
+ clientId = BuildConfig.OAUTH_CIENT_ID,
+ clientSecret = BuildConfig.OAUTH_CLIENT_SECRET,
+ accountType = BuildConfig.APPLICATION_ID
+ )
+
+ override fun defineResourceTags() =
+ listOf(
+ ResourceTag(
+ type = ResourceType.CareTeam.name,
+ tag =
+ Coding().apply {
+ system = context.getString(R.string.sync_strategy_careteam_system)
+ display = context.getString(R.string.sync_strategy_careteam_display)
+ }
+ ),
+ ResourceTag(
+ type = ResourceType.Location.name,
+ tag =
+ Coding().apply {
+ system = context.getString(R.string.sync_strategy_location_system)
+ display = context.getString(R.string.sync_strategy_location_display)
+ }
+ ),
+ ResourceTag(
+ type = ResourceType.Organization.name,
+ tag =
+ Coding().apply {
+ system = context.getString(R.string.sync_strategy_organization_system)
+ display = context.getString(R.string.sync_strategy_organization_display)
+ }
+ ),
+ ResourceTag(
+ type = SharedPreferenceKey.PRACTITIONER_ID.name,
+ tag =
+ Coding().apply {
+ system = context.getString(R.string.sync_strategy_practitioner_system)
+ display = context.getString(R.string.sync_strategy_practitioner_display)
+ },
+ isResource = false
+ ),
+ ResourceTag(
+ type = SharedPreferenceKey.APP_ID.name,
+ tag =
+ Coding().apply {
+ system = context.getString(R.string.sync_strategy_appid_system)
+ display = context.getString(R.string.application_id)
+ },
+ isResource = false
+ )
+ )
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/data/QuestXFhirQueryResolver.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/data/QuestXFhirQueryResolver.kt
new file mode 100644
index 0000000000..53a6790efc
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/data/QuestXFhirQueryResolver.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.data
+
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.datacapture.XFhirQueryResolver
+import com.google.android.fhir.search.search
+import javax.inject.Inject
+import javax.inject.Singleton
+import org.hl7.fhir.r4.model.Resource
+
+@Singleton
+class QuestXFhirQueryResolver @Inject constructor(val fhirEngine: FhirEngine) : XFhirQueryResolver {
+ override suspend fun resolve(xFhirQuery: String): List {
+ return fhirEngine.search(xFhirQuery)
+ }
+}
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
new file mode 100644
index 0000000000..516565ef36
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.dtree.fhircore.dataclerk.DataClerkConfigService
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
+
+@InstallIn(SingletonComponent::class)
+@Module
+abstract class ConfigServiceModule {
+ @Binds
+ abstract fun provideConfigService(questConfigService: DataClerkConfigService): ConfigService
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/DataClerkLoginService.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/DataClerkLoginService.kt
new file mode 100644
index 0000000000..ba67e6f7ea
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/DataClerkLoginService.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.di
+
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import javax.inject.Inject
+import org.dtree.fhircore.dataclerk.ui.main.AppMainActivity
+import org.smartregister.fhircore.engine.ui.login.LoginService
+
+class DataClerkLoginService @Inject constructor() : LoginService {
+
+ override lateinit var loginActivity: AppCompatActivity
+
+ override fun navigateToHome() {
+ loginActivity.run {
+ startActivity(
+ Intent(loginActivity, AppMainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ }
+ )
+ finish()
+ }
+ }
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/LoginServiceModule.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/LoginServiceModule.kt
new file mode 100644
index 0000000000..2c5d682cd3
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/LoginServiceModule.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityComponent
+import org.smartregister.fhircore.engine.ui.login.LoginService
+
+@InstallIn(ActivityComponent::class)
+@Module
+abstract class LoginServiceModule {
+
+ @Binds abstract fun bindLoginService(loginService: DataClerkLoginService): LoginService
+}
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
new file mode 100644
index 0000000000..9afa8cd98d
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeScreen.kt
@@ -0,0 +1,201 @@
+/*
+ * 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.ui.home
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.ModalBottomSheetLayout
+import androidx.compose.material.ModalBottomSheetValue
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.rememberModalBottomSheetState
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+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.hilt.navigation.compose.hiltViewModel
+import com.google.android.fhir.sync.SyncJobStatus
+import kotlinx.coroutines.launch
+import org.dtree.fhircore.dataclerk.ui.info.InfoScreen
+import org.dtree.fhircore.dataclerk.ui.main.AppMainViewModel
+import org.dtree.fhircore.dataclerk.ui.main.PatientItem
+import org.smartregister.fhircore.engine.R
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
+@Composable
+fun HomeScreen(
+ appMainViewModel: AppMainViewModel,
+ homeViewModel: HomeViewModel = hiltViewModel(),
+ sync: () -> Unit,
+ openPatient: (PatientItem) -> Unit
+) {
+ val appState by appMainViewModel.appMainUiState
+ val context = LocalContext.current
+ val patientRegistrationLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult(),
+ onResult = {}
+ )
+ val syncState by appMainViewModel.syncSharedFlow.collectAsState(initial = null)
+ val refreshKey by appMainViewModel.refreshHash
+ val sheetState =
+ rememberModalBottomSheetState(
+ initialValue = ModalBottomSheetValue.Hidden,
+ confirmStateChange = { it != ModalBottomSheetValue.HalfExpanded }
+ )
+ val coroutineScope = rememberCoroutineScope()
+
+ LaunchedEffect(syncState) {
+ if (syncState is SyncJobStatus.Finished) {
+ homeViewModel.refresh()
+ }
+ }
+
+ LaunchedEffect(refreshKey) { if (refreshKey.isNotBlank()) homeViewModel.refresh() }
+
+ ModalBottomSheetLayout(
+ sheetState = sheetState,
+ sheetContent = { InfoScreen(appMainViewModel) },
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Scaffold(
+ topBar = {
+ Column(Modifier.fillMaxWidth()) {
+ TopAppBar(
+ title = { Text(text = appState.appTitle) },
+ actions = {
+ AppScreenBody(
+ syncState = syncState,
+ sync = sync,
+ )
+ IconButton(onClick = { homeViewModel.refresh() }) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = "Refresh",
+ )
+ }
+ IconButton(
+ onClick = {
+ coroutineScope.launch {
+ if (sheetState.isVisible) sheetState.hide() else sheetState.show()
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.BugReport,
+ contentDescription = "Debug",
+ )
+ }
+ }
+ )
+ SyncStatusBar(
+ syncState = syncState,
+ )
+ }
+ },
+ bottomBar = {
+ if (!appState.isInitialSync)
+ Button(
+ onClick = { patientRegistrationLauncher.launch(appMainViewModel.openForm(context)) },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text(text = appState.registrationButton) }
+ }
+ ) { paddingValues ->
+ Column(Modifier.padding(paddingValues)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val count by homeViewModel.patientCount
+ Text(text = "Patients: $count")
+ Text(text = "Last Sync: ${appState.lastSyncTime}")
+ }
+ PatientList(viewModel = homeViewModel, navigate = openPatient)
+ }
+ }
+ }
+}
+
+@Composable
+fun SyncStatusBar(
+ syncState: SyncJobStatus?,
+) {
+ if (syncState is SyncJobStatus.InProgress) {
+ val progress =
+ syncState
+ .let { it.completed.toDouble().div(it.total) }
+ .let { if (it.isNaN()) 0.0 else it }
+ .times(100)
+ .div(100)
+ .toFloat()
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), progress = progress)
+ } else if (syncState is SyncJobStatus.Started) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+}
+
+@Composable
+fun AppScreenBody(syncState: SyncJobStatus?, sync: () -> Unit) {
+ Row() {
+ Button(
+ onClick = sync,
+ enabled = !(syncState is SyncJobStatus.InProgress || syncState is SyncJobStatus.Started)
+ ) {
+ when (syncState) {
+ is SyncJobStatus.InProgress, is SyncJobStatus.Started -> {
+ Text(
+ text =
+ if (syncState is SyncJobStatus.Started) stringResource(R.string.syncing_initiated)
+ else
+ "${(syncState as SyncJobStatus.InProgress).syncOperation.name.lowercase()}ing...",
+ )
+ }
+ is SyncJobStatus.Finished -> {
+ Text(text = "Sync (finished)")
+ }
+ is SyncJobStatus.Glitch, is SyncJobStatus.Failed -> {
+ Text(text = "Sync (failed)")
+ }
+ else -> {
+ Text(text = "Run Sync")
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000000..cebc5b2d4b
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/HomeViewModel.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.ui.home
+
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.launch
+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)
+ var patientsPaging: MutableStateFlow>> =
+ MutableStateFlow(emptyFlow())
+ init {
+ refresh()
+ }
+ private fun getPatients() =
+ Pager(
+ config =
+ PagingConfig(
+ pageSize = 20,
+ ),
+ pagingSourceFactory = { PatientPagingSource(dataStore) }
+ )
+ .flow
+ .cachedIn(viewModelScope)
+
+ fun refresh() {
+ viewModelScope.launch {
+ patientCount.value = dataStore.patientCount()
+ patientsPaging.emit(getPatients())
+ }
+ }
+}
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
new file mode 100644
index 0000000000..107df56ec8
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/PatientList.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.ui.home
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.paging.LoadState
+import androidx.paging.compose.collectAsLazyPagingItems
+import org.dtree.fhircore.dataclerk.ui.main.PatientItem
+import org.dtree.fhircore.dataclerk.ui.patient.Constants
+import org.dtree.fhircore.dataclerk.util.getFormattedAge
+import org.smartregister.fhircore.engine.ui.components.ErrorMessage
+import org.smartregister.fhircore.engine.util.extension.toHumanDisplay
+import timber.log.Timber
+
+@Composable
+fun PatientList(viewModel: HomeViewModel, navigate: (PatientItem) -> Unit) {
+ val source by viewModel.patientsPaging.collectAsState()
+ val patients = source.collectAsLazyPagingItems()
+
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(8.dp)
+ ) {
+ items(items = removeDuplicates(patients.itemSnapshotList.items)) { patient ->
+ PatientItemCard(patient, onClick = { navigate(patient) })
+ }
+ when (val state = patients.loadState.refresh) { // FIRST LOAD
+ is LoadState.Error -> {
+ item {
+ ErrorMessage(
+ message = state.error.also { Timber.e(it) }.localizedMessage!!,
+ onClickRetry = { patients.retry() }
+ )
+ }
+ }
+ is LoadState.Loading -> { // Loading UI
+ item {
+ Column(
+ modifier = Modifier.fillParentMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(modifier = Modifier.padding(8.dp), text = "Refresh Loading")
+
+ CircularProgressIndicator(color = Color.Black)
+ }
+ }
+ }
+ else -> {}
+ }
+ when (val state = patients.loadState.append) { // Pagination
+ is LoadState.Error -> {
+ item {
+ ErrorMessage(
+ message = state.error.also { Timber.e(it) }.localizedMessage!!,
+ onClickRetry = { patients.retry() }
+ )
+ }
+ }
+ is LoadState.Loading -> { // Pagination Loading UI
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(text = "Pagination Loading")
+
+ CircularProgressIndicator(color = Color.Black)
+ }
+ }
+ }
+ else -> {}
+ }
+ }
+}
+
+@Composable
+fun PatientItemCard(patient: PatientItem, onClick: () -> Unit) {
+ OutlinedCard(modifier = Modifier.fillMaxWidth().clickable { onClick() }) {
+ Column(Modifier.padding(Constants.defaultCardPadding).fillMaxWidth()) {
+ Text(
+ text = patient.name,
+ style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold)
+ )
+ Row(
+ Modifier.fillMaxWidth().padding(top = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier =
+ Modifier.background(
+ color = MaterialTheme.colorScheme.primary,
+ shape = RoundedCornerShape(8.dp)
+ )
+ .padding(8.dp)
+ ) {
+ Text(
+ text = "Id: #${patient.id}",
+ style =
+ MaterialTheme.typography.labelSmall.copy(color = MaterialTheme.colorScheme.onPrimary)
+ )
+ }
+ Text(text = getFormattedAge(patient, LocalContext.current.resources))
+ }
+ Box(modifier = Modifier.height(4.dp))
+ Text(text = "Updated: " + patient.dateCreated?.toHumanDisplay() ?: "")
+ }
+ }
+}
+
+fun removeDuplicates(list: List): List {
+ val distinctList = mutableListOf()
+ for (element in list) {
+ if (!distinctList.contains(element)) {
+ distinctList.add(element)
+ }
+ }
+ return distinctList
+}
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
new file mode 100644
index 0000000000..b5da93ce13
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/home/paging/PatientPagingSource.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.ui.home.paging
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import org.dtree.fhircore.dataclerk.ui.main.AppDataStore
+import org.dtree.fhircore.dataclerk.ui.main.PatientItem
+
+class PatientPagingSource(private val dataStore: AppDataStore) : PagingSource() {
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition?.let { anchorPosition ->
+ val anchorPage = state.closestPageToPosition(anchorPosition)
+ anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
+ }
+ }
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val page = params.key ?: 1
+ val response = dataStore.loadPatients(page = page)
+
+ LoadResult.Page(
+ data = response,
+ prevKey = null,
+ nextKey = if (response.isEmpty()) null else page.plus(1),
+ )
+ } catch (e: Exception) {
+ LoadResult.Error(e)
+ }
+ }
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/info/InfoScreen.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/info/InfoScreen.kt
new file mode 100644
index 0000000000..28730fbc6d
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/info/InfoScreen.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.ui.info
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.dtree.fhircore.dataclerk.ui.main.AppMainViewModel
+
+@Composable
+fun InfoScreen(appMainViewModel: AppMainViewModel) {
+ val data = appMainViewModel.getInfoData()
+
+ data.entries.forEach {
+ Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
+ Text(text = it.key)
+ Box(modifier = Modifier.height(8.dp))
+ Text(text = it.value)
+ }
+ }
+}
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
new file mode 100644
index 0000000000..c8f78eb045
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppDataStore.kt
@@ -0,0 +1,144 @@
+/*
+ * 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.ui.main
+
+import ca.uhn.fhir.context.FhirContext
+import ca.uhn.fhir.context.FhirVersionEnum
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.get
+import com.google.android.fhir.logicalId
+import com.google.android.fhir.search.Order
+import com.google.android.fhir.search.Search
+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.Patient
+import org.hl7.fhir.r4.model.Reference
+import org.hl7.fhir.r4.model.Resource
+import org.hl7.fhir.r4.model.ResourceType
+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.data.local.DefaultRepository
+import org.smartregister.fhircore.engine.domain.model.HealthStatus
+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.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 jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser()
+
+ private fun getApplicationConfiguration(): ApplicationConfiguration {
+ return configurationRegistry.retrieveConfiguration(AppConfigClassification.APPLICATION)
+ }
+
+ suspend fun loadPatients(page: Int = 1): List {
+ Timber.e("Page: $page")
+ // TODO: replace with _tag search when update is out
+ return fhirEngine
+ .search {
+ filter(Patient.ACTIVE, { value = of(true) })
+ sort(Patient.NAME, Order.ASCENDING)
+ count = 20
+ from = (page - 1) * 20
+ }
+ .map { inputModel ->
+ Timber.e(jsonParser.encodeResourceToString(inputModel))
+ inputModel.toPatientItem(getApplicationConfiguration())
+ }
+ }
+
+ suspend fun getPatient(patientId: String): PatientItem {
+ val patient = fhirEngine.get(patientId)
+ return patient.toPatientItem(getApplicationConfiguration())
+ }
+
+ suspend fun getResource(resourceId: String): Resource {
+ return defaultRepository.loadResource(Reference().apply { this.reference = resourceId })
+ }
+
+ suspend fun patientCount(): Long {
+ return fhirEngine.count(
+ Search(ResourceType.Patient).apply { filter(Patient.ACTIVE, { value = of(true) }) }
+ )
+ }
+}
+
+data class PatientItem(
+ val id: String,
+ val resourceId: String,
+ val name: String,
+ val gender: String,
+ val dob: LocalDate? = null,
+ val addressData: AddressData,
+ val phone: String,
+ val isActive: Boolean,
+ val chwAssigned: String,
+ val healthStatus: HealthStatus,
+ val practitioners: List? = null,
+ val dateCreated: Date? = null
+)
+
+data class AddressData(
+ val district: String = "",
+ val state: String = "",
+ val text: String = "",
+ val fullAddress: String = ""
+)
+
+internal fun Patient.toPatientItem(configuration: ApplicationConfiguration): PatientItem {
+ val phone = if (hasTelecom()) telecom[0].value else "N/A"
+ val isActive = active
+ val gender = if (hasGenderElement()) genderElement.valueAsString else ""
+ val dob =
+ if (hasBirthDateElement())
+ LocalDate.parse(birthDateElement.valueAsString, DateTimeFormatter.ISO_DATE)
+ else null
+ return PatientItem(
+ id = this.extractOfficialIdentifier() ?: "N/A",
+ resourceId = this.logicalId,
+ name = this.extractName(),
+ dob = dob,
+ gender = gender ?: "",
+ phone = phone ?: "N/A",
+ isActive = isActive,
+ healthStatus =
+ this.extractHealthStatusFromMeta(configuration.patientTypeFilterTagViaMetaCodingSystem),
+ chwAssigned = this.extractGeneralPractitionerReference(),
+ practitioners = this.generalPractitioner,
+ addressData =
+ AddressData(
+ district = this.extractWithFhirPath("Patient.address.district"),
+ state = this.extractWithFhirPath("Patient.address.state"),
+ text = this.extractWithFhirPath("Patient.address.text"),
+ fullAddress = this.extractAddress()
+ ),
+ dateCreated = this.meta.lastUpdated
+ )
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainActivity.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainActivity.kt
new file mode 100644
index 0000000000..b9a7ad6daa
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainActivity.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.ui.main
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.lifecycle.lifecycleScope
+import com.google.android.fhir.sync.SyncJobStatus
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import kotlin.math.max
+import kotlinx.coroutines.launch
+import org.dtree.fhircore.dataclerk.ui.home.HomeViewModel
+import org.hl7.fhir.r4.model.ResourceType
+import org.smartregister.fhircore.engine.R
+import org.smartregister.fhircore.engine.sync.OnSyncListener
+import org.smartregister.fhircore.engine.sync.SyncBroadcaster
+import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity
+import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity
+import org.smartregister.fhircore.engine.ui.theme.AppTheme
+import org.smartregister.fhircore.engine.util.extension.showToast
+import timber.log.Timber
+
+@AndroidEntryPoint
+class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener {
+ private val appMainViewModel by viewModels()
+ private val homeViewModel by viewModels()
+ @Inject lateinit var syncBroadcaster: SyncBroadcaster
+ var lastSyncState: SyncJobStatus? = null
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ AppTheme() {
+ AppScreen(appMainViewModel, homeViewModel) { appMainViewModel.sync(syncBroadcaster) }
+ }
+ }
+
+ syncBroadcaster.registerSyncListener(this, lifecycleScope)
+ appMainViewModel.run { lifecycleScope.launch { retrieveAppMainUiState(syncBroadcaster) } }
+
+ syncBroadcaster.runSync()
+ }
+
+ override fun onSync(state: SyncJobStatus) {
+ Timber.i("Sync state received is $state, last state is $lastSyncState")
+ when (state) {
+ is SyncJobStatus.Started -> {
+ if (lastSyncState !is SyncJobStatus.Failed) {
+ showToast(getString(org.smartregister.fhircore.engine.R.string.syncing))
+ }
+ appMainViewModel.onEvent(AppMainEvent.UpdateSyncState(state, null))
+ }
+ is SyncJobStatus.InProgress -> {
+ Timber.d(
+ "Syncing in progress: ${state.syncOperation.name} ${state.completed.div(max(state.total, 1).toDouble()).times(100)}%"
+ )
+ appMainViewModel.onEvent(AppMainEvent.UpdateSyncState(state, null))
+ }
+ is SyncJobStatus.Glitch -> {
+ appMainViewModel.onEvent(AppMainEvent.UpdateSyncState(state, lastSyncTime = null))
+ Timber.w(
+ (if (state?.exceptions != null) state.exceptions else emptyList()).joinToString {
+ it.exception.message.toString()
+ }
+ )
+ }
+ is SyncJobStatus.Failed -> {
+ if (!state?.exceptions.isNullOrEmpty() &&
+ state.exceptions.first().resourceType == ResourceType.Flag
+ ) {
+ if (lastSyncState !is SyncJobStatus.Failed) {
+ showToast(state.exceptions.first().exception.message!!)
+ }
+ appMainViewModel.onEvent(AppMainEvent.UpdateSyncState(state, lastSyncTime = null))
+ return
+ }
+ if (lastSyncState !is SyncJobStatus.Failed) {
+ showToast(getString(org.smartregister.fhircore.engine.R.string.sync_failed_text))
+ }
+
+ appMainViewModel.onEvent(
+ AppMainEvent.UpdateSyncState(
+ state,
+ lastSyncTime =
+ if (!appMainViewModel.retrieveLastSyncTimestamp().isNullOrEmpty())
+ getString(
+ org.smartregister.fhircore.engine.R.string.last_sync_timestamp,
+ appMainViewModel.retrieveLastSyncTimestamp()
+ )
+ else null
+ )
+ )
+ }
+ is SyncJobStatus.Finished -> {
+ if (lastSyncState !is SyncJobStatus.Finished) {
+ showToast(getString(org.smartregister.fhircore.engine.R.string.sync_completed))
+ }
+ appMainViewModel.run {
+ onEvent(
+ AppMainEvent.UpdateSyncState(
+ state,
+ getString(
+ org.smartregister.fhircore.engine.R.string.last_sync_timestamp,
+ formatLastSyncTimestamp(state.timestamp)
+ )
+ )
+ )
+ updateLastSyncTimestamp(state.timestamp)
+ }
+ }
+ }
+ lastSyncState = state
+ }
+
+ @Suppress("DEPRECATION")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+
+ if (resultCode == Activity.RESULT_OK)
+ data?.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_BACK_REFERENCE_KEY)?.let {
+ appMainViewModel.onTaskComplete()
+ }
+ }
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainEvent.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainEvent.kt
new file mode 100644
index 0000000000..e3a959dfd8
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainEvent.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.dtree.fhircore.dataclerk.ui.main
+
+import android.content.Context
+import android.content.Intent
+import com.google.android.fhir.sync.SyncJobStatus
+import org.smartregister.fhircore.engine.domain.model.Language
+
+sealed class AppMainEvent(val state: SyncJobStatus) {
+ data class SwitchLanguage(
+ val syncState: SyncJobStatus,
+ val language: Language,
+ val context: Context
+ ) : AppMainEvent(syncState)
+ data class DeviceToDeviceSync(val syncState: SyncJobStatus, val context: Context) :
+ AppMainEvent(syncState)
+ data class Logout(val syncState: SyncJobStatus, val context: Context) : AppMainEvent(syncState)
+ data class SyncData(val syncState: SyncJobStatus, val launchManualAuth: (Intent) -> Unit) :
+ AppMainEvent(syncState)
+ data class ResumeSync(val syncState: SyncJobStatus) : AppMainEvent(syncState)
+ data class UpdateSyncState(val syncState: SyncJobStatus, val lastSyncTime: String?) :
+ AppMainEvent(syncState)
+ data class RefreshAuthToken(
+ val syncState: SyncJobStatus,
+ val launchManualAuth: (Intent) -> Unit
+ ) : AppMainEvent(syncState)
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainUiState.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainUiState.kt
new file mode 100644
index 0000000000..9b19f5e808
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainUiState.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.ui.main
+
+import java.util.Locale
+import org.smartregister.fhircore.engine.domain.model.Language
+
+data class AppMainUiState(
+ val appTitle: String,
+ val username: String,
+ val lastSyncTime: String,
+ val currentLanguage: String,
+ val languages: List,
+ val isInitialSync: Boolean,
+ val registrationButton: String,
+)
+
+fun appMainUiStateOf(
+ appTitle: String = "FHIR App",
+ username: String = "",
+ lastSyncTime: String = "",
+ currentLanguage: String = Locale.ENGLISH.displayName,
+ languages: List = emptyList(),
+ isInitialSync: Boolean = false,
+ registrationButton: String = "Create New Patient",
+): AppMainUiState {
+ return AppMainUiState(
+ appTitle = appTitle,
+ username = username,
+ lastSyncTime = lastSyncTime,
+ currentLanguage = currentLanguage,
+ languages = languages,
+ isInitialSync = isInitialSync,
+ registrationButton = registrationButton
+ )
+}
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
new file mode 100644
index 0000000000..766611ed20
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainViewModel.kt
@@ -0,0 +1,201 @@
+/*
+ * 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.ui.main
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.fhir.sync.SyncJobStatus
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.text.SimpleDateFormat
+import java.time.OffsetDateTime
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableSharedFlow
+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.ResourceType
+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
+import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
+import org.smartregister.fhircore.engine.util.extension.fetchLanguages
+
+@HiltViewModel
+class AppMainViewModel
+@Inject
+constructor(
+ private val configurationRegistry: ConfigurationRegistry,
+ private val sharedPreferencesHelper: SharedPreferencesHelper,
+ private val secureSharedPreference: SecureSharedPreference,
+ private val dataStore: AppDataStore
+) : ViewModel() {
+
+ private val patientRegisterConfiguration: RegisterViewConfiguration by lazy {
+ configurationRegistry.retrieveConfiguration(AppConfigClassification.PATIENT_REGISTER)
+ }
+ private val simpleDateFormat = SimpleDateFormat(SYNC_TIMESTAMP_OUTPUT_FORMAT, Locale.getDefault())
+ val appMainUiState: MutableState = mutableStateOf(appMainUiStateOf())
+ val syncSharedFlow = MutableSharedFlow()
+
+ private val applicationConfiguration: ApplicationConfiguration =
+ configurationRegistry.retrieveConfiguration(AppConfigClassification.APPLICATION)
+ val refreshHash = mutableStateOf("")
+
+ suspend fun retrieveAppMainUiState(syncBroadcaster: SyncBroadcaster) {
+ appMainUiState.value =
+ appMainUiStateOf(
+ appTitle = applicationConfiguration.applicationName,
+ currentLanguage = loadCurrentLanguage(),
+ username = secureSharedPreference.retrieveSessionUsername() ?: "",
+ lastSyncTime = retrieveLastSyncTimestamp() ?: "",
+ languages = configurationRegistry.fetchLanguages(),
+ isInitialSync = syncBroadcaster.isInitialSync(),
+ registrationButton = patientRegisterConfiguration.newClientButtonText
+ )
+ }
+
+ fun retrieveLastSyncTimestamp(): String? =
+ sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null)
+
+ private fun loadCurrentLanguage() =
+ Locale.forLanguageTag(
+ sharedPreferencesHelper.read(SharedPreferenceKey.LANG.name, Locale.UK.toLanguageTag())!!
+ )
+ .displayName
+
+ fun openForm(context: Context): Intent {
+ val isArtClient = patientRegisterConfiguration.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"
+ }
+ return Intent(context, QuestionnaireActivity::class.java)
+ .putExtras(
+ QuestionnaireActivity.intentArgs(
+ formName = patientRegisterConfiguration.registrationForm,
+ questionnaireType = QuestionnaireType.DEFAULT,
+ populationResources =
+ arrayListOf(
+ Flag().apply {
+ code =
+ CodeableConcept().apply {
+ text = if (isArtClient) "client-already-on-art" else "exposed-infant"
+ addCoding(artCode)
+ }
+ }
+ ),
+ )
+ )
+ }
+
+ fun onEvent(event: AppMainEvent) {
+ viewModelScope.launch {
+ syncSharedFlow.emit(event.state)
+ when (event) {
+ is AppMainEvent.UpdateSyncState -> {
+ when (event.state) {
+ is SyncJobStatus.Finished, is SyncJobStatus.Failed -> {
+ val lastSyncTime = event.lastSyncTime ?: (retrieveLastSyncTimestamp() ?: "")
+ appMainUiState.value =
+ appMainUiState.value.copy(
+ lastSyncTime = lastSyncTime,
+ isInitialSync = lastSyncTime.isEmpty()
+ )
+ }
+ else ->
+ appMainUiState.value =
+ appMainUiState.value.copy(
+ lastSyncTime = event.lastSyncTime ?: appMainUiState.value.lastSyncTime
+ )
+ }
+ }
+ else -> {}
+ }
+ }
+ }
+
+ fun sync(syncBroadcaster: SyncBroadcaster) {
+ syncBroadcaster.runSync()
+ }
+
+ fun updateLastSyncTimestamp(timestamp: OffsetDateTime) {
+ sharedPreferencesHelper.write(
+ SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name,
+ formatLastSyncTimestamp(timestamp)
+ )
+ }
+
+ fun formatLastSyncTimestamp(timestamp: OffsetDateTime): String {
+
+ val syncTimestampFormatter =
+ SimpleDateFormat(SYNC_TIMESTAMP_INPUT_FORMAT, Locale.getDefault()).apply {
+ timeZone = TimeZone.getDefault()
+ }
+ val parse: Date? = syncTimestampFormatter.parse(timestamp.toString())
+ return if (parse == null) "" else simpleDateFormat.format(parse)
+ }
+
+ fun getInfoData(): Map {
+ return mapOf(
+ Pair(
+ "Practioner ID",
+ sharedPreferencesHelper.read(
+ key = SharedPreferenceKey.PRACTITIONER_ID.name,
+ defaultValue = null
+ )
+ ?: ""
+ ),
+ Pair(
+ "Location ID",
+ sharedPreferencesHelper.read(key = ResourceType.Location.name, defaultValue = null) ?: ""
+ ),
+ Pair(
+ "CareTeam",
+ sharedPreferencesHelper.read(key = ResourceType.CareTeam.name, defaultValue = null) ?: ""
+ ),
+ Pair(
+ "Organization ID",
+ sharedPreferencesHelper.read(key = ResourceType.Organization.name, defaultValue = null)
+ ?: ""
+ ),
+ )
+ }
+
+ fun onTaskComplete() {
+ refreshHash.value = System.currentTimeMillis().toString()
+ }
+
+ companion object {
+ const val SYNC_TIMESTAMP_INPUT_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
+ const val SYNC_TIMESTAMP_OUTPUT_FORMAT = "hh:mm aa, MMM d"
+ }
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppScreen.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppScreen.kt
new file mode 100644
index 0000000000..782bd30193
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppScreen.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.ui.main
+
+import androidx.compose.runtime.Composable
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import org.dtree.fhircore.dataclerk.ui.home.HomeScreen
+import org.dtree.fhircore.dataclerk.ui.home.HomeViewModel
+import org.dtree.fhircore.dataclerk.ui.patient.PatientScreen
+
+@Composable
+fun AppScreen(
+ appMainViewModel: AppMainViewModel,
+ homeViewModel: HomeViewModel = hiltViewModel(),
+ sync: () -> Unit
+) {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = "home") {
+ composable("home") {
+ HomeScreen(appMainViewModel = appMainViewModel, homeViewModel = homeViewModel, sync = sync) {
+ navController.navigate("patient/${it.resourceId}")
+ }
+ }
+ composable(
+ "patient/{patientId}",
+ arguments = listOf(navArgument("patientId") { type = NavType.StringType })
+ ) { PatientScreen(navController, appMainViewModel = appMainViewModel) }
+ }
+}
diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientDetail.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientDetail.kt
new file mode 100644
index 0000000000..f8a6a499f7
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientDetail.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.dtree.fhircore.dataclerk.ui.patient
+
+import org.dtree.fhircore.dataclerk.ui.main.PatientItem
+import org.hl7.fhir.r4.model.Resource
+
+sealed class PatientDetailScreenState {
+ object Loading : PatientDetailScreenState()
+ data class Success(val patientDetail: PatientItem, val detailsData: List) :
+ PatientDetailScreenState()
+ data class Error(val message: String) : PatientDetailScreenState()
+}
+
+sealed class ResourcePropertyState {
+ object Loading : ResourcePropertyState()
+ data class Success(val resource: Resource) : ResourcePropertyState()
+ data class Error(val message: String) : ResourcePropertyState()
+}
+
+interface PatientDetailData {
+ val firstInGroup: Boolean
+ val lastInGroup: Boolean
+}
+
+data class PatientDetailHeader(
+ val header: String,
+ override val firstInGroup: Boolean = false,
+ override val lastInGroup: Boolean = false
+) : PatientDetailData
+
+data class PatientDetailProperty(
+ val patientProperty: PatientProperty,
+ override val firstInGroup: Boolean = false,
+ override val lastInGroup: Boolean = false
+) : PatientDetailData
+
+data class PatientDetailOverview(
+ val patient: PatientItem,
+ override val firstInGroup: Boolean = false,
+ override val lastInGroup: Boolean = false
+) : PatientDetailData
+
+data class PatientReferenceProperty(
+ val patientProperty: PatientProperty,
+ override val firstInGroup: Boolean = false,
+ override val lastInGroup: Boolean = false
+) : PatientDetailData
+
+data class PatientProperty(val header: String, val value: String)
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
new file mode 100644
index 0000000000..4815577baa
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientScreen.kt
@@ -0,0 +1,210 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package org.dtree.fhircore.dataclerk.ui.patient
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import com.google.android.fhir.sync.SyncJobStatus
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.dtree.fhircore.dataclerk.ui.main.AppMainViewModel
+import org.dtree.fhircore.dataclerk.util.extractName
+import org.hl7.fhir.r4.model.Practitioner
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PatientScreen(
+ navController: NavController,
+ appMainViewModel: AppMainViewModel,
+ patientViewModel: PatientViewModel = hiltViewModel()
+) {
+ val state by patientViewModel.screenState.collectAsState()
+ val list by patientViewModel.resourceMapStatus
+ val syncState by appMainViewModel.syncSharedFlow.collectAsState(initial = null)
+ val refreshKey by appMainViewModel.refreshHash
+
+ LaunchedEffect(syncState) {
+ if (syncState is SyncJobStatus.Finished) {
+ patientViewModel.fetchPatient()
+ }
+ }
+
+ LaunchedEffect(refreshKey) { if (refreshKey.isNotBlank()) patientViewModel.fetchPatient() }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ navigationIcon = {
+ IconButton(onClick = { navController.popBackStack() }) {
+ Icon(Icons.Filled.ArrowBack, contentDescription = "")
+ }
+ },
+ title = {
+ if (state is PatientDetailScreenState.Success) {
+ Text(text = (state as PatientDetailScreenState.Success).patientDetail.name)
+ }
+ },
+ actions = {}
+ )
+ }
+ ) { paddingValues ->
+ Column(Modifier.padding(paddingValues).fillMaxSize()) {
+ val context = LocalContext.current
+ if (state is PatientDetailScreenState.Success) {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(8.dp),
+ modifier = Modifier.fillMaxSize()
+ ) {
+ items((state as PatientDetailScreenState.Success).detailsData) { data ->
+ when (data) {
+ is PatientDetailHeader -> PatientDetailsCardViewBinding(data)
+ is PatientDetailProperty -> PatientListItemViewBinding(data)
+ is PatientDetailOverview ->
+ PatientDetailsHeaderBinding(data) { patientViewModel.editPatient(context) }
+ is PatientReferenceProperty ->
+ list[data.patientProperty.value]?.let { PatientReferencePropertyBinding(data, it) }
+ }
+ }
+ }
+ } else {
+ CircularProgressIndicator()
+ }
+ }
+ }
+}
+
+@Composable
+fun PatientDetailsCardViewBinding(data: PatientDetailHeader) {
+ Text(text = data.header, modifier = Modifier.fillMaxWidth())
+}
+
+@Composable
+fun PatientDetailsHeaderBinding(data: PatientDetailOverview, editPatient: () -> Unit = {}) {
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(Constants.defaultCardPadding)) {
+ Text(
+ text = data.patient.name,
+ style =
+ MaterialTheme.typography.titleLarge.copy(fontSize = 24.sp, fontStyle = FontStyle.Normal)
+ )
+ Row(Modifier.padding(8.dp)) {
+ Text(text = "Type", style = MaterialTheme.typography.bodyMedium)
+ Box(modifier = Modifier.width(8.dp))
+ Text(text = data.patient.healthStatus.display, style = MaterialTheme.typography.bodyMedium)
+ }
+ Box(modifier = Modifier.height(12.dp))
+ Button(onClick = editPatient, modifier = Modifier.fillMaxWidth()) {
+ Text(text = "Edit Profile")
+ }
+ }
+ }
+}
+
+@Composable
+fun PatientListItemViewBinding(data: PatientDetailProperty) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(Constants.defaultCardPadding)) {
+ Text(
+ text = data.patientProperty.header,
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
+ )
+ Box(modifier = Modifier.height(8.dp))
+ Text(
+ text = data.patientProperty.value,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1
+ )
+ }
+ }
+}
+
+@Composable
+fun PatientReferencePropertyBinding(
+ data: PatientReferenceProperty,
+ value: MutableStateFlow
+) {
+ val state by value.collectAsState()
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(Constants.defaultCardPadding)) {
+ Text(
+ text = data.patientProperty.header,
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
+ )
+ Box(modifier = Modifier.height(8.dp))
+ if (state is ResourcePropertyState.Error) {
+ Text(
+ text = (state as ResourcePropertyState.Error).message,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1
+ )
+ } else if (state is ResourcePropertyState.Success) {
+ val resource = (state as ResourcePropertyState.Success).resource
+ if (resource is Practitioner) {
+ Text(
+ text = resource.extractName(),
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1
+ )
+ }
+ } else {
+ CircularProgressIndicator()
+ }
+ }
+ }
+}
+
+object Constants {
+ val defaultCardPadding = 12.dp
+}
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
new file mode 100644
index 0000000000..9886ee890a
--- /dev/null
+++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/patient/PatientViewModel.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.ui.patient
+
+import android.content.Context
+import android.icu.text.DateFormat
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.text.SimpleDateFormat
+import java.time.LocalDate
+import java.time.ZoneId
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+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.util.getFormattedAge
+import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity
+import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireType
+import org.smartregister.fhircore.engine.util.extension.toHumanDisplay
+
+@HiltViewModel
+class PatientViewModel
+@Inject
+constructor(
+ savedStateHandle: SavedStateHandle,
+ private val appDataStore: AppDataStore,
+ @ApplicationContext val context: Context
+) : ViewModel() {
+ private val patientId = savedStateHandle.get("patientId") ?: ""
+ val screenState = MutableStateFlow(PatientDetailScreenState.Loading)
+ val resourceMapStatus: MutableState