diff --git a/CHANGELOG.md b/CHANGELOG.md index 77c2f0400f5..e45a34c9c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ - Add effect to fetch complete medical records - Bump sqlcipher to v4.13.0 - Bump dagger to v2.59.1 +- Bump Mockito Kotlin to v6.2.3 +- Bump Sentry to v8.32.0 +- Bump Sentry Android to v6.0.0 +- Bump KSP to v2.3.5 +- Bump AndroidX Camera to v1.5.3 +- Bump AndroidX Camera View to v1.5.3 +- Bump AndroidX Work to v2.11.1 +- Bump AndroidX Activity to v1.12.4 +- Bump Compose BOM to v2026.02.00 +- Bump Kotlin to v2.3.10 +- Bump AGP to v9.0.1 +- Bump Lint to v32.0.1 + +### Changes + +- Add `Sync Medical Records` button on setting page behind feature flag ## 2026.02.02 diff --git a/app/src/androidTest/java/org/simple/clinic/settings/SettingsScreenTest.kt b/app/src/androidTest/java/org/simple/clinic/settings/SettingsScreenTest.kt index 223a183550a..c63989157fc 100644 --- a/app/src/androidTest/java/org/simple/clinic/settings/SettingsScreenTest.kt +++ b/app/src/androidTest/java/org/simple/clinic/settings/SettingsScreenTest.kt @@ -18,6 +18,7 @@ class SettingsScreenTest { private val defaultSettingsModel = SettingsModel.default( isChangeLanguageFeatureEnabled = true, + showDiagnosisButton = true ) @Test @@ -30,7 +31,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -58,7 +60,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -78,7 +81,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -97,7 +101,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -115,7 +120,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -133,7 +139,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -150,7 +157,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -168,7 +176,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -186,7 +195,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -198,6 +208,7 @@ class SettingsScreenTest { fun whenChangeLanguageFeatureIsNotEnabledThenDoNotShowChangeLanguageSetting() { val model = SettingsModel.default( isChangeLanguageFeatureEnabled = false, + showDiagnosisButton = true ) composeRule.setContent { SettingsScreen( @@ -205,7 +216,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -217,6 +229,7 @@ class SettingsScreenTest { fun whenChangeLanguageFeatureIsEnabledThenShowChangeLanguageSetting() { val model = SettingsModel.default( isChangeLanguageFeatureEnabled = true, + showDiagnosisButton = true, ) composeRule.setContent { SettingsScreen( @@ -224,7 +237,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } @@ -236,6 +250,7 @@ class SettingsScreenTest { fun logoutButtonShouldBeVisible() { val model = SettingsModel.default( isChangeLanguageFeatureEnabled = true, + showDiagnosisButton = true ) composeRule.setContent { SettingsScreen( @@ -243,7 +258,8 @@ class SettingsScreenTest { navigationIconClick = { /*no-op*/ }, changeLanguageButtonClick = { /*no-op*/ }, updateButtonClick = { /*no-op*/ }, - logoutButtonClick = { /*no-op*/ } + logoutButtonClick = { /*no-op*/ }, + syncMedicalRecordClick = { /*no-op*/ } ) } diff --git a/app/src/main/java/org/simple/clinic/feature/Feature.kt b/app/src/main/java/org/simple/clinic/feature/Feature.kt index a08845d928a..e08e558b7aa 100644 --- a/app/src/main/java/org/simple/clinic/feature/Feature.kt +++ b/app/src/main/java/org/simple/clinic/feature/Feature.kt @@ -26,5 +26,6 @@ enum class Feature( PatientStatinNudge(false, "patient_statin_nudge_v0"), NonLabBasedStatinNudge(false, "non_lab_based_statin_nudge"), LabBasedStatinNudge(false, "lab_based_statin_nudge"), - Screening(false, "screening_feature_v0") + Screening(false, "screening_feature_v0"), + ShowDiagnosisButton(false, "show_diagnosis_button"), } diff --git a/app/src/main/java/org/simple/clinic/patient/PatientRepository.kt b/app/src/main/java/org/simple/clinic/patient/PatientRepository.kt index ab79dac98a2..c089b48943a 100644 --- a/app/src/main/java/org/simple/clinic/patient/PatientRepository.kt +++ b/app/src/main/java/org/simple/clinic/patient/PatientRepository.kt @@ -743,26 +743,30 @@ class PatientRepository @Inject constructor( fun fetchCompleteMedicalRecord(): List { val patientProfiles = database.patientDao().allPatientProfiles() - return patientProfiles.map { patientProfile -> - val patientUuid = patientProfile.patientUuid - val medicalHistory = database.medicalHistoryDao().historyForPatientImmediate(patientUuid) - val appointments = database.appointmentDao().getAllAppointmentsForPatient(patientUuid) - val bloodPressures = database.bloodPressureDao().allBloodPressuresRecordedSinceImmediate( - patientUuid, - Instant.EPOCH - ) - val bloodSugars = database.bloodSugarDao().allBloodSugarsImmediate(patientUuid) + return patientProfiles.mapNotNull { patientProfile -> + try { + val patientUuid = patientProfile.patientUuid + val medicalHistory = database.medicalHistoryDao().historyForPatientImmediate(patientUuid) + val appointments = database.appointmentDao().getAllAppointmentsForPatient(patientUuid) + val bloodPressures = database.bloodPressureDao().allBloodPressuresRecordedSinceImmediate( + patientUuid, + Instant.EPOCH + ) + val bloodSugars = database.bloodSugarDao().allBloodSugarsImmediate(patientUuid) - val prescribedDrugs = database.prescriptionDao().forPatientImmediate(patientUuid) + val prescribedDrugs = database.prescriptionDao().forPatientImmediate(patientUuid) - CompleteMedicalRecord( - patient = patientProfile, - medicalHistory = medicalHistory, - appointments = appointments, - bloodPressures = bloodPressures, - bloodSugars = bloodSugars, - prescribedDrugs = prescribedDrugs - ) + CompleteMedicalRecord( + patient = patientProfile, + medicalHistory = medicalHistory, + appointments = appointments, + bloodPressures = bloodPressures, + bloodSugars = bloodSugars, + prescribedDrugs = prescribedDrugs + ) + } catch (_: Exception) { + null + } } } diff --git a/app/src/main/java/org/simple/clinic/patient/medicalRecords/CompleteMedicalRecordsPushRequest.kt b/app/src/main/java/org/simple/clinic/patient/medicalRecords/CompleteMedicalRecordsPushRequest.kt new file mode 100644 index 00000000000..94f129d765a --- /dev/null +++ b/app/src/main/java/org/simple/clinic/patient/medicalRecords/CompleteMedicalRecordsPushRequest.kt @@ -0,0 +1,13 @@ +package org.simple.clinic.patient.medicalRecords + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.simple.clinic.patient.CompleteMedicalRecord +import org.simple.clinic.patient.onlinelookup.api.CompleteMedicalRecordPayload + +@JsonClass(generateAdapter = true) +data class CompleteMedicalRecordsPushRequest( + + @Json(name = "patients") + val patients: List +) diff --git a/app/src/main/java/org/simple/clinic/patient/medicalRecords/PushMedicalRecordsOnline.kt b/app/src/main/java/org/simple/clinic/patient/medicalRecords/PushMedicalRecordsOnline.kt new file mode 100644 index 00000000000..a599e332923 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/patient/medicalRecords/PushMedicalRecordsOnline.kt @@ -0,0 +1,128 @@ +package org.simple.clinic.patient.medicalRecords + +import org.simple.clinic.patient.CompleteMedicalRecord +import org.simple.clinic.patient.onlinelookup.api.CompleteMedicalRecordPayload +import org.simple.clinic.patient.onlinelookup.api.RecordRetention +import org.simple.clinic.patient.onlinelookup.api.RetentionType +import org.simple.clinic.patient.onlinelookup.api.SecondsDuration +import org.simple.clinic.patient.sync.PatientSyncApi +import org.simple.clinic.util.UtcClock +import java.time.Duration +import java.time.Instant +import javax.inject.Inject + +class PushMedicalRecordsOnline @Inject constructor( + private val patientSyncApi: PatientSyncApi, + private val clock: UtcClock, +) { + fun pushAllMedicalRecordsOnServer( + medicalRecords: List + ): Result { + + if (medicalRecords.isEmpty()) { + return Result.NothingToPush + } + + val request = CompleteMedicalRecordsPushRequest( + patients = medicalRecords.map { mapToPayload(it) } + ) + + return try { + val response = patientSyncApi + .pushAllPatientsData(request) + .execute() + + return when (response.code()) { + 200 -> Result.Success + else -> Result.ServerError( + code = response.code(), + message = response.errorBody()?.string() + ) + } + + } catch (e: Exception) { + Result.NetworkError(e) + } + } + + + fun mapToPayload( + completeMedicalRecord: CompleteMedicalRecord + ): CompleteMedicalRecordPayload { + + val patientProfile = completeMedicalRecord.patient + val patient = patientProfile.patient + + return CompleteMedicalRecordPayload( + id = patient.uuid, + fullName = patient.fullName, + gender = patient.gender, + dateOfBirth = patient.ageDetails.dateOfBirth, + age = patient.ageDetails.ageValue, + ageUpdatedAt = patient.ageDetails.ageUpdatedAt, + status = patient.status, + createdAt = patient.createdAt, + updatedAt = patient.updatedAt, + deletedAt = patient.deletedAt, + + address = patientProfile.address.toPayload(), + + phoneNumbers = patientProfile.phoneNumbers + .map { it.toPayload() }, + + businessIds = patientProfile.businessIds + .map { it.toPayload() }, + + recordedAt = patient.recordedAt, + + reminderConsent = patient.reminderConsent, + + deletedReason = patient.deletedReason, + + registeredFacilityId = patient.registeredFacilityId, + + assignedFacilityId = patient.assignedFacilityId, + + appointments = completeMedicalRecord.appointments + .map { it.toPayload() }, + + bloodPressures = completeMedicalRecord.bloodPressures + .map { it.toPayload() }, + + bloodSugars = completeMedicalRecord.bloodSugars + .map { it.toPayload() }, + + medicalHistory = completeMedicalRecord.medicalHistory + ?.toPayload(), + + prescribedDrugs = completeMedicalRecord.prescribedDrugs + .map { it.toPayload() }, + + retention = patient.retainUntil?.let { retainUntil -> + val duration = Duration.between( + Instant.now(clock), + retainUntil + ).coerceAtLeast(Duration.ZERO) + + RecordRetention( + type = RetentionType.Temporary, + retainFor = SecondsDuration(duration) + ) + } ?: RecordRetention( + type = RetentionType.Permanent, + retainFor = null + ) + ) + } + + sealed class Result { + data object Success : Result() + data object NothingToPush : Result() + data class ServerError( + val code: Int, + val message: String? + ) : Result() + + data class NetworkError(val throwable: Throwable) : Result() + } +} diff --git a/app/src/main/java/org/simple/clinic/patient/sync/PatientSyncApi.kt b/app/src/main/java/org/simple/clinic/patient/sync/PatientSyncApi.kt index 2ca97df5f98..186ed874fae 100644 --- a/app/src/main/java/org/simple/clinic/patient/sync/PatientSyncApi.kt +++ b/app/src/main/java/org/simple/clinic/patient/sync/PatientSyncApi.kt @@ -1,6 +1,7 @@ package org.simple.clinic.patient.sync import org.simple.clinic.di.network.Timeout +import org.simple.clinic.patient.medicalRecords.CompleteMedicalRecordsPushRequest import org.simple.clinic.patient.onlinelookup.api.OnlineLookupResponsePayload import org.simple.clinic.patient.onlinelookup.api.PatientOnlineLookupRequest import org.simple.clinic.sync.DataPushResponse @@ -31,4 +32,9 @@ interface PatientSyncApi { fun lookup( @Body body: PatientOnlineLookupRequest ): Call + + @POST("v4/legacy_data_dumps") + fun pushAllPatientsData( + @Body body: CompleteMedicalRecordsPushRequest + ): Call } diff --git a/app/src/main/java/org/simple/clinic/settings/SettingsEffect.kt b/app/src/main/java/org/simple/clinic/settings/SettingsEffect.kt index 57c230f2935..0de8ff9fcb0 100644 --- a/app/src/main/java/org/simple/clinic/settings/SettingsEffect.kt +++ b/app/src/main/java/org/simple/clinic/settings/SettingsEffect.kt @@ -1,6 +1,6 @@ package org.simple.clinic.settings -import org.simple.clinic.scanid.ScanSimpleIdEffect +import org.simple.clinic.patient.CompleteMedicalRecord sealed class SettingsEffect @@ -28,3 +28,7 @@ data object GoBack : SettingsViewEffect() data object FetchCompleteMedicalRecords : SettingsEffect() +data class PushCompleteMedicalRecordsOnline( + val medicalRecords: List +) : SettingsEffect() + diff --git a/app/src/main/java/org/simple/clinic/settings/SettingsEffectHandler.kt b/app/src/main/java/org/simple/clinic/settings/SettingsEffectHandler.kt index 92a6ff2cce3..3f348b859e2 100644 --- a/app/src/main/java/org/simple/clinic/settings/SettingsEffectHandler.kt +++ b/app/src/main/java/org/simple/clinic/settings/SettingsEffectHandler.kt @@ -9,6 +9,7 @@ import io.reactivex.ObservableTransformer import org.simple.clinic.appupdate.AppUpdateState import org.simple.clinic.appupdate.CheckAppUpdateAvailability import org.simple.clinic.patient.PatientRepository +import org.simple.clinic.patient.medicalRecords.PushMedicalRecordsOnline import org.simple.clinic.storage.DatabaseEncryptor import org.simple.clinic.user.UserSession import org.simple.clinic.util.filterAndUnwrapJust @@ -22,6 +23,7 @@ class SettingsEffectHandler @AssistedInject constructor( private val appVersionFetcher: AppVersionFetcher, private val appUpdateAvailability: CheckAppUpdateAvailability, private val databaseEncryptor: DatabaseEncryptor, + private val pushMedicalRecordsOnline: PushMedicalRecordsOnline, @Assisted private val viewEffectsConsumer: Consumer ) { @@ -42,6 +44,7 @@ class SettingsEffectHandler @AssistedInject constructor( .addTransformer(LogoutUser::class.java, logoutUser()) .addTransformer(LoadDatabaseEncryptionStatus::class.java, loadDatabaseEncryptionStatus()) .addTransformer(FetchCompleteMedicalRecords::class.java, fetchCompleteMedicalRecords()) + .addTransformer(PushCompleteMedicalRecordsOnline::class.java, pushCompleteMedicalRecordsOnline()) .build() private fun loadDatabaseEncryptionStatus(): ObservableTransformer { @@ -109,4 +112,15 @@ class SettingsEffectHandler @AssistedInject constructor( .map(::MedicalRecordsFetched) } } + + private fun pushCompleteMedicalRecordsOnline(): ObservableTransformer { + return ObservableTransformer { effects -> + effects + .observeOn(schedulersProvider.io()) + .map { + val results = pushMedicalRecordsOnline.pushAllMedicalRecordsOnServer(it.medicalRecords) + PushMedicalRecordsOnlineCompleted(results) + } + } + } } diff --git a/app/src/main/java/org/simple/clinic/settings/SettingsEvent.kt b/app/src/main/java/org/simple/clinic/settings/SettingsEvent.kt index 9198e0dc94a..b3fe1a5e58a 100644 --- a/app/src/main/java/org/simple/clinic/settings/SettingsEvent.kt +++ b/app/src/main/java/org/simple/clinic/settings/SettingsEvent.kt @@ -1,7 +1,7 @@ package org.simple.clinic.settings import org.simple.clinic.patient.CompleteMedicalRecord -import org.simple.clinic.scanid.ScanSimpleIdEvent +import org.simple.clinic.patient.medicalRecords.PushMedicalRecordsOnline import org.simple.clinic.user.UserSession import org.simple.clinic.widgets.UiEvent @@ -35,6 +35,14 @@ data object BackClicked : SettingsEvent() { data class DatabaseEncryptionStatusLoaded(val isDatabaseEncrypted: Boolean) : SettingsEvent() +data object PushAllMedicalRecordsClicked : SettingsEvent() { + override val analyticsName: String = "Settings:Push all medical records Clicked" +} + data class MedicalRecordsFetched( val completeMedicalRecords: List ) : SettingsEvent() + +data class PushMedicalRecordsOnlineCompleted( + val result: PushMedicalRecordsOnline.Result, +) : SettingsEvent() diff --git a/app/src/main/java/org/simple/clinic/settings/SettingsModel.kt b/app/src/main/java/org/simple/clinic/settings/SettingsModel.kt index 9437b662296..0cb71c9ffc8 100644 --- a/app/src/main/java/org/simple/clinic/settings/SettingsModel.kt +++ b/app/src/main/java/org/simple/clinic/settings/SettingsModel.kt @@ -12,11 +12,14 @@ data class SettingsModel( val isUpdateAvailable: Boolean?, val isUserLoggingOut: Boolean?, val isDatabaseEncrypted: Boolean?, + val isPushingMedicalRecords: Boolean?, val isChangeLanguageFeatureEnabled: Boolean, -) : Parcelable { + val showDiagnosisButton: Boolean, + + ) : Parcelable { companion object { - fun default(isChangeLanguageFeatureEnabled: Boolean) = SettingsModel( + fun default(isChangeLanguageFeatureEnabled: Boolean, showDiagnosisButton: Boolean) = SettingsModel( name = null, phoneNumber = null, currentLanguage = null, @@ -24,7 +27,9 @@ data class SettingsModel( isUpdateAvailable = null, isUserLoggingOut = null, isDatabaseEncrypted = null, + isPushingMedicalRecords = null, isChangeLanguageFeatureEnabled = isChangeLanguageFeatureEnabled, + showDiagnosisButton = showDiagnosisButton ) } @@ -37,6 +42,9 @@ data class SettingsModel( val appVersionQueried: Boolean get() = appVersion != null + val isMedicalRecordsPushInProgress: Boolean + get() = isPushingMedicalRecords == true + fun userDetailsFetched(name: String, phoneNumber: String): SettingsModel { return copy(name = name, phoneNumber = phoneNumber) } @@ -68,4 +76,12 @@ data class SettingsModel( fun databaseEncryptionStatusLoaded(isDatabaseEncrypted: Boolean): SettingsModel { return copy(isDatabaseEncrypted = isDatabaseEncrypted) } + + fun medicalRecordsPushStarted(): SettingsModel { + return copy(isPushingMedicalRecords = true) + } + + fun medicalRecordsPushCompleted(): SettingsModel { + return copy(isPushingMedicalRecords = false) + } } diff --git a/app/src/main/java/org/simple/clinic/settings/SettingsScreen.kt b/app/src/main/java/org/simple/clinic/settings/SettingsScreen.kt index 7a4c065db70..a2eeaf0933d 100644 --- a/app/src/main/java/org/simple/clinic/settings/SettingsScreen.kt +++ b/app/src/main/java/org/simple/clinic/settings/SettingsScreen.kt @@ -43,11 +43,16 @@ class SettingsScreen : Fragment(), UiActions, HandlesBack { features.isEnabled(Feature.ChangeLanguage) } + private val showDiagnosisButton by unsafeLazy { + features.isEnabled(Feature.ShowDiagnosisButton) + } + private val viewEffectHandler by unsafeLazy { SettingsViewEffectHandler(this) } private val viewModel by mobiusViewModels( defaultModel = { SettingsModel.default( isChangeLanguageFeatureEnabled = isChangeLanguageFeatureEnabled, + showDiagnosisButton = showDiagnosisButton ) }, init = { SettingsInit() }, @@ -73,7 +78,8 @@ class SettingsScreen : Fragment(), UiActions, HandlesBack { navigationIconClick = { onBackPressed() }, changeLanguageButtonClick = { viewModel.dispatchEvent(ChangeLanguage) }, updateButtonClick = { launchPlayStoreForUpdate() }, - logoutButtonClick = { viewModel.dispatchEvent(LogoutButtonClicked) } + logoutButtonClick = { viewModel.dispatchEvent(LogoutButtonClicked) }, + syncMedicalRecordClick = { viewModel.dispatchEvent(PushAllMedicalRecordsClicked) }, ) } } diff --git a/app/src/main/java/org/simple/clinic/settings/SettingsUI.kt b/app/src/main/java/org/simple/clinic/settings/SettingsUI.kt index 1dad1517aa5..ed6c9f9542b 100644 --- a/app/src/main/java/org/simple/clinic/settings/SettingsUI.kt +++ b/app/src/main/java/org/simple/clinic/settings/SettingsUI.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -42,6 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.simple.clinic.R +import org.simple.clinic.common.ui.components.FilledButton import org.simple.clinic.common.ui.components.OutlinedButton import org.simple.clinic.common.ui.components.TopAppBar import org.simple.clinic.common.ui.theme.SimpleRedTheme @@ -54,6 +54,7 @@ fun SettingsScreen( changeLanguageButtonClick: () -> Unit, updateButtonClick: () -> Unit, logoutButtonClick: () -> Unit, + syncMedicalRecordClick: () -> Unit, modifier: Modifier = Modifier, ) { SimpleTheme { @@ -80,11 +81,12 @@ fun SettingsScreen( paddingValues = paddingValues, changeLanguageButtonClick = changeLanguageButtonClick, updateButtonClick = updateButtonClick, - logoutButtonClick = logoutButtonClick + logoutButtonClick = logoutButtonClick, + syncMedicalRecordClick = syncMedicalRecordClick ) } - if (model.isUserLoggingOut == true) { + if (model.isUserLoggingOut == true || model.isMedicalRecordsPushInProgress) { // Scrim Box( modifier = Modifier @@ -106,7 +108,8 @@ private fun SettingsList( paddingValues: PaddingValues, changeLanguageButtonClick: () -> Unit, updateButtonClick: () -> Unit, - logoutButtonClick: () -> Unit + logoutButtonClick: () -> Unit, + syncMedicalRecordClick: () -> Unit, ) { LazyColumn( modifier = Modifier @@ -257,6 +260,17 @@ private fun SettingsList( logout = logoutButtonClick ) } + + if (model.showDiagnosisButton) { + item { + SyncMedicalRecordsButton( + modifier = Modifier + .padding(top = 48.dp) + .testTag("SETTINGS_LOGOUT_BUTTON"), + syncMedicalRecords = syncMedicalRecordClick + ) + } + } } } @@ -337,6 +351,25 @@ private fun LogoutButton( } } +@Composable +private fun SyncMedicalRecordsButton( + modifier: Modifier = Modifier, + syncMedicalRecords: () -> Unit +) { + Box(modifier) { + SimpleTheme { + FilledButton( + onClick = syncMedicalRecords, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text(text = stringResource(id = R.string.settings_sync_medical_records).uppercase()) + } + } + } +} + private val previewSettingsModel = SettingsModel( name = "Riya Murthy", phoneNumber = "1111111111", @@ -345,7 +378,9 @@ private val previewSettingsModel = SettingsModel( isUpdateAvailable = true, isUserLoggingOut = null, isDatabaseEncrypted = true, + isPushingMedicalRecords = false, isChangeLanguageFeatureEnabled = true, + showDiagnosisButton = true, ) @Preview @@ -384,6 +419,9 @@ private fun SettingsScreenContentPreview() { }, logoutButtonClick = { // no-op + }, + syncMedicalRecordClick = { + // no-op } ) } diff --git a/app/src/main/java/org/simple/clinic/settings/SettingsUpdate.kt b/app/src/main/java/org/simple/clinic/settings/SettingsUpdate.kt index f0b43ba21a4..f8f36965144 100644 --- a/app/src/main/java/org/simple/clinic/settings/SettingsUpdate.kt +++ b/app/src/main/java/org/simple/clinic/settings/SettingsUpdate.kt @@ -27,7 +27,9 @@ class SettingsUpdate : Update { model.databaseEncryptionStatusLoaded(isDatabaseEncrypted = event.isDatabaseEncrypted) ) - is MedicalRecordsFetched -> noChange() + is PushAllMedicalRecordsClicked -> next(model.medicalRecordsPushStarted(), FetchCompleteMedicalRecords) + is MedicalRecordsFetched -> dispatch(PushCompleteMedicalRecordsOnline(event.completeMedicalRecords)) + is PushMedicalRecordsOnlineCompleted -> next(model.medicalRecordsPushCompleted()) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 566687d8917..bb1e87ffe87 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -483,6 +483,7 @@ Update Version %1$s Logout + Sync medical records Logout Are you sure you want to logout? Yes diff --git a/app/src/test/java/org/simple/clinic/patient/medicalRecords/PushMedicalRecordsOnlineTest.kt b/app/src/test/java/org/simple/clinic/patient/medicalRecords/PushMedicalRecordsOnlineTest.kt new file mode 100644 index 00000000000..066cb9c2ba2 --- /dev/null +++ b/app/src/test/java/org/simple/clinic/patient/medicalRecords/PushMedicalRecordsOnlineTest.kt @@ -0,0 +1,122 @@ +package org.simple.clinic.patient.medicalRecords + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.simple.clinic.TestData +import org.simple.clinic.patient.CompleteMedicalRecord +import org.simple.clinic.patient.sync.PatientSyncApi +import org.simple.clinic.sync.DataPushResponse +import org.simple.clinic.util.TestUtcClock +import retrofit2.Call +import retrofit2.Response +import java.time.Instant + +class PushMedicalRecordsOnlineTest { + + private val patientSyncApi = mock() + + private val clock = TestUtcClock( + Instant.parse("2018-01-01T00:00:00Z") + ) + + private val pushMedicalRecordsOnline = PushMedicalRecordsOnline( + patientSyncApi = patientSyncApi, + clock = clock, + ) + + + private fun mockCall(): Call = mock() + + private fun fakeMedicalRecord(): CompleteMedicalRecord = TestData.completeMedicalRecord() + + + @Test + fun `when no medical records then return NothingToPush and do not call api`() { + val result = pushMedicalRecordsOnline + .pushAllMedicalRecordsOnServer(emptyList()) + + assertEquals( + PushMedicalRecordsOnline.Result.NothingToPush, + result + ) + + verify(patientSyncApi, org.mockito.kotlin.never()) + .pushAllPatientsData(any()) + } + + @Test + fun `when server returns 200 then return Success`() { + val call = mockCall() + + whenever(patientSyncApi.pushAllPatientsData(any())) + .thenReturn(call) + + whenever(call.execute()) + .thenReturn( + Response.success( + DataPushResponse( + validationErrors = emptyList() + ) + ) + ) + + val result = pushMedicalRecordsOnline.pushAllMedicalRecordsOnServer( + listOf(fakeMedicalRecord()) + ) + + assertEquals( + PushMedicalRecordsOnline.Result.Success, + result + ) + + verify(patientSyncApi).pushAllPatientsData(any()) + } + + @Test + fun `when server returns non-200 then return ServerError`() { + val call = mockCall() + + whenever(patientSyncApi.pushAllPatientsData(any())) + .thenReturn(call) + + whenever(call.execute()) + .thenReturn( + Response.error( + 500, + "boom".toResponseBody("text/plain".toMediaType()) + ) + ) + + val result = pushMedicalRecordsOnline.pushAllMedicalRecordsOnServer( + listOf(fakeMedicalRecord()) + ) + + result as PushMedicalRecordsOnline.Result.ServerError + + assertEquals(500, result.code) + assertEquals("boom", result.message) + } + + @Test + fun `when api throws exception then return NetworkError`() { + val call = mockCall() + + whenever(patientSyncApi.pushAllPatientsData(any())) + .thenReturn(call) + + whenever(call.execute()) + .thenThrow(RuntimeException("network down")) + + val result = pushMedicalRecordsOnline.pushAllMedicalRecordsOnServer( + listOf(fakeMedicalRecord()) + ) + + assert(result is PushMedicalRecordsOnline.Result.NetworkError) + } +} diff --git a/app/src/test/java/org/simple/clinic/settings/SettingsEffectHandlerTest.kt b/app/src/test/java/org/simple/clinic/settings/SettingsEffectHandlerTest.kt index fe07c03ed76..ff36836834c 100644 --- a/app/src/test/java/org/simple/clinic/settings/SettingsEffectHandlerTest.kt +++ b/app/src/test/java/org/simple/clinic/settings/SettingsEffectHandlerTest.kt @@ -18,6 +18,7 @@ import org.simple.clinic.mobius.EffectHandlerTestCase import org.simple.clinic.patient.PatientRepository import org.simple.clinic.patient.businessid.Identifier import org.simple.clinic.patient.businessid.Identifier.IdentifierType.BpPassport +import org.simple.clinic.patient.medicalRecords.PushMedicalRecordsOnline import org.simple.clinic.storage.DatabaseEncryptor import org.simple.clinic.user.User import org.simple.clinic.user.UserSession @@ -38,6 +39,8 @@ class SettingsEffectHandlerTest { private val patientRepository = mock() + private val pushMedicalRecordsOnline = mock() + private val effectHandler = SettingsEffectHandler( userSession = userSession, settingsRepository = settingsRepository, @@ -46,7 +49,8 @@ class SettingsEffectHandlerTest { appVersionFetcher = appVersionFetcher, appUpdateAvailability = checkAppUpdateAvailability, databaseEncryptor = databaseEncryptor, - viewEffectsConsumer = SettingsViewEffectHandler(uiActions)::handle + viewEffectsConsumer = SettingsViewEffectHandler(uiActions)::handle, + pushMedicalRecordsOnline = pushMedicalRecordsOnline ).build() private val testCase = EffectHandlerTestCase(effectHandler) @@ -241,4 +245,28 @@ class SettingsEffectHandlerTest { testCase.assertOutgoingEvents(MedicalRecordsFetched(medicalRecords)) verifyNoMoreInteractions(uiActions) } + + @Test + fun `when push complete medical records online effect is received, then push all medical records online`() { + // given + val identifier = Identifier("4f1cea37-70ff-498e-bd09-ad0ca75628ff", BpPassport) + val commonIdentifier = TestData.businessId(identifier = identifier) + val patientUuid1 = TestData.patientProfile(patientUuid = UUID.fromString("0b78c024-f527-4306-9e20-6ae6d7251e9b"), businessId = commonIdentifier) + val patientUuid2 = TestData.patientProfile(patientUuid = UUID.fromString("47fdb968-9512-4e50-b95f-cc83c6de4b0a"), businessId = commonIdentifier) + + val completeMedicalRecord = TestData.completeMedicalRecord(patient = patientUuid1) + val completeMedicalRecord2 = TestData.completeMedicalRecord(patient = patientUuid2) + + val medicalRecords = listOf(completeMedicalRecord, completeMedicalRecord2) + + val results = PushMedicalRecordsOnline.Result.Success + whenever(pushMedicalRecordsOnline.pushAllMedicalRecordsOnServer(medicalRecords)) doReturn results + + // when + testCase.dispatch(PushCompleteMedicalRecordsOnline(medicalRecords)) + + // then + testCase.assertOutgoingEvents(PushMedicalRecordsOnlineCompleted(results)) + verifyNoMoreInteractions(uiActions) + } } diff --git a/app/src/test/java/org/simple/clinic/settings/SettingsInitTest.kt b/app/src/test/java/org/simple/clinic/settings/SettingsInitTest.kt index ca490caedd9..16663a7ba7f 100644 --- a/app/src/test/java/org/simple/clinic/settings/SettingsInitTest.kt +++ b/app/src/test/java/org/simple/clinic/settings/SettingsInitTest.kt @@ -9,7 +9,8 @@ import org.junit.Test class SettingsInitTest { private val defaultModel = SettingsModel.default( - isChangeLanguageFeatureEnabled = true + isChangeLanguageFeatureEnabled = true, + showDiagnosisButton = true, ) private val spec = InitSpec(SettingsInit()) diff --git a/app/src/test/java/org/simple/clinic/settings/SettingsUpdateTest.kt b/app/src/test/java/org/simple/clinic/settings/SettingsUpdateTest.kt index dc047520e7b..4ada8b959db 100644 --- a/app/src/test/java/org/simple/clinic/settings/SettingsUpdateTest.kt +++ b/app/src/test/java/org/simple/clinic/settings/SettingsUpdateTest.kt @@ -7,13 +7,18 @@ import com.spotify.mobius.test.NextMatchers.hasNoModel import com.spotify.mobius.test.UpdateSpec import com.spotify.mobius.test.UpdateSpec.assertThatNext import org.junit.Test +import org.simple.clinic.TestData +import org.simple.clinic.patient.businessid.Identifier +import org.simple.clinic.patient.businessid.Identifier.IdentifierType.BpPassport import org.simple.clinic.user.UserSession.LogoutResult.Failure import org.simple.clinic.user.UserSession.LogoutResult.Success +import java.util.UUID class SettingsUpdateTest { private val defaultModel = SettingsModel.default( isChangeLanguageFeatureEnabled = true, + showDiagnosisButton = true ) private val spec = UpdateSpec(SettingsUpdate()) @@ -152,13 +157,35 @@ class SettingsUpdateTest { } @Test - fun `when database encryption status is loaded, then update ui`() { + fun `when sync medical records button is clicked, then update model and fetch complete medical records`() { spec .given(defaultModel) - .whenEvent(DatabaseEncryptionStatusLoaded(isDatabaseEncrypted = true)) + .whenEvent(PushAllMedicalRecordsClicked) .then(assertThatNext( - hasModel(defaultModel.databaseEncryptionStatusLoaded(isDatabaseEncrypted = true)), - hasNoEffects() + hasModel(defaultModel.medicalRecordsPushStarted()), + hasEffects(FetchCompleteMedicalRecords) + )) + } + + @Test + fun `when complete medical records are fetch, then push them online`() { + // given + val identifier = Identifier("4f1cea37-70ff-498e-bd09-ad0ca75628ff", BpPassport) + val commonIdentifier = TestData.businessId(identifier = identifier) + val patientUuid1 = TestData.patientProfile(patientUuid = UUID.fromString("0b78c024-f527-4306-9e20-6ae6d7251e9b"), businessId = commonIdentifier) + val patientUuid2 = TestData.patientProfile(patientUuid = UUID.fromString("47fdb968-9512-4e50-b95f-cc83c6de4b0a"), businessId = commonIdentifier) + + val completeMedicalRecord = TestData.completeMedicalRecord(patient = patientUuid1) + val completeMedicalRecord2 = TestData.completeMedicalRecord(patient = patientUuid2) + + val medicalRecords = listOf(completeMedicalRecord, completeMedicalRecord2) + + spec + .given(defaultModel) + .whenEvent(MedicalRecordsFetched(medicalRecords)) + .then(assertThatNext( + hasNoModel(), + hasEffects(PushCompleteMedicalRecordsOnline(medicalRecords)) )) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea4245febfb..9bce57d85e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,26 +1,26 @@ [versions] -agp = "9.0.0" +agp = "9.0.1" -androidx-cameraView = "1.5.2" -androidx-camera = "1.5.2" +androidx-cameraView = "1.5.3" +androidx-camera = "1.5.3" androidx-paging = "3.3.6" androidx-room = "2.8.4" -androidx-work = "2.11.0" +androidx-work = "2.11.1" androidx-security-crypto = "1.1.0" androidx-viewmodel = "2.10.0" androidx-lifecycle = "2.10.0" -androidx-activity = "1.12.2" +androidx-activity = "1.12.4" chucker = "4.3.0" dagger = "2.59.1" -kotlin = "2.3.0" +kotlin = "2.3.10" -ksp = "2.3.4" +ksp = "2.3.5" ktlint = "0.36.0" -lint = "32.0.0" +lint = "32.0.1" mobius = "2.1.1" @@ -38,7 +38,7 @@ coroutines = "1.10.2" compose-compiler = "1.5.13" -androidx-compose-bom = "2026.01.00" +androidx-compose-bom = "2026.02.00" composeThemeAdapter = "0.36.0" @@ -142,7 +142,7 @@ lottie = "com.airbnb.android:lottie-compose:6.7.1" material = "com.google.android.material:material:1.13.0" -mockito-kotlin = "org.mockito.kotlin:mockito-kotlin:6.2.1" +mockito-kotlin = "org.mockito.kotlin:mockito-kotlin:6.2.3" mobius-android = { module = "com.spotify.mobius:mobius-android", version.ref = "mobius" } mobius-core = { module = "com.spotify.mobius:mobius-core", version.ref = "mobius" } @@ -179,7 +179,7 @@ rx-java = "io.reactivex.rxjava2:rxjava:2.2.21" rx-kotlin = "io.reactivex.rxjava2:rxkotlin:2.4.0" rx-preferences = "com.f2prateek.rx.preferences2:rx-preferences:2.0.1" -sentry-android = "io.sentry:sentry-android:8.30.0" +sentry-android = "io.sentry:sentry-android:8.32.0" signaturepad = "com.github.gcacace:signature-pad:1.3.1" @@ -229,7 +229,7 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } google-services = { id = "com.google.gms.google-services", version = "4.4.4" } -sentry = { id = "io.sentry.android.gradle", version = "5.12.2" } +sentry = { id = "io.sentry.android.gradle", version = "6.0.0" } [bundles] androidx-camera = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-view", "androidx-camera-lifecycle"]