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>> = + mutableStateOf(mapOf()) + init { + fetchPatient() + } + + fun fetchPatient() { + viewModelScope.launch { + try { + screenState.emit(PatientDetailScreenState.Loading) + val data = mutableListOf() + val patient = appDataStore.getPatient(patientId) + val hashList = mutableListOf() + patient.let { patientItem -> + data.add(PatientDetailOverview(patientItem, firstInGroup = true)) + data.add( + PatientDetailProperty( + PatientProperty("Last Updated", patientItem.dateCreated?.toHumanDisplay() ?: "") + ) + ) + data.add(PatientDetailProperty(PatientProperty("HCC/ArtNumber", patientItem.id))) + data.add( + PatientDetailProperty( + PatientProperty(getString(R.string.patient_property_mobile), patientItem.phone) + ) + ) + data.add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_property_dob), + patientItem.dob?.localizedString ?: "" + ) + ) + ) + data.add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_property_age), + getFormattedAge(patient, context.resources) + ) + ) + ) + data.add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_property_gender), + patientItem.gender.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() + } + ), + lastInGroup = true + ) + ) + val address = patientItem.addressData.fullAddress + data.add( + PatientDetailProperty( + PatientProperty( + getString(R.string.patient_property_address), + address.ifBlank { "N/A" } + ) + ) + ) + data.add( + PatientReferenceProperty(PatientProperty("CHW Assigned", patientItem.chwAssigned)) + ) + if (patientItem.chwAssigned.isNotBlank()) { + hashList.add(patientItem.chwAssigned) + } + } + screenState.emit(PatientDetailScreenState.Success(patient, data)) + fetchResources(hashList) + } catch (e: Exception) { + screenState.emit(PatientDetailScreenState.Error(e.message ?: "Error")) + } + } + } + + private fun fetchResources(resourceIds: List) { + viewModelScope.launch { resourceIds.forEach { fetchResource(it) } } + } + + private suspend fun fetchResource(resourceId: String) { + viewModelScope.launch { + try { + resourceMapStatus.value = + resourceMapStatus.value.toMutableMap().apply { + this[resourceId] = MutableStateFlow(ResourcePropertyState.Loading) + } + val resource = appDataStore.getResource(resourceId) + resourceMapStatus.value = + resourceMapStatus.value.toMutableMap().apply { + this[resourceId] = MutableStateFlow(ResourcePropertyState.Success(resource)) + } + } catch (e: Exception) { + resourceMapStatus.value = + resourceMapStatus.value.toMutableMap().apply { + this[resourceId] = MutableStateFlow(ResourcePropertyState.Error(e.message ?: "Error")) + } + } + } + } + + private fun getString(resId: Int) = context.resources.getString(resId) + fun editPatient(context: Context) { + QuestionnaireActivity.launchQuestionnaire( + context = context, + questionnaireId = EDIT_PROFILE_FORM, + clientIdentifier = patientId, + questionnaireType = QuestionnaireType.EDIT + ) + } + + companion object { + const val EDIT_PROFILE_FORM = "edit-patient-profile" + } +} + +private fun isAndroidIcuSupported() = true + +val LocalDate.localizedString: String + get() { + val date = Date.from(atStartOfDay(ZoneId.systemDefault())?.toInstant()) + return if (isAndroidIcuSupported()) DateFormat.getDateInstance(DateFormat.DEFAULT).format(date) + else SimpleDateFormat.getDateInstance(DateFormat.DEFAULT, Locale.getDefault()).format(date) + } diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Color.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Color.kt new file mode 100644 index 0000000000..e01335a68a --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Color.kt @@ -0,0 +1,27 @@ +/* + * 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.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Theme.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Theme.kt new file mode 100644 index 0000000000..7f475c6c2b --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Theme.kt @@ -0,0 +1,80 @@ +/* + * 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.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = + darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80) + +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ + ) + +@Composable +fun FhircoreandroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: (@Composable() () -> Unit) +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) +} diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Type.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Type.kt new file mode 100644 index 0000000000..d8fa29d06c --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Type.kt @@ -0,0 +1,52 @@ +/* + * 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.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ + ) diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/util/Utils.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/util/Utils.kt new file mode 100644 index 0000000000..cca087f12b --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/util/Utils.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.util + +import android.content.res.Resources +import java.time.LocalDate +import java.time.Period +import org.dtree.fhircore.dataclerk.R +import org.dtree.fhircore.dataclerk.ui.main.PatientItem +import org.hl7.fhir.r4.model.Practitioner +import org.smartregister.fhircore.engine.util.extension.canonicalName + +fun Practitioner.extractName(): String { + if (!hasName()) return "" + return this.name.canonicalName() +} + +fun getFormattedAge(patientItem: PatientItem, resources: Resources): String { + if (patientItem.dob == null) return "" + return Period.between(patientItem.dob, LocalDate.now()).let { + when { + it.years > 0 -> resources.getQuantityString(R.plurals.ageYear, it.years, it.years) + it.months > 0 -> resources.getQuantityString(R.plurals.ageMonth, it.months, it.months) + else -> resources.getQuantityString(R.plurals.ageDay, it.days, it.days) + } + } +} diff --git a/android/dataclerk/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/dataclerk/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..7706ab9e6d --- /dev/null +++ b/android/dataclerk/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/android/dataclerk/src/main/res/drawable/ic_app_logo.png b/android/dataclerk/src/main/res/drawable/ic_app_logo.png new file mode 100644 index 0000000000..0690e9a1d2 Binary files /dev/null and b/android/dataclerk/src/main/res/drawable/ic_app_logo.png differ diff --git a/android/dataclerk/src/main/res/drawable/ic_launcher_background.xml b/android/dataclerk/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..ca3826a46c --- /dev/null +++ b/android/dataclerk/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..80b730f367 --- /dev/null +++ b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..80b730f367 --- /dev/null +++ b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher.png b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..bc04f14ea2 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..772cd05024 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..b2fd67c4ef Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher.png b/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..81f55e664e Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..40bec22e63 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..09780e1de0 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..9967869319 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..2e94a98380 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..3c079d3bc0 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..c18bb2ef4a Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..e673cd9fa1 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..c7e22f7c0c Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..9d05046075 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..e34b1f7b7d Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..cbdaff07f0 Binary files /dev/null and b/android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/dataclerk/src/main/res/values/colors.xml b/android/dataclerk/src/main/res/values/colors.xml new file mode 100644 index 0000000000..ca1931bca9 --- /dev/null +++ b/android/dataclerk/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/android/dataclerk/src/main/res/values/ic_launcher_background.xml b/android/dataclerk/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..f42ada656e --- /dev/null +++ b/android/dataclerk/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + diff --git a/android/dataclerk/src/main/res/values/plurals.xml b/android/dataclerk/src/main/res/values/plurals.xml new file mode 100644 index 0000000000..c5a2eb88df --- /dev/null +++ b/android/dataclerk/src/main/res/values/plurals.xml @@ -0,0 +1,15 @@ + + + + %d year old + %d years old + + + %d month old + %d months old + + + %d day old + %d days old + + diff --git a/android/dataclerk/src/main/res/values/strings.xml b/android/dataclerk/src/main/res/values/strings.xml new file mode 100644 index 0000000000..7c4fc408d9 --- /dev/null +++ b/android/dataclerk/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + Data Clerk + Initializing settings … + + Id + Name + Gender + DOB + Phone Number + City + Country + Is Active + Mobile Number + ID Number + Address + Date of Birth + Age + Gender + diff --git a/android/dataclerk/src/main/res/values/themes.xml b/android/dataclerk/src/main/res/values/themes.xml new file mode 100644 index 0000000000..31ec97313f --- /dev/null +++ b/android/dataclerk/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +