From 1feead068b272ae7a5a49246c76f7cb6b67585e7 Mon Sep 17 00:00:00 2001 From: Rkareko <47570855+Rkareko@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:55:42 +0300 Subject: [PATCH 1/6] Fix formatting issue in save draft docs (#3649) --- .../engineering/app/configuring/forms/save-form-as-draft.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx index a6457a835b..724298f013 100644 --- a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx +++ b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx @@ -54,10 +54,11 @@ A dialog appears with 3 buttons i.e `Save as draft`, `Discard changes` and `Canc The table below details what each of the buttons does. #### Alert dialog buttons descriptions + |Button | Description | -|--|--|:--:|:--:| +|:--|:--| Save as draft | Saves user input as a draft | -Discard changes | Dismisses user input, and closes the form without saving the draft. | +Discard changes | Dismisses user input, and closes the form without saving the draft | Cancel | Dismisses the dialog so that the user can continue interacting with the form | ## Launching save draft from DELETE_DRAFT_QUESTIONNAIRE workflow From a795d9c7ab0b191f3fc2591d7318c94b7083ccc9 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 11 Dec 2024 18:08:56 +0300 Subject: [PATCH 2/6] Improve visibility on loading map data and sync UX (#3621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement LineSpinFadeLoaderProgressIndicator Signed-off-by: Elly Kitoto * Run spotless Signed-off-by: Elly Kitoto * Fix spacing Signed-off-by: Elly Kitoto * Show the loader dialog when syncing the location data (#3598) * Show the LoaderDialog when syncing the Location data Update the LoaderDialog: - Make box and progressBar size dynamic - Make the progressDialog message as optional - Add the ability to show either the ProgressBar or LineSpinFadeLoaderProgressIndicator * Add an option in the LoaderDialog to show a dialog with non blocking UI Show a non blocking dialog when applying location filtering * clean-up: rename variables * fix tests * 🚧 Increase the loading dialog size on the geowidget screen * Fix showing map snackbar messages Signed-off-by: Elly Kitoto * Formate code Signed-off-by: Elly Kitoto --------- Signed-off-by: Elly Kitoto Co-authored-by: qaziabubakar-vd <72507786+qaziabubakar-vd@users.noreply.github.com> Co-authored-by: Benjamin Mwalimu Co-authored-by: qaziabubakar-vd --- android/engine/build.gradle.kts | 3 +- .../LineSpinFadeLoaderProgressIndicator.kt | 149 +++++++++++++++++ .../ui/components/register/LoaderDialog.kt | 151 +++++++++++++----- .../ui/geowidget/GeoWidgetLauncherFragment.kt | 1 + .../ui/geowidget/GeoWidgetLauncherScreen.kt | 39 ++++- .../geowidget/GeoWidgetLauncherViewModel.kt | 53 +++--- .../ui/shared/components/SyncStatusView.kt | 130 ++++++++------- 7 files changed, 398 insertions(+), 128 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index 753fbae6ce..9d90fcc3d3 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -147,6 +147,7 @@ dependencies { implementation(libs.slf4j.nop) implementation(libs.fhir.sdk.common) + // Shared dependencies api(libs.bundles.datastore.kt) api(libs.bundles.navigation) api(libs.bundles.materialicons) @@ -158,8 +159,6 @@ dependencies { api(libs.bundles.okhttp3) api(libs.bundles.paging) api(libs.ui) - - // Shared dependencies api(libs.glide) api(libs.knowledge) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.p2p.lib) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt new file mode 100644 index 0000000000..78d287df1d --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/LineSpinFadeLoaderProgressIndicator.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +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.graphics.StrokeCap +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated + +const val ANIMATION_LABEL = "LineSpinFadeLoaderProgressIndicator" + +/** + * A custom progress indicator that displays rotating lines in a circular pattern. Each line fades + * in and out as it rotates, creating a smooth loading animation effect. + * + * @param modifier Modifier to be applied to the Canvas composable + * @param color The color of the lines in the loading indicator + * @param lineCount The number of lines to be displayed in the circular pattern (default is 8) + * @param lineWidth The width/thickness of each line (default is 3f) + * @param lineLength The length of each line (default is 8f) + * @param innerRadius The radius of the circle on which the lines are positioned (default is 10f) + * + * Example usage: + * ``` + * LineSpinFadeLoaderProgressIndicator( + * modifier = Modifier.size(80.dp), + * color = Color.Blue, + * lineCount = 8, + * lineWidth = 3f, + * lineLength = 8f, + * innerRadius = 10f + * ) + * ``` + * + * The animation creates a rotating effect where: + * - All lines are visible simultaneously + * - Each line's opacity changes based on its current position in the rotation + * - Lines maintain fixed positions but fade in/out to create a rotation illusion + * - The animation continuously loops with a smooth transition + * + * @see Canvas + * @see rememberInfiniteTransition + */ +@Composable +fun LineSpinFadeLoaderProgressIndicator( + modifier: Modifier = Modifier, + color: Color = Color.Blue, + lineCount: Int = 12, + lineWidth: Float = 4f, + lineLength: Float = 20f, + innerRadius: Float = 20f, +) { + val infiniteTransition = rememberInfiniteTransition(ANIMATION_LABEL) + + val rotationAnimation by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = lineCount.toFloat(), + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = ANIMATION_LABEL, + ) + + Canvas(modifier = modifier.wrapContentSize()) { + val canvasWidth = size.width + val canvasHeight = size.height + val centerX = canvasWidth / 2 + val centerY = canvasHeight / 2 + + for (i in 0 until lineCount) { + val angle = 2 * PI * i / lineCount + val startX = centerX + cos(angle).toFloat() * innerRadius + val startY = centerY + sin(angle).toFloat() * innerRadius + val endX = centerX + cos(angle).toFloat() * (innerRadius + lineLength) + val endY = centerY + sin(angle).toFloat() * (innerRadius + lineLength) + + // Calculate alpha based on the current rotation + val distance = (i - rotationAnimation + lineCount) % lineCount + val alpha = + when { + distance < lineCount / 2f -> 1f - (distance / (lineCount / 2f)) + else -> (distance - (lineCount / 2f)) / (lineCount / 2f) + } + + drawLine( + color = color.copy(alpha = alpha), + start = androidx.compose.ui.geometry.Offset(startX, startY), + end = androidx.compose.ui.geometry.Offset(endX, endY), + strokeWidth = lineWidth, + cap = StrokeCap.Round, + ) + } + } +} + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun LoadingScreen() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LineSpinFadeLoaderProgressIndicator( + modifier = Modifier.padding(8.dp), + color = Color.Blue, + ) + + Spacer(modifier = Modifier.height(32.dp)) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt index da98fa0606..2ceeda3d5a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/LoaderDialog.kt @@ -19,10 +19,9 @@ package org.smartregister.fhircore.engine.ui.components.register import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Surface @@ -37,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -44,6 +44,7 @@ import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.ui.components.LineSpinFadeLoaderProgressIndicator import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated const val LOADER_DIALOG_PROGRESS_BAR_TAG = "loaderDialogProgressBarTag" @@ -52,58 +53,113 @@ const val LOADER_DIALOG_PROGRESS_MSG_TAG = "loaderDialogProgressMsgTag" @Composable fun LoaderDialog( modifier: Modifier = Modifier, - dialogMessage: String, + dialogMessage: String? = null, percentageProgressFlow: Flow = flowOf(0), showPercentageProgress: Boolean = false, + boxWidth: Dp = 240.dp, + boxHeight: Dp = 180.dp, + progressBarSize: Dp = 40.dp, + showBackground: Boolean = true, + showLineSpinIndicator: Boolean = false, + showOverlay: Boolean = true, + alignment: Alignment = Alignment.Center, ) { val currentPercentage = percentageProgressFlow.collectAsState(0).value + + if (showOverlay) { + Dialog(onDismissRequest = {}, properties = DialogProperties(dismissOnBackPress = false)) { + LoaderContent( + modifier = modifier, + dialogMessage = dialogMessage, + currentPercentage = currentPercentage, + showPercentageProgress = showPercentageProgress, + boxWidth = boxWidth, + boxHeight = boxHeight, + progressBarSize = progressBarSize, + showBackground = showBackground, + showLineSpinIndicator = showLineSpinIndicator, + ) + } + } else { + Box( + modifier = modifier.wrapContentSize(), + contentAlignment = alignment, + ) { + LoaderContent( + modifier = modifier, + dialogMessage = dialogMessage, + currentPercentage = currentPercentage, + showPercentageProgress = showPercentageProgress, + boxWidth = boxWidth, + boxHeight = boxHeight, + progressBarSize = progressBarSize, + showBackground = showBackground, + showLineSpinIndicator = showLineSpinIndicator, + ) + } + } +} + +@Composable +private fun LoaderContent( + modifier: Modifier, + dialogMessage: String?, + currentPercentage: Int, + showPercentageProgress: Boolean, + boxWidth: Dp, + boxHeight: Dp, + progressBarSize: Dp, + showBackground: Boolean, + showLineSpinIndicator: Boolean, +) { val openDialog = remember { mutableStateOf(true) } if (openDialog.value) { - Dialog( - onDismissRequest = { openDialog.value = true }, - properties = DialogProperties(dismissOnBackPress = true), - ) { - Box(modifier.size(240.dp, 180.dp)) { - Column( - modifier = modifier.padding(8.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + Box(modifier.size(boxWidth, boxHeight)) { + Column( + modifier = modifier.padding(8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + modifier = modifier.size(boxWidth, boxHeight), + shape = RoundedCornerShape(8.dp), + color = if (showBackground) Color.Black.copy(alpha = 0.56f) else Color.Transparent, ) { - Surface( - color = Color.Black.copy(alpha = 0.56f), - modifier = modifier.fillMaxSize(), - shape = RoundedCornerShape(8), + Column( + modifier = modifier.padding(8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { + if (showLineSpinIndicator) { + LineSpinFadeLoaderProgressIndicator( + color = Color.White, + lineLength = 12f, + innerRadius = 16f, + ) + } else { CircularProgressIndicator( color = Color.White, strokeWidth = 3.dp, - modifier = modifier.testTag(LOADER_DIALOG_PROGRESS_BAR_TAG).size(40.dp), + modifier = modifier.testTag(LOADER_DIALOG_PROGRESS_BAR_TAG).size(progressBarSize), ) - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - fontSize = 16.sp, - color = Color.White, - text = dialogMessage, - modifier = - modifier.testTag(LOADER_DIALOG_PROGRESS_MSG_TAG).padding(vertical = 16.dp), - ) + } - if (showPercentageProgress) { - Text( - fontSize = 15.sp, - color = Color.White, - text = stringResource(id = R.string.percentage_progress, currentPercentage), - modifier = modifier.padding(horizontal = 3.dp, vertical = 16.dp), - ) - } - } + dialogMessage?.let { + Text( + text = it, + color = Color.White, + fontSize = 14.sp, + modifier = modifier.testTag(LOADER_DIALOG_PROGRESS_MSG_TAG).padding(top = 8.dp), + ) + } + + if (showPercentageProgress) { + Text( + fontSize = 15.sp, + color = Color.White, + text = "$currentPercentage%", + modifier = modifier.padding(top = 4.dp), + ) } } } @@ -122,3 +178,16 @@ fun LoaderDialog( fun LoaderDialogPreview() { LoaderDialog(dialogMessage = stringResource(id = R.string.syncing)) } + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun LoaderDialogPreviewTest() { + LoaderDialog( + boxWidth = 50.dp, + boxHeight = 50.dp, + progressBarSize = 25.dp, + showBackground = false, + showLineSpinIndicator = true, + showOverlay = false, + ) +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt index 2eab074a82..18bb4efdf4 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt @@ -195,6 +195,7 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { launchQuestionnaire = geoWidgetLauncherViewModel::launchQuestionnaire, decodeImage = geoWidgetLauncherViewModel::getImageBitmap, onAppMainEvent = appMainViewModel::onEvent, + isSyncing = geoWidgetLauncherViewModel.isSyncing, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt index 86dc61b6bc..516a7c460c 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt @@ -18,17 +18,25 @@ package org.smartregister.fhircore.quest.ui.geowidget import android.content.Context import android.graphics.Bitmap +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import androidx.fragment.compose.AndroidFragment import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.navigation.NavController import org.hl7.fhir.r4.model.ResourceType @@ -36,6 +44,7 @@ import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation +import org.smartregister.fhircore.engine.ui.components.register.LoaderDialog import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment @@ -65,8 +74,11 @@ fun GeoWidgetLauncherScreen( launchQuestionnaire: (QuestionnaireConfig, GeoJsonFeature, Context) -> Unit, decodeImage: ((String) -> Bitmap?)?, onAppMainEvent: (AppMainEvent) -> Unit, + isSyncing: LiveData, ) { val context = LocalContext.current + val syncing by isSyncing.observeAsState() + Scaffold( topBar = { Column { @@ -118,14 +130,20 @@ fun GeoWidgetLauncherScreen( }, ) { innerPadding -> val fragmentState = rememberFragmentState() - Box(modifier = modifier.padding(innerPadding)) { + Box( + modifier = modifier.padding(innerPadding).fillMaxSize(), + ) { AndroidFragment(fragmentState = fragmentState) { fragment -> fragment .setUseGpsOnAddingLocation(false) .setAddLocationButtonVisibility(geoWidgetConfiguration.showAddLocation) .setOnAddLocationListener { feature: GeoJsonFeature -> if (feature.geometry?.coordinates == null) return@setOnAddLocationListener - launchQuestionnaire(geoWidgetConfiguration.registrationQuestionnaire, feature, context) + launchQuestionnaire( + geoWidgetConfiguration.registrationQuestionnaire, + feature, + context, + ) } .setOnCancelAddingLocationListener { context.showToast(context.getString(R.string.on_cancel_adding_location)) @@ -153,6 +171,23 @@ fun GeoWidgetLauncherScreen( observerGeoJsonFeatures(geoJsonFeatures) } } + if (syncing == true) { + Box( + modifier = + Modifier.fillMaxSize().padding(16.dp).pointerInput(Unit) { detectTapGestures {} }, + contentAlignment = Alignment.Center, + ) { + LoaderDialog( + boxWidth = 100.dp, + boxHeight = 100.dp, + progressBarSize = 130.dp, + showBackground = true, + showLineSpinIndicator = true, + showOverlay = false, + modifier = Modifier.align(Alignment.Center), + ) + } + } } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt index 8f7ae4b20e..e755cc748f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt @@ -75,6 +75,9 @@ constructor( private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() + private val _isSyncing = MutableLiveData(false) + val isSyncing: LiveData = _isSyncing + private val _noLocationFoundDialog = MutableLiveData() val noLocationFoundDialog: LiveData get() = _noLocationFoundDialog @@ -98,6 +101,7 @@ constructor( searchText: String?, ) { viewModelScope.launch { + _isSyncing.postValue(true) val (locationsWithCoordinates, locationsWithoutCoordinates) = defaultRepository .searchNestedResources( @@ -159,13 +163,15 @@ constructor( } else { registerData.filter { geoJsonFeature: GeoJsonFeature -> geoWidgetConfig.topScreenSection?.searchBar?.computedRules?.any { ruleName -> - // if ruleName not found in map return {-1}; check always return false hence no data + // if ruleName not found in map return {-1}; check always return false hence no + // data val value = geoJsonFeature.properties[ruleName]?.toString() ?: "{-1}" value.contains(other = searchText, ignoreCase = true) } == true } } + _isSyncing.postValue(false) geoJsonFeatures.postValue(features) Timber.w( @@ -197,30 +203,11 @@ constructor( ), ) } else { - val message = - if (searchText.isNullOrBlank()) { - context.getString(R.string.all_locations_rendered) - } else { - context.getString(R.string.all_matching_locations_rendered, locationsCount) - } - emitSnackBarState( - SnackBarMessageConfig( - message = message, - actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), - duration = SnackbarDuration.Short, - ), - ) - } - - // Account for missing locations - if (locationsCount == 0) { - if (!searchText.isNullOrBlank()) { + if (locationsCount == 0) { val message = - context.getString( - R.string.no_found_locations_matching_text, - searchText, - ) - Timber.w(message) + if (!searchText.isNullOrBlank()) { + context.getString(R.string.no_found_locations_matching_text, searchText) + } else context.getString(R.string.no_locations_to_render) emitSnackBarState( SnackBarMessageConfig( message = message, @@ -228,12 +215,22 @@ constructor( duration = SnackbarDuration.Long, ), ) + Timber.w(message) } else { - SnackBarMessageConfig( - message = context.getString(R.string.no_locations_to_render), - actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), - duration = SnackbarDuration.Long, + val message = + if (searchText.isNullOrBlank()) { + context.getString(R.string.all_locations_rendered) + } else { + context.getString(R.string.all_matching_locations_rendered, locationsCount) + } + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Short, + ), ) + Timber.w(message) } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt index 423323e660..cb371dfb20 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt @@ -19,6 +19,7 @@ package org.smartregister.fhircore.quest.ui.shared.components import androidx.compose.animation.animateContentSize 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.Row @@ -63,6 +64,7 @@ import java.time.OffsetDateTime import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.ui.components.LineSpinFadeLoaderProgressIndicator import org.smartregister.fhircore.engine.ui.theme.AppTheme import org.smartregister.fhircore.engine.ui.theme.DangerColor import org.smartregister.fhircore.engine.ui.theme.DefaultColor @@ -267,68 +269,86 @@ fun SyncStatusView( } if (currentSyncJobStatus is CurrentSyncJobStatus.Running) { - Column(modifier = Modifier.weight(1f)) { - if (!minimized) { - SyncStatusTitle( - text = - stringResource( - if (isSyncUpload == true) { - org.smartregister.fhircore.engine.R.string.sync_up_inprogress - } else { - org.smartregister.fhircore.engine.R.string.sync_down_inprogress - }, - progressPercentage ?: 0, - ), - minimized = false, - color = Color.White, - startPadding = 0, - ) - } - LinearProgressIndicator( - progress = (progressPercentage?.toFloat()?.div(100)) ?: 0f, - color = MaterialTheme.colors.primary, - backgroundColor = Color.White, - modifier = - Modifier.testTag(SYNC_PROGRESS_INDICATOR_TEST_TAG) - .padding(vertical = 4.dp) - .fillMaxWidth(), - ) - if (!minimized) { - Text( - text = stringResource(id = org.smartregister.fhircore.engine.R.string.please_wait), - color = SubtitleTextColor, - fontSize = 14.sp, - textAlign = TextAlign.Start, - modifier = Modifier.align(Alignment.Start), + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.weight(1f), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (!minimized) { + SyncStatusTitle( + text = + stringResource( + if (isSyncUpload == true) { + org.smartregister.fhircore.engine.R.string.sync_up_inprogress + } else { + org.smartregister.fhircore.engine.R.string.sync_down_inprogress + }, + progressPercentage ?: 0, + ), + minimized = false, + color = Color.White, + startPadding = 0, + ) + } + LinearProgressIndicator( + progress = (progressPercentage?.toFloat()?.div(100)) ?: 0f, + color = MaterialTheme.colors.primary, + backgroundColor = Color.White, + modifier = + Modifier.testTag(SYNC_PROGRESS_INDICATOR_TEST_TAG) + .padding(vertical = 4.dp) + .fillMaxWidth(), ) + if (!minimized) { + Text( + text = stringResource(id = org.smartregister.fhircore.engine.R.string.please_wait), + color = SubtitleTextColor, + fontSize = 14.sp, + textAlign = TextAlign.Start, + modifier = Modifier.align(Alignment.Start), + ) + } } } } - if ( - (currentSyncJobStatus is CurrentSyncJobStatus.Failed || - currentSyncJobStatus is CurrentSyncJobStatus.Running) && !minimized + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), ) { - Text( - text = - stringResource( - if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { - org.smartregister.fhircore.engine.R.string.retry - } else { - org.smartregister.fhircore.engine.R.string.cancel + if (currentSyncJobStatus is CurrentSyncJobStatus.Running) { + LineSpinFadeLoaderProgressIndicator( + color = Color.White, + lineLength = 8f, + innerRadius = 12f, + ) + } + if ( + (currentSyncJobStatus is CurrentSyncJobStatus.Failed || + currentSyncJobStatus is CurrentSyncJobStatus.Running) && !minimized + ) { + Text( + text = + stringResource( + if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { + org.smartregister.fhircore.engine.R.string.retry + } else { + org.smartregister.fhircore.engine.R.string.cancel + }, + ), + modifier = + Modifier.padding(start = 16.dp).clickable { + if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { + onRetry() + } else { + onCancel() + } }, - ), - modifier = - Modifier.padding(start = 16.dp).clickable { - if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { - onRetry() - } else { - onCancel() - } - }, - color = MaterialTheme.colors.primary, - fontWeight = FontWeight.SemiBold, - ) + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.SemiBold, + ) + } } } } From 1c565065945424a5e369e7168d6562a08eba5980 Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Wed, 11 Dec 2024 19:55:08 +0300 Subject: [PATCH 3/6] FHIR Core Enhancements (#3587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate to latest FHIR SDK Libraries * Implement CPU async foreach extension method * Refactor P2P data transer to execute in parallel * Add content to test workflow APIs * Revert Quest CI log level to Stacktrace * Update sync jobs configuration * Performance UX Improvements ⚡️ * Syncing in progress notifications * Fix crash on Android 12+ devices * Add Last sync duration info * Add Foreground Service data sync permission * Fix bug foreground service exception Android 14 --- .github/workflows/ci.yml | 6 +- android/engine/build.gradle.kts | 1 + .../configuration/ConfigurationRegistry.kt | 65 +++++----- .../app/ApplicationConfiguration.kt | 3 +- .../engine/configuration/app/ConfigService.kt | 3 + .../fhircore/engine/sync/AppSyncWorker.kt | 118 +++++++++++++++++- .../fhircore/engine/sync/CustomSyncWorker.kt | 2 +- .../engine/sync/OpenSrpDownloadManager.kt | 8 +- .../fhircore/engine/sync/SyncBroadcaster.kt | 20 ++- .../engine/sync/SyncListenerManager.kt | 9 ++ .../engine/util/NotificationConstants.kt | 33 +++++ .../fhircore/engine/util/ParallelUtil.kt | 22 ++-- .../engine/util/SharedPreferenceKey.kt | 2 + .../util/extension/ResourceExtension.kt | 74 ++++++----- .../res/drawable/ic_opensrp_small_logo.png | Bin 0 -> 1510 bytes .../engine/src/main/res/values-es/strings.xml | 4 +- .../engine/src/main/res/values-fr/strings.xml | 4 +- .../engine/src/main/res/values-in/strings.xml | 4 +- .../engine/src/main/res/values/strings.xml | 6 +- android/engine/src/main/res/values/styles.xml | 1 + .../fhircore/engine/app/AppConfigService.kt | 2 + .../ConfigurationRegistryTest.kt | 70 +++++++---- .../fhircore/engine/sync/AppSyncWorkerTest.kt | 15 ++- .../engine/task/FhirCarePlanGeneratorTest.kt | 2 +- .../fhircore/engine/util/ParallelUtilTest.kt | 8 +- .../geowidget/di/config/FakeConfigService.kt | 2 + android/gradle/libs.versions.toml | 6 +- .../fhircore/quest/integration/Faker.kt | 2 + .../ui/main/components/AppDrawerTest.kt | 2 +- .../ui/usersetting/UserSettingScreenTest.kt | 2 +- android/quest/src/main/AndroidManifest.xml | 7 +- .../fhircore/quest/QuestConfigService.kt | 2 + .../ui/appsetting/AppSettingViewModel.kt | 5 +- .../fhircore/quest/ui/login/LoginViewModel.kt | 18 ++- .../quest/ui/main/AppMainViewModel.kt | 20 +++ .../quest/ui/main/components/AppDrawer.kt | 5 +- .../quest/ui/usersetting/UserSettingScreen.kt | 2 +- .../fhircore/quest/app/AppConfigService.kt | 2 + .../quest/app/ConfigurationRegistryTest.kt | 11 +- .../ui/appsetting/AppSettingViewModelTest.kt | 55 ++++---- .../quest/ui/main/AppMainActivityTest.kt | 2 +- .../quest/ui/main/AppMainViewModelTest.kt | 6 +- 42 files changed, 450 insertions(+), 181 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt create mode 100644 android/engine/src/main/res/drawable/ic_opensrp_small_logo.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90c4b4bc62..cdb47307ff 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,11 +272,7 @@ jobs: force-avd-creation: true emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:connectedOpensrpDebugAndroidTest --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance - - - name: Test UnitTest - run: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:testOpensrpDebugUnitTest --stacktrace - working-directory: android + script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance - name: Run Quest module unit and instrumentation tests and generate aggregated coverage report (Disabled) if: false diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index 9d90fcc3d3..f26c0adfb0 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -196,6 +196,7 @@ dependencies { exclude(group = "com.google.android.fhir", module = "engine") exclude(group = "org.smartregister", module = "engine") exclude(group = "com.github.ben-manes.caffeine") + exclude(group = "com.google.android.fhir", module = "knowledge") } api(libs.contrib.barcode) { isTransitive = true diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index 50579cce38..c8521b3f20 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -34,8 +34,6 @@ import java.util.PropertyResourceBundle import java.util.ResourceBundle import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.RequestBody.Companion.toRequestBody @@ -100,7 +98,6 @@ constructor( val localizationHelper: LocalizationHelper by lazy { LocalizationHelper(this) } private val supportedFileExtensions = listOf("json", "properties") private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK - private val mutex = Mutex() /** * Retrieve configuration for the provided [ConfigType]. The JSON retrieved from [configsJsonMap] @@ -592,34 +589,30 @@ constructor( * Note */ suspend fun addOrUpdate(resource: R) { - withContext(dispatcherProvider.io()) { - try { - createOrUpdateRemote(resource) - } catch (sqlException: SQLException) { - Timber.e(sqlException) - } + try { + createOrUpdateRemote(resource) + } catch (sqlException: SQLException) { + Timber.e(sqlException) + } - /** - * Knowledge manager [MetadataResource]s install. Here we install all resources types of - * [MetadataResource] as per FHIR Spec.This supports future use cases as well - */ - try { - if (resource is MetadataResource) { - mutex.withLock { - knowledgeManager.install( - KnowledgeManagerUtil.writeToFile( - context = context, - configService = configService, - metadataResource = resource, - subFilePath = - "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", - ), - ) - } - } - } catch (exception: Exception) { - Timber.e(exception) + /** + * Knowledge manager [MetadataResource]s install. Here we install all resources types of + * [MetadataResource] as per FHIR Spec.This supports future use cases as well + */ + try { + if (resource is MetadataResource) { + knowledgeManager.install( + KnowledgeManagerUtil.writeToFile( + context = context, + configService = configService, + metadataResource = resource, + subFilePath = + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", + ), + ) } + } catch (exception: Exception) { + Timber.e(exception) } } @@ -632,13 +625,11 @@ constructor( * @param resources vararg of resources */ suspend fun createOrUpdateRemote(vararg resources: Resource) { - return withContext(dispatcherProvider.io()) { - resources.onEach { - it.updateLastUpdated() - it.generateMissingId() - } - fhirEngine.create(*resources, isLocalOnly = true) + resources.onEach { + it.updateLastUpdated() + it.generateMissingId() } + fhirEngine.create(*resources, isLocalOnly = true) } @VisibleForTesting fun isNonProxy(): Boolean = _isNonProxy @@ -750,7 +741,7 @@ constructor( it.system.contentEquals(organizationResourceTag?.tag?.system, ignoreCase = true) } ?.code - COUNT -> appConfig.remoteSyncPageSize.toString() + COUNT -> DEFAULT_COUNT.toString() else -> paramExpression }?.let { paramExpression?.replace(paramLiteral, it) } @@ -801,7 +792,7 @@ constructor( const val MANIFEST_PROCESSOR_BATCH_SIZE = 20 const val ORGANIZATION = "organization" const val TYPE_REFERENCE_DELIMITER = "/" - const val DEFAULT_COUNT = 200 + const val DEFAULT_COUNT = 1000 const val PAGINATION_NEXT = "next" const val RESOURCES_PATH = "resources/" const val SYNC_LOCATION_IDS = "_syncLocations" diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index 27076b4a8f..c3479e1dcf 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -19,6 +19,7 @@ package org.smartregister.fhircore.engine.configuration.app import kotlinx.serialization.Serializable import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.Configuration +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.event.EventWorkflow import org.smartregister.fhircore.engine.domain.model.LauncherType import org.smartregister.fhircore.engine.util.extension.DEFAULT_FORMAT_SDF_DD_MM_YYYY @@ -28,7 +29,7 @@ data class ApplicationConfiguration( override var appId: String, override var configType: String = ConfigType.Application.name, val appTitle: String = "", - val remoteSyncPageSize: Int = 100, + val remoteSyncPageSize: Int = ConfigurationRegistry.DEFAULT_COUNT, val languages: List = listOf("en"), val useDarkTheme: Boolean = false, val syncInterval: Long = 15, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt index 502cb2cde8..d7d642960e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt @@ -34,6 +34,9 @@ interface ConfigService { /** Define a list of [ResourceTag] for the application. */ fun defineResourceTags(): List + /** Return the App's launcher icon for use in notifications */ + fun getLauncherIcon(): Int + /** * Provide a list of [Coding] that represents [ResourceTag]. [Coding] can be directly appended to * a FHIR resource. diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt index e16812a7ac..80d7988320 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt @@ -16,20 +16,36 @@ package org.smartregister.fhircore.engine.sync +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo +import android.graphics.drawable.Icon +import android.os.Build +import androidx.core.app.NotificationCompat import androidx.hilt.work.HiltWorker +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.ConflictResolver +import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker +import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.SyncOperation import com.google.android.fhir.sync.upload.HttpCreateMethod import com.google.android.fhir.sync.upload.HttpUpdateMethod import com.google.android.fhir.sync.upload.UploadStrategy +import com.ibm.icu.util.Calendar import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.runBlocking +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.util.NotificationConstants +import org.smartregister.fhircore.engine.util.SharedPreferenceKey @HiltWorker class AppSyncWorker @@ -40,7 +56,14 @@ constructor( val syncListenerManager: SyncListenerManager, private val openSrpFhirEngine: FhirEngine, private val appTimeStampContext: AppTimeStampContext, -) : FhirSyncWorker(appContext, workerParams) { + private val configService: ConfigService, +) : FhirSyncWorker(appContext, workerParams), OnSyncListener { + private val notificationManager = + appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + init { + syncListenerManager.registerSyncListener(this) + } override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver @@ -50,6 +73,26 @@ constructor( context = appTimeStampContext, ) + override suspend fun doWork(): Result { + saveSyncStartTimestamp() + setForeground(getForegroundInfo()) + return super.doWork() + } + + private fun saveSyncStartTimestamp() { + syncListenerManager.sharedPreferencesHelper.write( + SharedPreferenceKey.SYNC_START_TIMESTAMP.name, + Calendar.getInstance().timeInMillis, + ) + } + + private fun saveSyncEndTimestamp() { + syncListenerManager.sharedPreferencesHelper.write( + SharedPreferenceKey.SYNC_END_TIMESTAMP.name, + Calendar.getInstance().timeInMillis, + ) + } + override fun getFhirEngine(): FhirEngine = openSrpFhirEngine override fun getUploadStrategy(): UploadStrategy = @@ -59,4 +102,77 @@ constructor( squash = true, bundleSize = 500, ) + + override suspend fun getForegroundInfo(): ForegroundInfo { + val channel = + NotificationChannel( + NotificationConstants.ChannelId.DATA_SYNC, + NotificationConstants.ChannelName.DATA_SYNC, + NotificationManager.IMPORTANCE_LOW, + ) + notificationManager.createNotificationChannel(channel) + + val notification: Notification = + buildNotification(progress = 0, isSyncUpload = false, isInitial = true) + + val foregroundInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NotificationConstants.NotificationId.DATA_SYNC, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + ForegroundInfo(NotificationConstants.NotificationId.DATA_SYNC, notification) + } + return foregroundInfo + } + + private fun getSyncProgress(completed: Int, total: Int) = + completed * 100 / if (total > 0) total else 1 + + override fun onSync(syncJobStatus: CurrentSyncJobStatus) { + when (syncJobStatus) { + is CurrentSyncJobStatus.Running -> { + if (syncJobStatus.inProgressSyncJob is SyncJobStatus.InProgress) { + val inProgressSyncJob = syncJobStatus.inProgressSyncJob as SyncJobStatus.InProgress + val isSyncUpload = inProgressSyncJob.syncOperation == SyncOperation.UPLOAD + val progressPercentage = + getSyncProgress(inProgressSyncJob.completed, inProgressSyncJob.total) + updateNotificationProgress(progress = progressPercentage, isSyncUpload = isSyncUpload) + } + } + is CurrentSyncJobStatus.Succeeded -> saveSyncEndTimestamp() + else -> {} + } + } + + private fun buildNotification( + progress: Int, + isSyncUpload: Boolean, + isInitial: Boolean, + ): Notification { + return NotificationCompat.Builder(applicationContext, NotificationConstants.ChannelId.DATA_SYNC) + .setContentTitle( + applicationContext.getString( + if (isInitial) { + R.string.syncing_initiated + } else if (isSyncUpload) R.string.syncing_up else R.string.syncing_down, + ), + ) + .setSmallIcon(R.drawable.ic_opensrp_small_logo) + .setLargeIcon(Icon.createWithResource(applicationContext, configService.getLauncherIcon())) + .setContentText(applicationContext.getString(R.string.percentage_progress, progress)) + .setProgress(100, progress, progress == 0) + .setOngoing(true) + .build() + } + + private fun updateNotificationProgress(progress: Int, isSyncUpload: Boolean) { + val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = + buildNotification(progress = progress, isSyncUpload = isSyncUpload, isInitial = false) + notificationManager.notify(NotificationConstants.NotificationId.DATA_SYNC, notification) + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt index 00c66ddce4..d084c33d4d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt @@ -49,7 +49,7 @@ constructor( val (resourceSearchParams, _) = loadResourceSearchParams() Timber.i("Custom resource sync parameters $resourceSearchParams") resourceSearchParams - .asSequence() + .asIterable() .filter { it.value.isNotEmpty() } .map { "${it.key}?${it.value.concatParams()}" } .forEach { url -> diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt index b94a78381b..6b4947e53d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt @@ -20,6 +20,7 @@ import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.download.DownloadRequest import com.google.android.fhir.sync.download.ResourceParamsBasedDownloadWorkManager import com.google.android.fhir.sync.download.ResourceSearchParams +import com.google.android.fhir.sync.download.UrlDownloadRequest import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.util.extension.updateLastUpdated @@ -32,7 +33,12 @@ class OpenSrpDownloadManager( private val downloadWorkManager = ResourceParamsBasedDownloadWorkManager(resourceSearchParams, context) - override suspend fun getNextRequest(): DownloadRequest? = downloadWorkManager.getNextRequest() + override suspend fun getNextRequest(): DownloadRequest? = + downloadWorkManager.getNextRequest().apply { + if (this is UrlDownloadRequest) { + url.replace("_pretty=true", "_pretty=false") + } + } override suspend fun getSummaryRequestUrls(): Map = downloadWorkManager.getSummaryRequestUrls() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt index 134e1df08f..59c9413d93 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt @@ -17,16 +17,19 @@ package org.smartregister.fhircore.engine.sync import android.content.Context +import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.google.android.fhir.FhirEngine +import com.google.android.fhir.sync.BackoffCriteria import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.android.fhir.sync.LastSyncJobStatus import com.google.android.fhir.sync.PeriodicSyncConfiguration import com.google.android.fhir.sync.PeriodicSyncJobStatus import com.google.android.fhir.sync.RepeatInterval +import com.google.android.fhir.sync.RetryConfiguration import com.google.android.fhir.sync.Sync import com.google.android.fhir.sync.SyncJobStatus import com.google.android.fhir.sync.download.ResourceParamsBasedDownloadWorkManager @@ -77,6 +80,11 @@ constructor( .setConstraints( Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), ) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + 10, + TimeUnit.SECONDS, + ) .build(), ) } @@ -95,6 +103,16 @@ constructor( syncConstraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), repeat = RepeatInterval(interval = interval, timeUnit = TimeUnit.MINUTES), + retryConfiguration = + RetryConfiguration( + backoffCriteria = + BackoffCriteria( + backoffDelay = 10, + timeUnit = TimeUnit.SECONDS, + backoffPolicy = BackoffPolicy.EXPONENTIAL, + ), + maxRetries = 3, + ), ), ) .handlePeriodicSyncJobStatus(this) @@ -106,7 +124,7 @@ constructor( this.onEach { syncListenerManager.onSyncListeners.forEach { onSyncListener -> onSyncListener.onSync( - if (it.lastSyncJobStatus != null) { + if (it.lastSyncJobStatus as? LastSyncJobStatus.Succeeded != null) { CurrentSyncJobStatus.Succeeded((it.lastSyncJobStatus as LastSyncJobStatus).timestamp) } else { it.currentSyncJobStatus diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt index bc865b01c0..9015ef7a14 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt @@ -73,6 +73,15 @@ constructor( } } + fun registerSyncListener(onSyncListener: OnSyncListener) { + if (_onSyncListeners.find { it.get() == onSyncListener } == null) { + _onSyncListeners.add(WeakReference(onSyncListener)) + Timber.w("${onSyncListener::class.simpleName} registered to receive sync state events") + } + + _onSyncListeners.removeIf { it.get() == null } + } + /** * This function removes [onSyncListener] from the list of registered [OnSyncListener]'s to stop * receiving sync state events. diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt new file mode 100644 index 0000000000..d1e42d3824 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/NotificationConstants.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.util + +/** This class has method to help track, manage Notifications identifiers */ +object NotificationConstants { + + object NotificationId { + const val DATA_SYNC = 1 + } + + object ChannelId { + const val DATA_SYNC = "channel_id_datasync" + } + + object ChannelName { + const val DATA_SYNC = "Data sync" + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt index 78b46a65bb..331212708a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/ParallelUtil.kt @@ -16,9 +16,11 @@ package org.smartregister.fhircore.engine.util +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch /** * Launch a new coroutine for each map iteration using async. From @@ -26,18 +28,24 @@ import kotlinx.coroutines.coroutineScope * * @param A the type of elements in the iterable * @param B the type of elements returned by the function + * @param dispatcher dispatcher that creates the async coroutine * @param f the function to apply to the elements * @return the resulting list after apply *f* to the elements of the iterable */ -suspend fun Iterable.pmap(f: suspend (A) -> B): Iterable = coroutineScope { - map { async { f(it) } }.awaitAll() -} +suspend fun Iterable.pmap(dispatcher: CoroutineDispatcher, f: suspend (A) -> B): List = + coroutineScope { + map { async(dispatcher) { f(it) } }.awaitAll() + } /** - * Launch a new coroutine for each loop iteration using async. + * Launch a new coroutine for each loop iteration using launch and the specified Dispatcher for + * computationaly intensive tasks. * * @param T the type of elements in the iterable + * @param dispatcher dispatcher that creates the async coroutine + * @param action the function to apply to the elements */ -suspend fun Iterable.forEachAsync(action: suspend (T) -> Unit): Unit = coroutineScope { - forEach { async { action(it) } } -} +suspend fun Iterable.forEachAsync( + dispatcher: CoroutineDispatcher, + action: suspend (T) -> Unit, +): Unit = coroutineScope { forEach { launch(dispatcher) { action(it) } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt index 9b5b5e0432..99fa88f93b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt @@ -34,4 +34,6 @@ enum class SharedPreferenceKey { ORGANIZATION, GEO_LOCATION, SELECTED_LOCATION_ID, + SYNC_START_TIMESTAMP, + SYNC_END_TIMESTAMP, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index fe502ab93e..4b21562dcc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt @@ -18,7 +18,6 @@ package org.smartregister.fhircore.engine.util.extension import android.content.Context import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.gclient.ReferenceClientParam import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.get @@ -114,23 +113,20 @@ fun Base?.valueToString(datePattern: String = "dd-MMM-yyyy"): String { fun CodeableConcept.stringValue(): String = this.text ?: this.codingFirstRep.display ?: this.codingFirstRep.code -fun Resource.encodeResourceToString( - parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), -): String = parser.encodeResourceToString(this.copy()) +fun Resource.encodeResourceToString(): String = + FhirContext.forR4().getCustomJsonParser().encodeResourceToString(this.copy()) -fun StructureMap.encodeResourceToString( - parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), -): String = - parser +fun StructureMap.encodeResourceToString(): String = + FhirContext.forR4() + .getCustomJsonParser() .encodeResourceToString(this) .replace("'months'", "\\\\'months\\\\'") .replace("'days'", "\\\\'days\\\\'") .replace("'years'", "\\\\'years\\\\'") .replace("'weeks'", "\\\\'weeks\\\\'") -fun String.decodeResourceFromString( - parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), -): T = parser.parseResource(this) as T +fun String.decodeResourceFromString(): T = + FhirContext.forR4().getCustomJsonParser().parseResource(this) as T fun T.updateFrom(updatedResource: Resource): T { var extensionUpdateFrom = listOf() @@ -141,38 +137,40 @@ fun T.updateFrom(updatedResource: Resource): T { if (this is Patient) { extension = this.extension } - val jsonParser = FhirContext.forR4Cached().getCustomJsonParser() - val stringJson = encodeResourceToString(jsonParser) + val stringJson = encodeResourceToString() val originalResourceJson = JSONObject(stringJson) - originalResourceJson.updateFrom(JSONObject(updatedResource.encodeResourceToString(jsonParser))) - return jsonParser.parseResource(this::class.java, originalResourceJson.toString()).apply { - val meta = this.meta - val metaUpdateFrom = this@updateFrom.meta - if ((meta == null || meta.isEmpty)) { - if (metaUpdateFrom != null) { - this.meta = metaUpdateFrom - this.meta.tag = metaUpdateFrom.tag - } - } else { - val setOfTags = mutableSetOf() - setOfTags.addAll(meta.tag) - setOfTags.addAll(metaUpdateFrom.tag) - this.meta.tag = setOfTags.distinctBy { it.code + it.system } - } - if (this is Patient && this@updateFrom is Patient && updatedResource is Patient) { - if (extension.isEmpty()) { - if (extensionUpdateFrom.isNotEmpty()) { - this.extension = extensionUpdateFrom + originalResourceJson.updateFrom(JSONObject(updatedResource.encodeResourceToString())) + return FhirContext.forR4() + .getCustomJsonParser() + .parseResource(this::class.java, originalResourceJson.toString()) + .apply { + val meta = this.meta + val metaUpdateFrom = this@updateFrom.meta + if ((meta == null || meta.isEmpty)) { + if (metaUpdateFrom != null) { + this.meta = metaUpdateFrom + this.meta.tag = metaUpdateFrom.tag } } else { - val setOfExtension = mutableSetOf() - setOfExtension.addAll(extension) - setOfExtension.addAll(extensionUpdateFrom) - this.extension = setOfExtension.distinct() + val setOfTags = mutableSetOf() + setOfTags.addAll(meta.tag) + setOfTags.addAll(metaUpdateFrom.tag) + this.meta.tag = setOfTags.distinctBy { it.code + it.system } + } + if (this is Patient && this@updateFrom is Patient && updatedResource is Patient) { + if (extension.isEmpty()) { + if (extensionUpdateFrom.isNotEmpty()) { + this.extension = extensionUpdateFrom + } + } else { + val setOfExtension = mutableSetOf() + setOfExtension.addAll(extension) + setOfExtension.addAll(extensionUpdateFrom) + this.extension = setOfExtension.distinct() + } } } - } } @Throws(JSONException::class) @@ -439,7 +437,7 @@ fun Composition.retrieveCompositionSections(): List = - FhirContext.forR4Cached().getResourceDefinition(this).implementingClass as Class + FhirContext.forR4().getResourceDefinition(this).implementingClass as Class /** * A function that extracts only the UUID part of a resource logicalId. diff --git a/android/engine/src/main/res/drawable/ic_opensrp_small_logo.png b/android/engine/src/main/res/drawable/ic_opensrp_small_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0b490b2c67210054fc32ea0b022afe7024448bae GIT binary patch literal 1510 zcmeAS@N?(olHy`uVBq!ia0y~yVDtjw8ysvv5$*Pcz6=bkYdu{YLn`9lUQz55W@K=< zXxd@g(Zc$y!LW8j>kcnk63x^x0K#!1ugsOvqS3|-ShJ&LLc!OkJ)|C8Xng4A3J~I@w z>F+-LCGn>S>Cr_-3?sCxU3L5We6z!!U)NRC*)q;~_~Xap?`1!ygr-e7&^j8GWGCW7 zUweW`&Uc7dAuACqssHls@!MxFQ*YSsCerrar sesión como %1$s Sincronización completa Sincronización - Sincronizando - Sincronizando + Sincronizando… + Sincronizando… Sincronización iniciada… Error de sincronización. Verifique la conexión a Internet o vuelva a intentarlo más tarde Sincronización completada con errores. Reintentando… diff --git a/android/engine/src/main/res/values-fr/strings.xml b/android/engine/src/main/res/values-fr/strings.xml index c3ca7b188d..c6da757598 100644 --- a/android/engine/src/main/res/values-fr/strings.xml +++ b/android/engine/src/main/res/values-fr/strings.xml @@ -23,8 +23,8 @@ Se déconnecter en tant que %1$s Synchronisation terminée Synchronisation - Synchronisation - Synchronisation en cours + Synchronisation… + Synchronisation en cours… Synchronisation initiée… La synchronisation a échoué. Vérifier la connexion internet ou réessayer plus tard La synchronisation s\'est terminée avec des erreurs. Réessayer... diff --git a/android/engine/src/main/res/values-in/strings.xml b/android/engine/src/main/res/values-in/strings.xml index 4200c79864..9e674f4fc5 100644 --- a/android/engine/src/main/res/values-in/strings.xml +++ b/android/engine/src/main/res/values-in/strings.xml @@ -20,8 +20,8 @@ Keluar sebagai %1$s Sinkronisasi selesai Sinkronisasi - Menyinkronkan - Menyinkronkan + Menyinkronkan… + Menyinkronkan… Sinkronisasi dimulai… Sinkronisasi gagal. Periksa koneksi internet atau coba lagi nanti Sinkronisasi selesai dengan kesalahan. Mencoba lagi… diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index e22a4b54f1..9086e4a8a8 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Manual Sync + Manual sync Sync Language Log out as @@ -24,8 +24,8 @@ Log out as %1$s Sync complete Syncing - Syncing up - Syncing down + Syncing up… + Syncing down… Sync initiated… Sync failed. Check internet connection or try again later Sync completed with errors. Retrying… diff --git a/android/engine/src/main/res/values/styles.xml b/android/engine/src/main/res/values/styles.xml index eb95cda512..755e04226e 100644 --- a/android/engine/src/main/res/values/styles.xml +++ b/android/engine/src/main/res/values/styles.xml @@ -84,6 +84,7 @@ @color/colorPrimary +