diff --git a/CHANGELOG.md b/CHANGELOG.md index e45a34c9c03..728cb847334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ ### Internal +- Add `ReturnScore` table +- Add `ReturnScore` sync resource +- Bump Sentry Android to v6.2.0 +- Bump AndroidX Benchmark to v1.5.0-alpha04 +- Bump AndroidX Paging to v3.4.2 +- Bump Play Services Auth to v21.5.1 +- Bump Kotlin to v2.3.20 +- Bump KSP to v2.3.6 +- Bump Sentry to v8.36.0 +- Bump dagger to v2.59.2 +- Bump Jackson Core to v2.21.1 +- Bump Compose BOM to v2026.03.00 + +### Changes + +- Sort Overdue list based on return score when feature `sort_overdue_based_on_return_score` is enabled + +## 2026.03.02 + +### Internal + - Update `ci-checks.yml` & `story-link-check.sh` to verify valid jira reference - Add effect to fetch complete medical records - Bump sqlcipher to v4.13.0 @@ -24,6 +45,7 @@ ### Changes - Add `Sync Medical Records` button on setting page behind feature flag +- Increase the network read timeout to 45 seconds ## 2026.02.02 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b51f3f1b2c..404ea7f120c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -330,6 +330,7 @@ dependencies { implementation(libs.kotlin.coroutines) implementation(libs.kotlin.coroutines.test) implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.serialization) implementation(libs.logback.classic) diff --git a/app/schemas/org.simple.clinic.AppDatabase/123.json b/app/schemas/org.simple.clinic.AppDatabase/123.json new file mode 100644 index 00000000000..87abb44654f --- /dev/null +++ b/app/schemas/org.simple.clinic.AppDatabase/123.json @@ -0,0 +1,2285 @@ +{ + "formatVersion": 1, + "database": { + "version": 123, + "identityHash": "412ffbb3582e6c91acc665cc7fe5a566", + "entities": [ + { + "tableName": "Patient", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `addressUuid` TEXT NOT NULL, `fullName` TEXT NOT NULL, `gender` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, `recordedAt` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `reminderConsent` TEXT NOT NULL, `deletedReason` TEXT, `registeredFacilityId` TEXT, `assignedFacilityId` TEXT, `retainUntil` TEXT, `eligibleForReassignment` TEXT NOT NULL, `age_value` INTEGER, `age_updatedAt` TEXT, `dateOfBirth` TEXT, PRIMARY KEY(`uuid`), FOREIGN KEY(`addressUuid`) REFERENCES `PatientAddress`(`uuid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressUuid", + "columnName": "addressUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gender", + "columnName": "gender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "recordedAt", + "columnName": "recordedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminderConsent", + "columnName": "reminderConsent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedReason", + "columnName": "deletedReason", + "affinity": "TEXT" + }, + { + "fieldPath": "registeredFacilityId", + "columnName": "registeredFacilityId", + "affinity": "TEXT" + }, + { + "fieldPath": "assignedFacilityId", + "columnName": "assignedFacilityId", + "affinity": "TEXT" + }, + { + "fieldPath": "retainUntil", + "columnName": "retainUntil", + "affinity": "TEXT" + }, + { + "fieldPath": "eligibleForReassignment", + "columnName": "eligibleForReassignment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ageDetails.ageValue", + "columnName": "age_value", + "affinity": "INTEGER" + }, + { + "fieldPath": "ageDetails.ageUpdatedAt", + "columnName": "age_updatedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "ageDetails.dateOfBirth", + "columnName": "dateOfBirth", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_Patient_addressUuid", + "unique": false, + "columnNames": [ + "addressUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Patient_addressUuid` ON `${TABLE_NAME}` (`addressUuid`)" + }, + { + "name": "index_Patient_assignedFacilityId", + "unique": false, + "columnNames": [ + "assignedFacilityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Patient_assignedFacilityId` ON `${TABLE_NAME}` (`assignedFacilityId`)" + } + ], + "foreignKeys": [ + { + "table": "PatientAddress", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "addressUuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "PatientAddress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `streetAddress` TEXT, `colonyOrVillage` TEXT, `zone` TEXT, `district` TEXT NOT NULL, `state` TEXT NOT NULL, `country` TEXT, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streetAddress", + "columnName": "streetAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "colonyOrVillage", + "columnName": "colonyOrVillage", + "affinity": "TEXT" + }, + { + "fieldPath": "zone", + "columnName": "zone", + "affinity": "TEXT" + }, + { + "fieldPath": "district", + "columnName": "district", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "PatientPhoneNumber", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `number` TEXT NOT NULL, `phoneType` TEXT NOT NULL, `active` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`), FOREIGN KEY(`patientUuid`) REFERENCES `Patient`(`uuid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneType", + "columnName": "phoneType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_PatientPhoneNumber_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PatientPhoneNumber_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + } + ], + "foreignKeys": [ + { + "table": "Patient", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "patientUuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "BloodPressureMeasurement", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `userUuid` TEXT NOT NULL, `facilityUuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, `recordedAt` TEXT NOT NULL, `systolic` INTEGER NOT NULL, `diastolic` INTEGER NOT NULL, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userUuid", + "columnName": "userUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "facilityUuid", + "columnName": "facilityUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "recordedAt", + "columnName": "recordedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reading.systolic", + "columnName": "systolic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reading.diastolic", + "columnName": "diastolic", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_BloodPressureMeasurement_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BloodPressureMeasurement_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + }, + { + "name": "index_BloodPressureMeasurement_facilityUuid", + "unique": false, + "columnNames": [ + "facilityUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BloodPressureMeasurement_facilityUuid` ON `${TABLE_NAME}` (`facilityUuid`)" + } + ] + }, + { + "tableName": "PrescribedDrug", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `dosage` TEXT, `rxNormCode` TEXT, `isDeleted` INTEGER NOT NULL, `isProtocolDrug` INTEGER NOT NULL, `patientUuid` TEXT NOT NULL, `facilityUuid` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `frequency` TEXT, `durationInDays` INTEGER, `teleconsultationId` TEXT, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dosage", + "columnName": "dosage", + "affinity": "TEXT" + }, + { + "fieldPath": "rxNormCode", + "columnName": "rxNormCode", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isProtocolDrug", + "columnName": "isProtocolDrug", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "facilityUuid", + "columnName": "facilityUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "TEXT" + }, + { + "fieldPath": "durationInDays", + "columnName": "durationInDays", + "affinity": "INTEGER" + }, + { + "fieldPath": "teleconsultationId", + "columnName": "teleconsultationId", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_PrescribedDrug_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PrescribedDrug_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + }, + { + "name": "index_PrescribedDrug_facilityUuid", + "unique": false, + "columnNames": [ + "facilityUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PrescribedDrug_facilityUuid` ON `${TABLE_NAME}` (`facilityUuid`)" + } + ] + }, + { + "tableName": "Facility", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `facilityType` TEXT, `streetAddress` TEXT, `villageOrColony` TEXT, `district` TEXT NOT NULL, `state` TEXT NOT NULL, `country` TEXT NOT NULL, `pinCode` TEXT, `protocolUuid` TEXT, `groupUuid` TEXT, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `deletedAt` TEXT, `syncGroup` TEXT NOT NULL, `location_latitude` REAL, `location_longitude` REAL, `config_diabetesManagementEnabled` INTEGER NOT NULL, `config_teleconsultationEnabled` INTEGER, `config_monthlyScreeningReportsEnabled` INTEGER, `config_monthlySuppliesReportsEnabled` INTEGER NOT NULL, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "facilityType", + "columnName": "facilityType", + "affinity": "TEXT" + }, + { + "fieldPath": "streetAddress", + "columnName": "streetAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "villageOrColony", + "columnName": "villageOrColony", + "affinity": "TEXT" + }, + { + "fieldPath": "district", + "columnName": "district", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pinCode", + "columnName": "pinCode", + "affinity": "TEXT" + }, + { + "fieldPath": "protocolUuid", + "columnName": "protocolUuid", + "affinity": "TEXT" + }, + { + "fieldPath": "groupUuid", + "columnName": "groupUuid", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "syncGroup", + "columnName": "syncGroup", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location.latitude", + "columnName": "location_latitude", + "affinity": "REAL" + }, + { + "fieldPath": "location.longitude", + "columnName": "location_longitude", + "affinity": "REAL" + }, + { + "fieldPath": "config.diabetesManagementEnabled", + "columnName": "config_diabetesManagementEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "config.teleconsultationEnabled", + "columnName": "config_teleconsultationEnabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "config.monthlyScreeningReportsEnabled", + "columnName": "config_monthlyScreeningReportsEnabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "config.monthlySuppliesReportsEnabled", + "columnName": "config_monthlySuppliesReportsEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "LoggedInUser", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `fullName` TEXT NOT NULL, `phoneNumber` TEXT NOT NULL, `pinDigest` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `loggedInStatus` TEXT NOT NULL, `registrationFacilityUuid` TEXT NOT NULL, `currentFacilityUuid` TEXT NOT NULL, `teleconsultPhoneNumber` TEXT, `capability_canTeleconsult` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneNumber", + "columnName": "phoneNumber", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pinDigest", + "columnName": "pinDigest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loggedInStatus", + "columnName": "loggedInStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "registrationFacilityUuid", + "columnName": "registrationFacilityUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentFacilityUuid", + "columnName": "currentFacilityUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "teleconsultPhoneNumber", + "columnName": "teleconsultPhoneNumber", + "affinity": "TEXT" + }, + { + "fieldPath": "capabilities.canTeleconsult", + "columnName": "capability_canTeleconsult", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "Appointment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `facilityUuid` TEXT NOT NULL, `scheduledDate` TEXT NOT NULL, `status` TEXT NOT NULL, `cancelReason` TEXT, `remindOn` TEXT, `agreedToVisit` INTEGER, `appointmentType` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, `creationFacilityUuid` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "facilityUuid", + "columnName": "facilityUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheduledDate", + "columnName": "scheduledDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cancelReason", + "columnName": "cancelReason", + "affinity": "TEXT" + }, + { + "fieldPath": "remindOn", + "columnName": "remindOn", + "affinity": "TEXT" + }, + { + "fieldPath": "agreedToVisit", + "columnName": "agreedToVisit", + "affinity": "INTEGER" + }, + { + "fieldPath": "appointmentType", + "columnName": "appointmentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "creationFacilityUuid", + "columnName": "creationFacilityUuid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_Appointment_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Appointment_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + }, + { + "name": "index_Appointment_creationFacilityUuid", + "unique": false, + "columnNames": [ + "creationFacilityUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Appointment_creationFacilityUuid` ON `${TABLE_NAME}` (`creationFacilityUuid`)" + }, + { + "name": "index_Appointment_facilityUuid", + "unique": false, + "columnNames": [ + "facilityUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Appointment_facilityUuid` ON `${TABLE_NAME}` (`facilityUuid`)" + } + ] + }, + { + "tableName": "MedicalHistory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `diagnosedWithHypertension` TEXT NOT NULL, `isOnHypertensionTreatment` TEXT NOT NULL, `isOnDiabetesTreatment` TEXT NOT NULL, `hasHadHeartAttack` TEXT NOT NULL, `hasHadStroke` TEXT NOT NULL, `hasHadKidneyDisease` TEXT NOT NULL, `hasDiabetes` TEXT NOT NULL, `isSmoking` TEXT NOT NULL, `isUsingSmokelessTobacco` TEXT NOT NULL, `cholesterol_value` REAL, `hypertensionDiagnosedAt` TEXT, `diabetesDiagnosedAt` TEXT, `syncStatus` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "diagnosedWithHypertension", + "columnName": "diagnosedWithHypertension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOnHypertensionTreatment", + "columnName": "isOnHypertensionTreatment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOnDiabetesTreatment", + "columnName": "isOnDiabetesTreatment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasHadHeartAttack", + "columnName": "hasHadHeartAttack", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasHadStroke", + "columnName": "hasHadStroke", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasHadKidneyDisease", + "columnName": "hasHadKidneyDisease", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "diagnosedWithDiabetes", + "columnName": "hasDiabetes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSmoking", + "columnName": "isSmoking", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsingSmokelessTobacco", + "columnName": "isUsingSmokelessTobacco", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cholesterol", + "columnName": "cholesterol_value", + "affinity": "REAL" + }, + { + "fieldPath": "hypertensionDiagnosedAt", + "columnName": "hypertensionDiagnosedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "diabetesDiagnosedAt", + "columnName": "diabetesDiagnosedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_MedicalHistory_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MedicalHistory_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + } + ] + }, + { + "tableName": "OngoingLoginEntry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `phoneNumber` TEXT, `pin` TEXT, `fullName` TEXT, `pinDigest` TEXT, `registrationFacilityUuid` TEXT, `status` TEXT, `createdAt` TEXT, `updatedAt` TEXT, `teleconsultPhoneNumber` TEXT, `capability_canTeleconsult` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneNumber", + "columnName": "phoneNumber", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT" + }, + { + "fieldPath": "pinDigest", + "columnName": "pinDigest", + "affinity": "TEXT" + }, + { + "fieldPath": "registrationFacilityUuid", + "columnName": "registrationFacilityUuid", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultPhoneNumber", + "columnName": "teleconsultPhoneNumber", + "affinity": "TEXT" + }, + { + "fieldPath": "capabilities.canTeleconsult", + "columnName": "capability_canTeleconsult", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "Protocol", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `name` TEXT NOT NULL, `followUpDays` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "followUpDays", + "columnName": "followUpDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "ProtocolDrug", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `protocolUuid` TEXT NOT NULL, `name` TEXT NOT NULL, `rxNormCode` TEXT, `dosage` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, `order` INTEGER NOT NULL, PRIMARY KEY(`uuid`), FOREIGN KEY(`protocolUuid`) REFERENCES `Protocol`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocolUuid", + "columnName": "protocolUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rxNormCode", + "columnName": "rxNormCode", + "affinity": "TEXT" + }, + { + "fieldPath": "dosage", + "columnName": "dosage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_ProtocolDrug_protocolUuid", + "unique": false, + "columnNames": [ + "protocolUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ProtocolDrug_protocolUuid` ON `${TABLE_NAME}` (`protocolUuid`)" + } + ], + "foreignKeys": [ + { + "table": "Protocol", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "protocolUuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "BusinessId", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `metaVersion` TEXT NOT NULL, `meta` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, `searchHelp` TEXT NOT NULL, `identifier` TEXT NOT NULL, `identifierType` TEXT NOT NULL, PRIMARY KEY(`uuid`), FOREIGN KEY(`patientUuid`) REFERENCES `Patient`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaDataVersion", + "columnName": "metaVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaData", + "columnName": "meta", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "searchHelp", + "columnName": "searchHelp", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identifier.value", + "columnName": "identifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identifier.type", + "columnName": "identifierType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_BusinessId_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BusinessId_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + }, + { + "name": "index_BusinessId_identifier", + "unique": false, + "columnNames": [ + "identifier" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BusinessId_identifier` ON `${TABLE_NAME}` (`identifier`)" + } + ], + "foreignKeys": [ + { + "table": "Patient", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "patientUuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "MissingPhoneReminder", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`patientUuid` TEXT NOT NULL, `remindedAt` TEXT NOT NULL, PRIMARY KEY(`patientUuid`))", + "fields": [ + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remindedAt", + "columnName": "remindedAt", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "patientUuid" + ] + } + }, + { + "tableName": "BloodSugarMeasurements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `recordedAt` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `userUuid` TEXT NOT NULL, `facilityUuid` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `reading_value` TEXT NOT NULL, `reading_type` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordedAt", + "columnName": "recordedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userUuid", + "columnName": "userUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "facilityUuid", + "columnName": "facilityUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reading.value", + "columnName": "reading_value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reading.type", + "columnName": "reading_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_BloodSugarMeasurements_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BloodSugarMeasurements_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + }, + { + "name": "index_BloodSugarMeasurements_facilityUuid", + "unique": false, + "columnNames": [ + "facilityUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BloodSugarMeasurements_facilityUuid` ON `${TABLE_NAME}` (`facilityUuid`)" + } + ] + }, + { + "tableName": "TextRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `text` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "TeleconsultationFacilityInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`teleconsultationFacilityId` TEXT NOT NULL, `facilityId` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, `syncStatus` TEXT NOT NULL, PRIMARY KEY(`teleconsultationFacilityId`))", + "fields": [ + { + "fieldPath": "teleconsultationFacilityId", + "columnName": "teleconsultationFacilityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "facilityId", + "columnName": "facilityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "teleconsultationFacilityId" + ] + } + }, + { + "tableName": "MedicalOfficer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`medicalOfficerId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `phoneNumber` TEXT NOT NULL, PRIMARY KEY(`medicalOfficerId`))", + "fields": [ + { + "fieldPath": "medicalOfficerId", + "columnName": "medicalOfficerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneNumber", + "columnName": "phoneNumber", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "medicalOfficerId" + ] + } + }, + { + "tableName": "TeleconsultationFacilityMedicalOfficersCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`teleconsultationFacilityId` TEXT NOT NULL, `medicalOfficerId` TEXT NOT NULL, PRIMARY KEY(`teleconsultationFacilityId`, `medicalOfficerId`))", + "fields": [ + { + "fieldPath": "teleconsultationFacilityId", + "columnName": "teleconsultationFacilityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "medicalOfficerId", + "columnName": "medicalOfficerId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "teleconsultationFacilityId", + "medicalOfficerId" + ] + } + }, + { + "tableName": "TeleconsultRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `medicalOfficerId` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `request_requesterId` TEXT, `request_facilityId` TEXT, `request_requestedAt` TEXT, `request_requesterCompletionStatus` TEXT, `record_recordedAt` TEXT, `record_teleconsultationType` TEXT, `record_patientTookMedicines` TEXT, `record_patientConsented` TEXT, `record_medicalOfficerNumber` TEXT, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientId", + "columnName": "patientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "medicalOfficerId", + "columnName": "medicalOfficerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "teleconsultRequestInfo.requesterId", + "columnName": "request_requesterId", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRequestInfo.facilityId", + "columnName": "request_facilityId", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRequestInfo.requestedAt", + "columnName": "request_requestedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRequestInfo.requesterCompletionStatus", + "columnName": "request_requesterCompletionStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRecordInfo.recordedAt", + "columnName": "record_recordedAt", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRecordInfo.teleconsultationType", + "columnName": "record_teleconsultationType", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRecordInfo.patientTookMedicines", + "columnName": "record_patientTookMedicines", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRecordInfo.patientConsented", + "columnName": "record_patientConsented", + "affinity": "TEXT" + }, + { + "fieldPath": "teleconsultRecordInfo.medicalOfficerNumber", + "columnName": "record_medicalOfficerNumber", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "Drug", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT, `frequency` TEXT, `composition` TEXT, `dosage` TEXT, `rxNormCode` TEXT, `protocol` TEXT NOT NULL, `common` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT" + }, + { + "fieldPath": "frequency", + "columnName": "frequency", + "affinity": "TEXT" + }, + { + "fieldPath": "composition", + "columnName": "composition", + "affinity": "TEXT" + }, + { + "fieldPath": "dosage", + "columnName": "dosage", + "affinity": "TEXT" + }, + { + "fieldPath": "rxNormCode", + "columnName": "rxNormCode", + "affinity": "TEXT" + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "common", + "columnName": "common", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CallResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `patientId` TEXT, `facilityId` TEXT, `appointmentId` TEXT NOT NULL, `removeReason` TEXT, `outcome` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientId", + "columnName": "patientId", + "affinity": "TEXT" + }, + { + "fieldPath": "facilityId", + "columnName": "facilityId", + "affinity": "TEXT" + }, + { + "fieldPath": "appointmentId", + "columnName": "appointmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "removeReason", + "columnName": "removeReason", + "affinity": "TEXT" + }, + { + "fieldPath": "outcome", + "columnName": "outcome", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_CallResult_appointmentId", + "unique": false, + "columnNames": [ + "appointmentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_CallResult_appointmentId` ON `${TABLE_NAME}` (`appointmentId`)" + } + ] + }, + { + "tableName": "PatientFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`uuid` TEXT NOT NULL, `fullName` TEXT NOT NULL, content=`Patient`)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "Patient", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientFts_BEFORE_UPDATE BEFORE UPDATE ON `Patient` BEGIN DELETE FROM `PatientFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientFts_BEFORE_DELETE BEFORE DELETE ON `Patient` BEGIN DELETE FROM `PatientFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientFts_AFTER_UPDATE AFTER UPDATE ON `Patient` BEGIN INSERT INTO `PatientFts`(`docid`, `uuid`, `fullName`) VALUES (NEW.`rowid`, NEW.`uuid`, NEW.`fullName`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientFts_AFTER_INSERT AFTER INSERT ON `Patient` BEGIN INSERT INTO `PatientFts`(`docid`, `uuid`, `fullName`) VALUES (NEW.`rowid`, NEW.`uuid`, NEW.`fullName`); END" + ] + }, + { + "tableName": "PatientPhoneNumberFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`patientUuid` TEXT NOT NULL, `number` TEXT NOT NULL, content=`PatientPhoneNumber`)", + "fields": [ + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "PatientPhoneNumber", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientPhoneNumberFts_BEFORE_UPDATE BEFORE UPDATE ON `PatientPhoneNumber` BEGIN DELETE FROM `PatientPhoneNumberFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientPhoneNumberFts_BEFORE_DELETE BEFORE DELETE ON `PatientPhoneNumber` BEGIN DELETE FROM `PatientPhoneNumberFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientPhoneNumberFts_AFTER_UPDATE AFTER UPDATE ON `PatientPhoneNumber` BEGIN INSERT INTO `PatientPhoneNumberFts`(`docid`, `patientUuid`, `number`) VALUES (NEW.`rowid`, NEW.`patientUuid`, NEW.`number`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientPhoneNumberFts_AFTER_INSERT AFTER INSERT ON `PatientPhoneNumber` BEGIN INSERT INTO `PatientPhoneNumberFts`(`docid`, `patientUuid`, `number`) VALUES (NEW.`rowid`, NEW.`patientUuid`, NEW.`number`); END" + ] + }, + { + "tableName": "BusinessIdFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`patientUuid` TEXT NOT NULL, `searchHelp` TEXT NOT NULL, content=`BusinessId`)", + "fields": [ + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchHelp", + "columnName": "searchHelp", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "BusinessId", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_BusinessIdFts_BEFORE_UPDATE BEFORE UPDATE ON `BusinessId` BEGIN DELETE FROM `BusinessIdFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_BusinessIdFts_BEFORE_DELETE BEFORE DELETE ON `BusinessId` BEGIN DELETE FROM `BusinessIdFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_BusinessIdFts_AFTER_UPDATE AFTER UPDATE ON `BusinessId` BEGIN INSERT INTO `BusinessIdFts`(`docid`, `patientUuid`, `searchHelp`) VALUES (NEW.`rowid`, NEW.`patientUuid`, NEW.`searchHelp`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_BusinessIdFts_AFTER_INSERT AFTER INSERT ON `BusinessId` BEGIN INSERT INTO `BusinessIdFts`(`docid`, `patientUuid`, `searchHelp`) VALUES (NEW.`rowid`, NEW.`patientUuid`, NEW.`searchHelp`); END" + ] + }, + { + "tableName": "PatientAddressFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`uuid` TEXT NOT NULL, `colonyOrVillage` TEXT, content=`PatientAddress`)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colonyOrVillage", + "columnName": "colonyOrVillage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "PatientAddress", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientAddressFts_BEFORE_UPDATE BEFORE UPDATE ON `PatientAddress` BEGIN DELETE FROM `PatientAddressFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientAddressFts_BEFORE_DELETE BEFORE DELETE ON `PatientAddress` BEGIN DELETE FROM `PatientAddressFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientAddressFts_AFTER_UPDATE AFTER UPDATE ON `PatientAddress` BEGIN INSERT INTO `PatientAddressFts`(`docid`, `uuid`, `colonyOrVillage`) VALUES (NEW.`rowid`, NEW.`uuid`, NEW.`colonyOrVillage`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_PatientAddressFts_AFTER_INSERT AFTER INSERT ON `PatientAddress` BEGIN INSERT INTO `PatientAddressFts`(`docid`, `uuid`, `colonyOrVillage`) VALUES (NEW.`rowid`, NEW.`uuid`, NEW.`colonyOrVillage`); END" + ] + }, + { + "tableName": "Questionnaire", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `questionnaire_type` TEXT NOT NULL, `layout` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`questionnaire_type`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionnaire_type", + "columnName": "questionnaire_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layout", + "columnName": "layout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "questionnaire_type" + ] + } + }, + { + "tableName": "QuestionnaireResponse", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `questionnaireId` TEXT NOT NULL, `questionnaireType` TEXT NOT NULL, `facilityId` TEXT NOT NULL, `lastUpdatedByUserId` TEXT, `content` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionnaireId", + "columnName": "questionnaireId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionnaireType", + "columnName": "questionnaireType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "facilityId", + "columnName": "facilityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdatedByUserId", + "columnName": "lastUpdatedByUserId", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "PatientAttribute", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `userUuid` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `height` REAL NOT NULL, `weight` REAL NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userUuid", + "columnName": "userUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bmiReading.height", + "columnName": "height", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bmiReading.weight", + "columnName": "weight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_PatientAttribute_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PatientAttribute_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + } + ] + }, + { + "tableName": "CVDRisk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `syncStatus` TEXT NOT NULL, `min` INTEGER NOT NULL, `max` INTEGER NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "riskScore.min", + "columnName": "min", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "riskScore.max", + "columnName": "max", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_CVDRisk_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_CVDRisk_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + } + ] + }, + { + "tableName": "ReturnScore", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `patientUuid` TEXT NOT NULL, `scoreType` TEXT NOT NULL, `scoreValue` REAL NOT NULL, `createdAt` TEXT NOT NULL, `updatedAt` TEXT NOT NULL, `deletedAt` TEXT, PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientUuid", + "columnName": "patientUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scoreType", + "columnName": "scoreType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scoreValue", + "columnName": "scoreValue", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "timestamps.createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.updatedAt", + "columnName": "updatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamps.deletedAt", + "columnName": "deletedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_ReturnScore_patientUuid", + "unique": false, + "columnNames": [ + "patientUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ReturnScore_patientUuid` ON `${TABLE_NAME}` (`patientUuid`)" + } + ] + } + ], + "views": [ + { + "viewName": "PatientSearchResult", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT P.uuid, P.fullName, P.gender, P.dateOfBirth, P.age_value, P.age_updatedAt,\n P.assignedFacilityId, P.status, P.eligibleForReassignment,\n PA.uuid addr_uuid, PA.streetAddress addr_streetAddress, PA.colonyOrVillage addr_colonyOrVillage, PA.zone addr_zone, PA.district addr_district,\n PA.state addr_state, PA.country addr_country,\n PA.createdAt addr_createdAt, PA.updatedAt addr_updatedAt, PA.deletedAt addr_deletedAt,\n PP.number phoneNumber,\n B.identifier id_identifier, B.identifierType id_identifierType, B.searchHelp identifierSearchHelp, AF.name assignedFacilityName\n FROM Patient P\n INNER JOIN PatientAddress PA ON PA.uuid = P.addressUuid\n LEFT JOIN PatientPhoneNumber PP ON PP.patientUuid = P.uuid\n LEFT JOIN Facility AF ON AF.uuid = P.assignedFacilityId\n LEFT JOIN BusinessId B ON B.patientUuid = P.uuid\n WHERE P.deletedAt IS NULL" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '412ffbb3582e6c91acc665cc7fe5a566')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt b/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt index 3f384730af7..60fe0cf2505 100644 --- a/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt +++ b/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt @@ -40,6 +40,7 @@ import org.simple.clinic.patient.onlinelookup.api.LookupPatientOnlineApiIntegrat import org.simple.clinic.patientattribute.PatientAttributeRepositoryAndroidTest import org.simple.clinic.protocolv2.ProtocolRepositoryAndroidTest import org.simple.clinic.protocolv2.sync.ProtocolSyncAndroidTest +import org.simple.clinic.returnscore.ReturnScoreRepositoryAndroidTest import org.simple.clinic.rules.LocalAuthenticationRule import org.simple.clinic.rules.RegisterPatientRule import org.simple.clinic.rules.SaveDatabaseRule @@ -73,6 +74,7 @@ import org.simple.clinic.sync.ProtocolSyncIntegrationTest import org.simple.clinic.sync.QuestionnaireResponseSyncIntegrationTest import org.simple.clinic.sync.QuestionnaireSyncIntegrationTest import org.simple.clinic.sync.ReportsSyncIntegrationTest +import org.simple.clinic.sync.ReturnScoreSyncIntegrationTest import org.simple.clinic.sync.TeleconsultationSyncIntegrationTest import org.simple.clinic.teleconsultlog.teleconsultrecord.TeleconsultRecordRepositoryAndroidTest import org.simple.clinic.teleconsultlog.teleconsultrecord.TeleconsultRecordSyncIntegrationTest @@ -170,4 +172,6 @@ interface TestAppComponent { fun inject(target: PatientAttributeSyncIntegrationTest) fun inject(target: CVDRiskRepositoryAndroidTest) fun inject(target: CVDRiskSyncIntegrationTest) + fun inject(target: ReturnScoreRepositoryAndroidTest) + fun inject(target: ReturnScoreSyncIntegrationTest) } diff --git a/app/src/androidTest/java/org/simple/clinic/returnscore/ReturnScoreRepositoryAndroidTest.kt b/app/src/androidTest/java/org/simple/clinic/returnscore/ReturnScoreRepositoryAndroidTest.kt new file mode 100644 index 00000000000..6cc6ed62330 --- /dev/null +++ b/app/src/androidTest/java/org/simple/clinic/returnscore/ReturnScoreRepositoryAndroidTest.kt @@ -0,0 +1,54 @@ +package org.simple.clinic.returnscore + +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.simple.clinic.AppDatabase +import org.simple.clinic.TestClinicApp +import org.simple.clinic.TestData +import org.simple.clinic.rules.SaveDatabaseRule +import org.simple.clinic.util.Rules +import java.util.UUID +import javax.inject.Inject + +class ReturnScoreRepositoryAndroidTest { + + @Inject + lateinit var database: AppDatabase + + @Inject + lateinit var returnScoreRepository: ReturnScoreRepository + + @get:Rule + val rules: RuleChain = Rules + .global() + .around(SaveDatabaseRule()) + + @Before + fun setUp() { + TestClinicApp.appComponent().inject(this) + } + + @Test + fun saving_return_scores_should_work_correctly() { + // given + val returnScores = listOf( + TestData.returnScore( + uuid = UUID.fromString("ef5b7656-a6df-459c-a5b0-80d100721597"), + ), + TestData.returnScore( + uuid = UUID.fromString("ef5b7656-a6df-459c-a5b0-80d123021597"), + ) + ) + + // when + returnScoreRepository.save(returnScores) + + // then + val savedReturnScores = returnScoreRepository.returnScoresImmediate() + + assertThat(savedReturnScores).isEqualTo(returnScores) + } +} diff --git a/app/src/androidTest/java/org/simple/clinic/storage/migrations/Migration123AndroidTest.kt b/app/src/androidTest/java/org/simple/clinic/storage/migrations/Migration123AndroidTest.kt new file mode 100644 index 00000000000..5499ab96b3b --- /dev/null +++ b/app/src/androidTest/java/org/simple/clinic/storage/migrations/Migration123AndroidTest.kt @@ -0,0 +1,19 @@ +package org.simple.clinic.storage.migrations + +import org.junit.Test +import org.simple.clinic.assertIndexDoesNotExist +import org.simple.clinic.assertIndexExists +import org.simple.clinic.assertTableDoesNotExist +import org.simple.clinic.assertTableExists + +class Migration123AndroidTest : BaseDatabaseMigrationTest(122, 123) { + + @Test + fun migration_123_should_generate_the_ReturnScore_table() { + before.assertTableDoesNotExist("ReturnScore") + before.assertIndexDoesNotExist("index_ReturnScore_patientUuid") + + after.assertTableExists("ReturnScore") + after.assertIndexExists("index_ReturnScore_patientUuid") + } +} diff --git a/app/src/androidTest/java/org/simple/clinic/sync/ReturnScoreSyncIntegrationTest.kt b/app/src/androidTest/java/org/simple/clinic/sync/ReturnScoreSyncIntegrationTest.kt new file mode 100644 index 00000000000..289ca203746 --- /dev/null +++ b/app/src/androidTest/java/org/simple/clinic/sync/ReturnScoreSyncIntegrationTest.kt @@ -0,0 +1,92 @@ +package org.simple.clinic.sync + +import com.f2prateek.rx.preferences2.Preference +import com.google.common.truth.Truth +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.simple.clinic.AppDatabase +import org.simple.clinic.TestClinicApp +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.returnscore.ReturnScoreRepository +import org.simple.clinic.returnscore.sync.ReturnScoreSync +import org.simple.clinic.returnscore.sync.ReturnScoreSyncApi +import org.simple.clinic.rules.SaveDatabaseRule +import org.simple.clinic.rules.ServerAuthenticationRule +import org.simple.clinic.util.Rules +import java.util.Optional +import javax.inject.Inject + +class ReturnScoreSyncIntegrationTest { + + @Inject + lateinit var appDatabase: AppDatabase + + @Inject + lateinit var repository: ReturnScoreRepository + + @Inject + @TypedPreference(TypedPreference.Type.LastReturnScorePullToken) + lateinit var lastPullToken: Preference> + + @Inject + lateinit var syncApi: ReturnScoreSyncApi + + @Inject + lateinit var syncInterval: SyncInterval + + @get:Rule + val ruleChain: RuleChain = Rules + .global() + .around(ServerAuthenticationRule()) + .around(SaveDatabaseRule()) + + private lateinit var sync: ReturnScoreSync + + private val batchSize = 1000 + private lateinit var config: SyncConfig + + @Before + fun setUp() { + TestClinicApp.appComponent().inject(this) + + resetLocalData() + + config = SyncConfig( + syncInterval = syncInterval, + pullBatchSize = batchSize, + pushBatchSize = batchSize, + name = "" + ) + + sync = ReturnScoreSync( + syncCoordinator = SyncCoordinator(), + api = syncApi, + repository = repository, + lastPullToken = lastPullToken, + config = config + ) + } + + private fun resetLocalData() { + clearReturnScoreDao() + lastPullToken.delete() + } + + private fun clearReturnScoreDao() { + appDatabase.returnScoreDao().clear() + } + + @Test + fun syncing_records_should_work_as_expected() { + // when + Truth.assertThat(repository.recordCount().blockingFirst()).isEqualTo(0) + sync.pull() + + // then + val pulledRecords = repository.returnScoresImmediate() + + Truth.assertThat(pulledRecords).isNotEmpty() + } +} diff --git a/app/src/main/java/org/simple/clinic/AppDatabase.kt b/app/src/main/java/org/simple/clinic/AppDatabase.kt index bb94adb128f..126206f7091 100644 --- a/app/src/main/java/org/simple/clinic/AppDatabase.kt +++ b/app/src/main/java/org/simple/clinic/AppDatabase.kt @@ -49,9 +49,11 @@ import org.simple.clinic.protocol.Protocol import org.simple.clinic.protocol.ProtocolDrug import org.simple.clinic.questionnaire.Questionnaire import org.simple.clinic.questionnaire.QuestionnaireType +import org.simple.clinic.returnscore.ReturnScore import org.simple.clinic.questionnaire.component.BaseComponentData import org.simple.clinic.questionnaire.component.properties.InputFieldType import org.simple.clinic.questionnaireresponse.QuestionnaireResponse +import org.simple.clinic.returnscore.ScoreType import org.simple.clinic.storage.text.TextRecord import org.simple.clinic.summary.addphone.MissingPhoneReminder import org.simple.clinic.summary.teleconsultation.sync.MedicalOfficer @@ -105,12 +107,13 @@ import org.simple.clinic.patient.Answer as PatientAnswer Questionnaire::class, QuestionnaireResponse::class, PatientAttribute::class, - CVDRisk::class + CVDRisk::class, + ReturnScore::class, ], views = [ PatientSearchResult::class ], - version = 122, + version = 123, exportSchema = true ) @TypeConverters( @@ -148,6 +151,7 @@ import org.simple.clinic.patient.Answer as PatientAnswer MapRoomTypeConverter::class, PatientAnswer.RoomTypeConverter::class, CVDRiskRange.RoomTypeConverter::class, + ScoreType.RoomTypeConverter::class, ) abstract class AppDatabase : RoomDatabase() { @@ -209,6 +213,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun cvdRiskDao(): CVDRisk.RoomDao + abstract fun returnScoreDao(): ReturnScore.RoomDao + fun clearAppData() { runInTransaction { patientDao().clear() @@ -228,6 +234,7 @@ abstract class AppDatabase : RoomDatabase() { questionnaireResponseDao().clear() patientAttributeDao().clear() cvdRiskDao().clear() + returnScoreDao().clear() } } @@ -258,7 +265,8 @@ abstract class AppDatabase : RoomDatabase() { purgeUnnecessaryMedicalHistories() + purgeUnnecessaryPrescriptions() + purgeUnnecessaryCallResults() + - purgeUnnecessaryQuestionnaireResponses() + purgeUnnecessaryQuestionnaireResponses() + + purgeUnnecessaryReturnScores() } } @@ -316,6 +324,10 @@ abstract class AppDatabase : RoomDatabase() { questionnaireResponseDao().purgeDeleted() } + private fun purgeUnnecessaryReturnScores(): Int { + return returnScoreDao().purgeDeleted() + } + private fun vacuumDatabase() { val db = openHelper.writableDatabase diff --git a/app/src/main/java/org/simple/clinic/di/network/NetworkModule.kt b/app/src/main/java/org/simple/clinic/di/network/NetworkModule.kt index 67d939d1237..a30b13eaf3f 100644 --- a/app/src/main/java/org/simple/clinic/di/network/NetworkModule.kt +++ b/app/src/main/java/org/simple/clinic/di/network/NetworkModule.kt @@ -32,6 +32,7 @@ import org.simple.clinic.questionnaire.component.properties.InputFieldType import org.simple.clinic.questionnaire.component.properties.InputFieldViewType import org.simple.clinic.questionnaireresponse.sync.QuestionnaireResponsePayload import org.simple.clinic.remoteconfig.ConfigReader +import org.simple.clinic.returnscore.ScoreType import org.simple.clinic.scanid.IndiaNHIDDateOfBirth import org.simple.clinic.scanid.IndiaNHIDDateOfBirthMoshiAdapter import org.simple.clinic.scanid.IndiaNHIDGender @@ -98,6 +99,7 @@ class NetworkModule { .add(InputFieldViewType.MoshiTypeAdapter()) .add(PatientAnswer.MoshiTypeAdapter()) .add(CVDRiskRange.MoshiTypeAdapter()) + .add(ScoreType.MoshiTypeAdapter()) .build() val patientPayloadNullSerializingAdapter = moshi.adapter(PatientPayload::class.java).serializeNulls() @@ -123,7 +125,7 @@ class NetworkModule { // When syncing large amounts of data, the default read timeout(10s) has been seen to // timeout frequently for larger models. Through trial and error, 15s was found to be a // good number for syncing large batch sizes. - readTimeout(configReader.long("networkmodule_read_timeout", default = 30L), TimeUnit.SECONDS) + readTimeout(configReader.long("networkmodule_read_timeout", default = 45L), TimeUnit.SECONDS) } .build() } 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 e08e558b7aa..77666331dd0 100644 --- a/app/src/main/java/org/simple/clinic/feature/Feature.kt +++ b/app/src/main/java/org/simple/clinic/feature/Feature.kt @@ -28,4 +28,6 @@ enum class Feature( LabBasedStatinNudge(false, "lab_based_statin_nudge"), Screening(false, "screening_feature_v0"), ShowDiagnosisButton(false, "show_diagnosis_button"), + SortOverdueBasedOnReturnScore(false, "sort_overdue_based_on_return_score"), + ShowReturnScoreDebugValues(false, "show_return_score_debug_values"), } diff --git a/app/src/main/java/org/simple/clinic/home/overdue/OverdueAppointmentSections.kt b/app/src/main/java/org/simple/clinic/home/overdue/OverdueAppointmentSections.kt index ea2457bd48b..619e5621d88 100644 --- a/app/src/main/java/org/simple/clinic/home/overdue/OverdueAppointmentSections.kt +++ b/app/src/main/java/org/simple/clinic/home/overdue/OverdueAppointmentSections.kt @@ -2,10 +2,12 @@ package org.simple.clinic.home.overdue import android.os.Parcelable import kotlinx.parcelize.Parcelize +import java.util.UUID @Parcelize data class OverdueAppointmentSections( val pendingAppointments: List, + val pendingDebugInfo: Map>, val agreedToVisitAppointments: List, val remindToCallLaterAppointments: List, val removedFromOverdueAppointments: List, diff --git a/app/src/main/java/org/simple/clinic/home/overdue/OverdueAppointmentSorter.kt b/app/src/main/java/org/simple/clinic/home/overdue/OverdueAppointmentSorter.kt new file mode 100644 index 00000000000..2b9a08da00e --- /dev/null +++ b/app/src/main/java/org/simple/clinic/home/overdue/OverdueAppointmentSorter.kt @@ -0,0 +1,86 @@ +package org.simple.clinic.home.overdue + +import org.simple.clinic.feature.Feature +import org.simple.clinic.feature.Features +import org.simple.clinic.returnscore.LikelyToReturnIfCalledScoreType +import org.simple.clinic.returnscore.ReturnScore +import java.util.UUID +import javax.inject.Inject +import kotlin.math.max +import kotlin.random.Random + +class OverdueAppointmentSorter @Inject constructor( + private val returnScoreDao: ReturnScore.RoomDao, + private val features: Features, + private val random: Random = Random.Default +) { + + fun sort(overdueAppointments: List): List { + + if (!features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) { + return overdueAppointments.map { + SortedOverdueAppointment( + appointment = it, + score = 0f, + bucket = OverdueBucket.REMAINING + ) + } + } + + val scores = returnScoreDao.getAllImmediate() + .filter { it.scoreType == LikelyToReturnIfCalledScoreType } + + val scoreMap: Map = scores.associate { + it.patientUuid to it.scoreValue + } + + val withScores = overdueAppointments.map { appointment -> + val score = scoreMap[appointment.appointment.patientUuid] ?: 0f + appointment to score + } + + val sorted = withScores.sortedByDescending { it.second } + + val total = sorted.size + if (total == 0) return emptyList() + + val top20End = max((total * 0.2).toInt(), 1) + val next30End = max((total * 0.5).toInt(), top20End) + + val top20 = sorted.take(top20End) + val next30 = sorted.subList(top20End, next30End) + val rest = sorted.drop(next30End) + + val topPickCount = max((top20.size * 0.5).toInt(), 1) + val nextPickCount = max((next30.size * 0.5).toInt(), 1) + + val topPicked = top20.shuffled(random).take(topPickCount) + val nextPicked = next30.shuffled(random).take(nextPickCount) + + val selectedAppointments = (topPicked + nextPicked) + .map { it.first } + .toSet() + + val topRemaining = top20.filterNot { it.first in selectedAppointments } + val nextRemaining = next30.filterNot { it.first in selectedAppointments } + + fun mapToSorted( + list: List>, + bucket: OverdueBucket + ) = list.map { (appointment, score) -> + SortedOverdueAppointment( + appointment = appointment, + score = score, + bucket = bucket + ) + } + + return buildList { + addAll(mapToSorted(topPicked, OverdueBucket.TOP_20)) + addAll(mapToSorted(nextPicked, OverdueBucket.NEXT_30)) + addAll(mapToSorted(topRemaining, OverdueBucket.TOP_20)) + addAll(mapToSorted(nextRemaining, OverdueBucket.NEXT_30)) + addAll(mapToSorted(rest, OverdueBucket.REMAINING)) + } + } +} diff --git a/app/src/main/java/org/simple/clinic/home/overdue/OverdueBucket.kt b/app/src/main/java/org/simple/clinic/home/overdue/OverdueBucket.kt new file mode 100644 index 00000000000..8533a5621a6 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/home/overdue/OverdueBucket.kt @@ -0,0 +1,7 @@ +package org.simple.clinic.home.overdue + +enum class OverdueBucket { + TOP_20, + NEXT_30, + REMAINING +} diff --git a/app/src/main/java/org/simple/clinic/home/overdue/OverdueEffectHandler.kt b/app/src/main/java/org/simple/clinic/home/overdue/OverdueEffectHandler.kt index fc06766d5a6..34be7ee1a00 100644 --- a/app/src/main/java/org/simple/clinic/home/overdue/OverdueEffectHandler.kt +++ b/app/src/main/java/org/simple/clinic/home/overdue/OverdueEffectHandler.kt @@ -27,6 +27,7 @@ class OverdueEffectHandler @AssistedInject constructor( private val overdueDownloadScheduler: OverdueDownloadScheduler, private val userClock: UserClock, private val overdueAppointmentSelector: OverdueAppointmentSelector, + private val overdueAppointmentSorter: OverdueAppointmentSorter, @Assisted private val viewEffectsConsumer: Consumer ) { @@ -82,8 +83,17 @@ class OverdueEffectHandler @AssistedInject constructor( overdueAppointments = overdueAppointments ) val overdueSections = overdueAppointmentsWithInYear.groupBy { it.callResult?.outcome } + + val pendingAppointments = overdueSections[null].orEmpty() + + val sortedPendingAppointments = overdueAppointmentSorter.sort(pendingAppointments) + val debugMap = sortedPendingAppointments.associate { + it.appointment.appointment.patientUuid to (it.score to it.bucket) + } + val overdueAppointmentSections = OverdueAppointmentSections( - pendingAppointments = overdueSections[null].orEmpty(), + pendingAppointments = sortedPendingAppointments.map { it.appointment }, + pendingDebugInfo = debugMap, agreedToVisitAppointments = overdueSections[Outcome.AgreedToVisit].orEmpty(), remindToCallLaterAppointments = overdueSections[Outcome.RemindToCallLater].orEmpty(), removedFromOverdueAppointments = overdueSections[Outcome.RemovedFromOverdueList].orEmpty(), diff --git a/app/src/main/java/org/simple/clinic/home/overdue/OverdueScreen.kt b/app/src/main/java/org/simple/clinic/home/overdue/OverdueScreen.kt index 8b08140c870..2dd9ebd68c1 100644 --- a/app/src/main/java/org/simple/clinic/home/overdue/OverdueScreen.kt +++ b/app/src/main/java/org/simple/clinic/home/overdue/OverdueScreen.kt @@ -31,6 +31,7 @@ import org.simple.clinic.databinding.ScreenOverdueBinding import org.simple.clinic.di.injector import org.simple.clinic.feature.Feature.OverdueInstantSearch import org.simple.clinic.feature.Feature.PatientReassignment +import org.simple.clinic.feature.Feature.ShowReturnScoreDebugValues import org.simple.clinic.feature.Features import org.simple.clinic.home.HomeScreen import org.simple.clinic.home.overdue.compose.OverdueScreenView @@ -249,6 +250,7 @@ class OverdueScreen : BaseScreen< isOverdueSelectAndDownloadEnabled = country.isoCountryCode == Country.INDIA, selectedOverdueAppointments = selectedOverdueAppointments, isPatientReassignmentFeatureEnabled = features.isEnabled(PatientReassignment), + showDebugValues = features.isEnabled(ShowReturnScoreDebugValues), locale = locale, ) diff --git a/app/src/main/java/org/simple/clinic/home/overdue/SortedOverdueAppointment.kt b/app/src/main/java/org/simple/clinic/home/overdue/SortedOverdueAppointment.kt new file mode 100644 index 00000000000..b5766f28075 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/home/overdue/SortedOverdueAppointment.kt @@ -0,0 +1,7 @@ +package org.simple.clinic.home.overdue + +data class SortedOverdueAppointment( + val appointment: OverdueAppointment, + val score: Float, + val bucket: OverdueBucket +) diff --git a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueAppointmentSections.kt b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueAppointmentSections.kt index b1db2547633..5f9b2a74de7 100644 --- a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueAppointmentSections.kt +++ b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueAppointmentSections.kt @@ -60,6 +60,9 @@ fun OverdueAppointmentSections( isOverdueSelectAndDownloadEnabled = model.isOverdueSelectAndDownloadEnabled, isAppointmentSelected = model.isAppointmentSelected, isEligibleForReassignment = model.isEligibleForReassignment, + showDebugValues = model.showDebugValues, + returnScore = model.returnScore, + bucket = model.bucket, onCallClicked = onCallClicked, onRowClicked = onRowClicked, onCheckboxClicked = onCheckboxClicked diff --git a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverduePatientListItem.kt b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverduePatientListItem.kt index 2d4ed3fcc05..e3c80b086ac 100644 --- a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverduePatientListItem.kt +++ b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverduePatientListItem.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.simple.clinic.R import org.simple.clinic.common.ui.theme.SimpleTheme +import org.simple.clinic.home.overdue.OverdueBucket import org.simple.clinic.patient.Gender import org.simple.clinic.patient.displayIconRes import java.util.UUID @@ -44,6 +45,9 @@ fun OverduePatientListItem( isOverdueSelectAndDownloadEnabled: Boolean, isAppointmentSelected: Boolean, isEligibleForReassignment: Boolean, + showDebugValues: Boolean, + returnScore: Float?, + bucket: OverdueBucket?, onCallClicked: (UUID) -> Unit, onRowClicked: (UUID) -> Unit, onCheckboxClicked: (UUID) -> Unit @@ -97,6 +101,10 @@ fun OverduePatientListItem( style = SimpleTheme.typography.material.body2, color = SimpleTheme.colors.material.error, ) + + if (showDebugValues) { + DebugScoreView(returnScore = returnScore, bucket = bucket) + } } OverduePatientListItemRightButton( @@ -210,6 +218,28 @@ fun OverduePatientListItemRightButton( } } +@Composable +private fun DebugScoreView( + returnScore: Float?, + bucket: OverdueBucket? +) { + if (returnScore != null && bucket != null) { + + val bucketText = when (bucket) { + OverdueBucket.TOP_20 -> "Top 20%" + OverdueBucket.NEXT_30 -> "Next 30%" + OverdueBucket.REMAINING -> "Remaining" + } + + Text( + modifier = Modifier.padding(top = 4.dp), + text = "Score: ${"%.1f".format(returnScore)} | $bucketText", + style = SimpleTheme.typography.material.caption, + color = Color.Gray + ) + } +} + @Preview @Composable private fun OverduePatientListItemPreview() { @@ -226,6 +256,9 @@ private fun OverduePatientListItemPreview() { isOverdueSelectAndDownloadEnabled = false, isAppointmentSelected = false, isEligibleForReassignment = true, + showDebugValues = true, + returnScore = 9.2f, + bucket = OverdueBucket.TOP_20, onCallClicked = {}, onRowClicked = {}, onCheckboxClicked = {} diff --git a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModel.kt b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModel.kt index 6ee2e1ab260..34c0ee92e54 100644 --- a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModel.kt +++ b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModel.kt @@ -2,6 +2,7 @@ package org.simple.clinic.home.overdue.compose import androidx.annotation.StringRes import org.simple.clinic.home.overdue.OverdueAppointmentSectionTitle +import org.simple.clinic.home.overdue.OverdueBucket import org.simple.clinic.home.overdue.PendingListState import org.simple.clinic.patient.Gender import java.util.Locale @@ -20,6 +21,9 @@ sealed class OverdueUiModel { val isOverdueSelectAndDownloadEnabled: Boolean, val isAppointmentSelected: Boolean, val isEligibleForReassignment: Boolean, + val showDebugValues: Boolean, + val returnScore: Float? = null, + val bucket: OverdueBucket? = null ) : OverdueUiModel() data class Header( diff --git a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModelMapper.kt b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModelMapper.kt index 215c3ed96f0..ddf58307494 100644 --- a/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModelMapper.kt +++ b/app/src/main/java/org/simple/clinic/home/overdue/compose/OverdueUiModelMapper.kt @@ -8,6 +8,7 @@ import org.simple.clinic.home.overdue.OverdueAppointmentSectionTitle.PENDING_TO_ import org.simple.clinic.home.overdue.OverdueAppointmentSectionTitle.REMIND_TO_CALL import org.simple.clinic.home.overdue.OverdueAppointmentSectionTitle.REMOVED_FROM_OVERDUE import org.simple.clinic.home.overdue.OverdueAppointmentSections +import org.simple.clinic.home.overdue.OverdueBucket import org.simple.clinic.home.overdue.OverdueListSectionStates import org.simple.clinic.home.overdue.PendingListState.SEE_ALL import org.simple.clinic.home.overdue.PendingListState.SEE_LESS @@ -31,6 +32,7 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled: Boolean, selectedOverdueAppointments: Set, isPatientReassignmentFeatureEnabled: Boolean, + showDebugValues: Boolean, locale: Locale, ): List { val searchOverduePatientsButtonListItem = searchOverduePatientItem( @@ -45,6 +47,7 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled, selectedOverdueAppointments, isPatientReassignmentFeatureEnabled, + showDebugValues, locale, ) @@ -236,6 +239,7 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled: Boolean, selectedOverdueAppointments: Set, isPatientReassignmentFeatureEnabled: Boolean, + showDebugValues: Boolean, locale: Locale, ): List { val pendingAppointments = overdueAppointmentSections.pendingAppointments @@ -256,6 +260,7 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled, selectedOverdueAppointments, isPatientReassignmentFeatureEnabled, + showDebugValues ) val showPendingListFooter = pendingAppointments.size > pendingListDefaultStateSize && overdueListSectionStates.isPendingHeaderExpanded @@ -278,6 +283,7 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled: Boolean, selectedOverdueAppointments: Set, isPatientReassignmentFeatureEnabled: Boolean, + showDebugValues: Boolean, ): List { val pendingAppointmentsList = when (overdueListSectionStates.pendingListState) { SEE_LESS -> overdueAppointmentSections.pendingAppointments.take(pendingListDefaultStateSize) @@ -291,6 +297,8 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled, selectedOverdueAppointments, isPatientReassignmentFeatureEnabled, + showDebugValues, + overdueAppointmentSections.pendingDebugInfo ) return if (pendingAppointmentsList.isEmpty() && overdueListSectionStates.isPendingHeaderExpanded) { @@ -307,6 +315,8 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled: Boolean, selectedOverdueAppointments: Set, isPatientReassignmentFeatureEnabled: Boolean, + showDebugValues: Boolean = false, + debugMap: Map> = emptyMap() ): List { return if (isListExpanded) { overdueAppointment.map { @@ -317,6 +327,8 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled, isAppointmentSelected, isPatientReassignmentFeatureEnabled, + showDebugValues, + debugMap ) } } else { @@ -330,7 +342,12 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled: Boolean, isAppointmentSelected: Boolean, isPatientReassignmentFeatureEnabled: Boolean, + showDebugValues: Boolean, + debugMap: Map> ): OverdueUiModel { + val patientUuid = overdueAppointment.appointment.patientUuid + val debugInfo = debugMap[patientUuid] + return OverdueUiModel.Patient( appointmentUuid = overdueAppointment.appointment.uuid, patientUuid = overdueAppointment.appointment.patientUuid, @@ -343,6 +360,9 @@ class OverdueUiModelMapper { isOverdueSelectAndDownloadEnabled = isOverdueSelectAndDownloadEnabled, isAppointmentSelected = isAppointmentSelected, isEligibleForReassignment = (overdueAppointment.eligibleForReassignment == Answer.Yes) && isPatientReassignmentFeatureEnabled, + showDebugValues = showDebugValues, + returnScore = debugInfo?.first, + bucket = debugInfo?.second, ) } diff --git a/app/src/main/java/org/simple/clinic/main/TypedPreference.kt b/app/src/main/java/org/simple/clinic/main/TypedPreference.kt index 33e6cb446de..5d84df00a38 100644 --- a/app/src/main/java/org/simple/clinic/main/TypedPreference.kt +++ b/app/src/main/java/org/simple/clinic/main/TypedPreference.kt @@ -25,5 +25,6 @@ annotation class TypedPreference(val value: Type) { DataProtectionConsent, LastPatientAttributePullToken, LastCVDRiskPullToken, + LastReturnScorePullToken } } diff --git a/app/src/main/java/org/simple/clinic/overdue/AppointmentModule.kt b/app/src/main/java/org/simple/clinic/overdue/AppointmentModule.kt index 8b8d8e7bc66..b44a40f314a 100644 --- a/app/src/main/java/org/simple/clinic/overdue/AppointmentModule.kt +++ b/app/src/main/java/org/simple/clinic/overdue/AppointmentModule.kt @@ -10,12 +10,14 @@ import org.simple.clinic.overdue.TimeToAppointment.Days import org.simple.clinic.overdue.TimeToAppointment.Months import org.simple.clinic.overdue.TimeToAppointment.Weeks import org.simple.clinic.remoteconfig.ConfigReader +import org.simple.clinic.returnscore.ReturnScore import org.simple.clinic.util.preference.StringPreferenceConverter import org.simple.clinic.util.preference.getOptional import retrofit2.Retrofit import java.time.Period import java.util.Optional import javax.inject.Named +import kotlin.random.Random @Module class AppointmentModule { @@ -133,4 +135,9 @@ class AppointmentModule { @Provides fun providePendingAppointmentsConfig(configReader: ConfigReader) = PendingAppointmentsConfig.read(configReader) + + @Provides + fun provideRandom(): Random { + return Random.Default + } } diff --git a/app/src/main/java/org/simple/clinic/returnscore/ReturnScore.kt b/app/src/main/java/org/simple/clinic/returnscore/ReturnScore.kt new file mode 100644 index 00000000000..aaafef4fdd2 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/ReturnScore.kt @@ -0,0 +1,63 @@ +package org.simple.clinic.returnscore + +import android.os.Parcelable +import androidx.room.Dao +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import io.reactivex.Flowable +import io.reactivex.Observable +import kotlinx.parcelize.Parcelize +import org.simple.clinic.storage.Timestamps +import java.util.UUID + +@Entity(tableName = "ReturnScore", + indices = [ + Index("patientUuid") + ]) +@Parcelize +data class ReturnScore( + @PrimaryKey + val uuid: UUID, + + val patientUuid: UUID, + + val scoreType: ScoreType, + + val scoreValue: Float, + + @Embedded + val timestamps: Timestamps, +) : Parcelable { + + @Dao + interface RoomDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(returnScores: List) + + @Query("SELECT * FROM ReturnScore WHERE deletedAt IS NULL") + fun getAll(): Flowable> + + @Query("SELECT * FROM ReturnScore WHERE deletedAt IS NULL") + fun getAllImmediate(): List + + @Query("SELECT * FROM ReturnScore WHERE scoreType == :type AND deletedAt IS NULL LIMIT 1") + fun getByScoreType(type: ScoreType): Flowable> + + @Query("SELECT COUNT(uuid) FROM ReturnScore") + fun count(): Observable + + @Query("DELETE FROM returnscore") + fun clear(): Int + + @Query(""" + DELETE FROM ReturnScore + WHERE deletedAt IS NOT NULL + """) + fun purgeDeleted(): Int + } +} diff --git a/app/src/main/java/org/simple/clinic/returnscore/ReturnScoreRepository.kt b/app/src/main/java/org/simple/clinic/returnscore/ReturnScoreRepository.kt new file mode 100644 index 00000000000..d55487a7072 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/ReturnScoreRepository.kt @@ -0,0 +1,62 @@ +package org.simple.clinic.returnscore + +import io.reactivex.Observable +import org.simple.clinic.di.AppScope +import org.simple.clinic.patient.SyncStatus +import org.simple.clinic.returnscore.sync.ReturnScorePayload +import org.simple.clinic.sync.SynceableRepository +import java.util.UUID +import javax.inject.Inject + +@AppScope +class ReturnScoreRepository @Inject constructor( + private val dao: ReturnScore.RoomDao +) : SynceableRepository { + + override fun save(records: List) { + saveRecords(records) + } + + override fun setSyncStatus(from: SyncStatus, to: SyncStatus) { + // no-op + } + + override fun setSyncStatus(ids: List, to: SyncStatus) { + // no-op + } + + override fun mergeWithLocalData(payloads: List) { + val records = payloads + .map { it.toDatabaseModel() } + + saveRecords(records) + } + + override fun recordCount(): Observable { + return dao.count() + } + + override fun pendingSyncRecordCount(): Observable { + return Observable.just(0) + } + + override fun pendingSyncRecords(limit: Int, offset: Int): List { + return emptyList() + } + + private fun saveRecords(records: List) { + dao.save(records) + } + + fun returnScores(): Observable> { + return dao.getAll().toObservable() + } + + fun returnScoresImmediate(): List { + return dao.getAllImmediate() + } + + fun returnScoresByType(type: ScoreType): Observable> { + return dao.getByScoreType(type).toObservable() + } +} diff --git a/app/src/main/java/org/simple/clinic/returnscore/ScoreType.kt b/app/src/main/java/org/simple/clinic/returnscore/ScoreType.kt new file mode 100644 index 00000000000..781936c5b96 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/ScoreType.kt @@ -0,0 +1,54 @@ +package org.simple.clinic.returnscore + +import android.os.Parcelable +import androidx.annotation.VisibleForTesting +import androidx.room.TypeConverter +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import kotlinx.parcelize.Parcelize +import org.simple.clinic.util.room.SafeEnumTypeAdapter + +sealed class ScoreType : Parcelable { + object TypeAdapter : SafeEnumTypeAdapter( + knownMappings = mapOf( + LikelyToReturnIfCalledScoreType to "likely_to_return_if_called", + LikelyToReturnIfNotCalledIn15DaysScoreType to "likely_to_return_if_not_called_in_15_days", + ), + unknownStringToEnumConverter = ::Unknown, + unknownEnumToStringConverter = { (it as Unknown).actualValue } + ) + + class RoomTypeConverter { + + @TypeConverter + fun toEnum(value: String?): ScoreType? = TypeAdapter.toEnum(value) + + @TypeConverter + fun fromEnum(scoreType: ScoreType?): String? = TypeAdapter.fromEnum(scoreType) + } + + class MoshiTypeAdapter { + + @FromJson + fun fromJson(value: String?): ScoreType? = TypeAdapter.toEnum(value) + + @ToJson + fun toJson(scoreType: ScoreType?): String? = TypeAdapter.fromEnum(scoreType) + } + + companion object { + @VisibleForTesting + fun random(): ScoreType = TypeAdapter.knownMappings.keys.shuffled().first() + } +} + +@Parcelize +data object LikelyToReturnIfCalledScoreType : ScoreType() + +@Parcelize +data object LikelyToReturnIfNotCalledIn15DaysScoreType : ScoreType() + + +@Parcelize +data class Unknown(val actualValue: String) : ScoreType() + diff --git a/app/src/main/java/org/simple/clinic/returnscore/di/ReturnScoreModule.kt b/app/src/main/java/org/simple/clinic/returnscore/di/ReturnScoreModule.kt new file mode 100644 index 00000000000..058969c876b --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/di/ReturnScoreModule.kt @@ -0,0 +1,36 @@ +package org.simple.clinic.returnscore.di + +import com.f2prateek.rx.preferences2.Preference +import com.f2prateek.rx.preferences2.RxSharedPreferences +import dagger.Module +import dagger.Provides +import org.simple.clinic.AppDatabase +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.returnscore.ReturnScore +import org.simple.clinic.returnscore.sync.ReturnScoreSyncApi +import org.simple.clinic.util.preference.StringPreferenceConverter +import org.simple.clinic.util.preference.getOptional +import retrofit2.Retrofit +import java.util.Optional +import javax.inject.Named + +@Module +open class ReturnScoreModule { + + @Provides + fun dao(appDatabase: AppDatabase): ReturnScore.RoomDao { + return appDatabase.returnScoreDao() + } + + @Provides + fun syncApi(@Named("for_deployment") retrofit: Retrofit): ReturnScoreSyncApi { + return retrofit.create(ReturnScoreSyncApi::class.java) + } + + @Provides + @TypedPreference(TypedPreference.Type.LastReturnScorePullToken) + fun lastPullToken(rxSharedPrefs: RxSharedPreferences): Preference> { + return rxSharedPrefs.getOptional("last_return_score_pull_token_v1", StringPreferenceConverter()) + } +} + diff --git a/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScorePayload.kt b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScorePayload.kt new file mode 100644 index 00000000000..7defaf53ea6 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScorePayload.kt @@ -0,0 +1,46 @@ +package org.simple.clinic.returnscore.sync + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.simple.clinic.returnscore.ReturnScore +import org.simple.clinic.returnscore.ScoreType +import org.simple.clinic.storage.Timestamps +import java.time.Instant +import java.util.UUID + +@JsonClass(generateAdapter = true) +data class ReturnScorePayload( + @Json(name = "id") + val uuid: UUID, + + @Json(name = "patient_id") + val patientUuid: UUID, + + @Json(name = "score_type") + val scoreType: ScoreType, + + @Json(name = "score_value") + val scoreValue: Float, + + @Json(name = "created_at") + val createdAt: Instant, + + @Json(name = "updated_at") + val updatedAt: Instant, + + @Json(name = "deleted_at") + val deletedAt: Instant?, +) { + + fun toDatabaseModel() = ReturnScore( + uuid = uuid, + patientUuid = patientUuid, + scoreType = scoreType, + scoreValue = scoreValue, + timestamps = Timestamps( + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt + ) + ) +} diff --git a/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScorePullResponse.kt b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScorePullResponse.kt new file mode 100644 index 00000000000..61d9e369028 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScorePullResponse.kt @@ -0,0 +1,16 @@ +package org.simple.clinic.returnscore.sync + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.simple.clinic.sync.DataPullResponse + +@JsonClass(generateAdapter = true) +data class ReturnScorePullResponse( + + @Json(name = "patient_scores") + override val payloads: List, + + @Json(name = "process_token") + override val processToken: String + +) : DataPullResponse diff --git a/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScoreSync.kt b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScoreSync.kt new file mode 100644 index 00000000000..f4af766aac3 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScoreSync.kt @@ -0,0 +1,35 @@ +package org.simple.clinic.returnscore.sync + +import com.f2prateek.rx.preferences2.Preference +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.returnscore.ReturnScoreRepository +import org.simple.clinic.sync.ModelSync +import org.simple.clinic.sync.SyncConfig +import org.simple.clinic.sync.SyncConfigType +import org.simple.clinic.sync.SyncCoordinator +import org.simple.clinic.util.read +import java.util.Optional +import javax.inject.Inject + +class ReturnScoreSync @Inject constructor( + private val syncCoordinator: SyncCoordinator, + private val api: ReturnScoreSyncApi, + private val repository: ReturnScoreRepository, + @TypedPreference(TypedPreference.Type.LastReturnScorePullToken) private val lastPullToken: Preference>, + @SyncConfigType(SyncConfigType.Type.Frequent) private val config: SyncConfig +) : ModelSync { + + override val name: String = "ReturnScore" + + override val requiresSyncApprovedUser = true + + override fun push() { + /* Nothing to do here */ + } + + override fun pull() { + val batchSize = config.pullBatchSize + + syncCoordinator.pull(repository, lastPullToken, batchSize) { api.pull(batchSize, it).execute().read()!! } + } +} diff --git a/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScoreSyncApi.kt b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScoreSyncApi.kt new file mode 100644 index 00000000000..9c59680b9f5 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/returnscore/sync/ReturnScoreSyncApi.kt @@ -0,0 +1,17 @@ +package org.simple.clinic.returnscore.sync + +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Query + +interface ReturnScoreSyncApi { + + @Headers(value = ["X-RESYNC-TOKEN: 1"]) + @GET("v4/patient_scores/sync") + fun pull( + @Query("limit") recordsToPull: Int, + @Query("process_token") lastPullToken: String? = null, + ): Call +} + diff --git a/app/src/main/java/org/simple/clinic/storage/migrations/Migration_123.kt b/app/src/main/java/org/simple/clinic/storage/migrations/Migration_123.kt new file mode 100644 index 00000000000..4e50a1a3b6b --- /dev/null +++ b/app/src/main/java/org/simple/clinic/storage/migrations/Migration_123.kt @@ -0,0 +1,28 @@ +package org.simple.clinic.storage.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.simple.clinic.storage.inTransaction +import javax.inject.Inject + +@Suppress("ClassName") +class Migration_123 @Inject constructor() : Migration(122, 123) { + override fun migrate(db: SupportSQLiteDatabase) { + db.inTransaction { + execSQL(""" + CREATE TABLE IF NOT EXISTS "ReturnScore" ( + "uuid" TEXT NOT NULL, + "patientUuid" TEXT NOT NULL, + "scoreType" TEXT NOT NULL, + "scoreValue" REAL NOT NULL, + "createdAt" TEXT NOT NULL, + "updatedAt" TEXT NOT NULL, + "deletedAt" TEXT, + PRIMARY KEY("uuid") + ) + """) + + execSQL("CREATE INDEX IF NOT EXISTS `index_ReturnScore_patientUuid` ON `ReturnScore` (`patientUuid`)") + } + } +} diff --git a/app/src/main/java/org/simple/clinic/storage/migrations/RoomMigrationsModule.kt b/app/src/main/java/org/simple/clinic/storage/migrations/RoomMigrationsModule.kt index 0d66b164cc3..a315d83efc9 100644 --- a/app/src/main/java/org/simple/clinic/storage/migrations/RoomMigrationsModule.kt +++ b/app/src/main/java/org/simple/clinic/storage/migrations/RoomMigrationsModule.kt @@ -128,6 +128,7 @@ class RoomMigrationsModule { migration120: Migration_120, migration121: Migration_121, migration122: Migration_122, + migration123: Migration_123, ): List { return listOf( migration_3_4, @@ -249,6 +250,7 @@ class RoomMigrationsModule { migration120, migration121, migration122, + migration123 ) } } diff --git a/app/src/main/java/org/simple/clinic/sync/SyncModule.kt b/app/src/main/java/org/simple/clinic/sync/SyncModule.kt index b8c2496ff51..28758398dea 100644 --- a/app/src/main/java/org/simple/clinic/sync/SyncModule.kt +++ b/app/src/main/java/org/simple/clinic/sync/SyncModule.kt @@ -48,6 +48,8 @@ import org.simple.clinic.questionnaireresponse.di.QuestionnaireResponseModule import org.simple.clinic.questionnaireresponse.sync.QuestionnaireResponseSync import org.simple.clinic.reports.ReportsModule import org.simple.clinic.reports.ReportsSync +import org.simple.clinic.returnscore.di.ReturnScoreModule +import org.simple.clinic.returnscore.sync.ReturnScoreSync import org.simple.clinic.summary.teleconsultation.sync.TeleconsultationSync import org.simple.clinic.teleconsultlog.teleconsultrecord.TeleconsultRecordRepository import org.simple.clinic.teleconsultlog.teleconsultrecord.TeleconsultRecordSync @@ -74,7 +76,8 @@ import javax.inject.Named QuestionnaireModule::class, QuestionnaireResponseModule::class, PatientAttributeModule::class, - CVDRiskModule::class + CVDRiskModule::class, + ReturnScoreModule::class, ]) class SyncModule { @@ -99,6 +102,7 @@ class SyncModule { questionnaireResponseSync: QuestionnaireResponseSync, patientAttributeSync: PatientAttributeSync, cvdRiskSync: CVDRiskSync, + returnScoreSync: ReturnScoreSync ): List { val optionalSyncs = if (features.isEnabled(Feature.CallResultSyncEnabled)) listOf(callResultSync) else emptyList() @@ -106,7 +110,7 @@ class SyncModule { questionnaireSync, questionnaireResponseSync, protocolSync, reportsSync, helpSync, patientSync, bloodPressureSync, medicalHistorySync, appointmentSync, prescriptionSync, bloodSugarSync, teleconsultationMedicalOfficersSync, - teleconsultRecordSync, drugSync, patientAttributeSync, cvdRiskSync + teleconsultRecordSync, drugSync, patientAttributeSync, cvdRiskSync, returnScoreSync ) + optionalSyncs } diff --git a/app/src/test/java/org/simple/clinic/home/overdue/OverdueAppointmentSorterTest.kt b/app/src/test/java/org/simple/clinic/home/overdue/OverdueAppointmentSorterTest.kt new file mode 100644 index 00000000000..f96e254a376 --- /dev/null +++ b/app/src/test/java/org/simple/clinic/home/overdue/OverdueAppointmentSorterTest.kt @@ -0,0 +1,282 @@ +package org.simple.clinic.home.overdue + +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever +import org.simple.clinic.TestData +import org.simple.clinic.TestData.overdueAppointment +import org.simple.clinic.feature.Feature +import org.simple.clinic.feature.Features +import org.simple.clinic.returnscore.LikelyToReturnIfNotCalledIn15DaysScoreType +import org.simple.clinic.returnscore.ReturnScore +import org.simple.clinic.storage.Timestamps +import java.time.Instant +import java.util.UUID +import kotlin.random.Random + +class OverdueAppointmentSorterTest { + + private lateinit var returnScoreDao: ReturnScore.RoomDao + private lateinit var features: Features + private lateinit var sorter: OverdueAppointmentSorter + + @Before + fun setup() { + returnScoreDao = mock() + features = mock() + + sorter = OverdueAppointmentSorter( + returnScoreDao = returnScoreDao, + features = features, + random = Random(123) + ) + } + + @Test + fun `returns original list when feature disabled`() { + val list = (1..5).map { overdueAppointment(UUID.randomUUID()) } + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(false) + + val result = sorter.sort(list) + + assertThat(result.map { it.appointment }) + .containsExactlyElementsIn(list) + .inOrder() + + assertThat(result.all { it.score == 0f }).isTrue() + assertThat(result.all { it.bucket == OverdueBucket.REMAINING }).isTrue() + } + + @Test + fun `does not lose or duplicate patients`() { + val list = (1..20).map { overdueAppointment(UUID.randomUUID()) } + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + whenever(returnScoreDao.getAllImmediate()).thenReturn(emptyList()) + + val result = sorter.sort(list) + + val input = list.map { it.appointment.patientUuid } + val output = result.map { it.appointment.appointment.patientUuid } + + assertThat(output).containsExactlyElementsIn(input) + } + + @Test + fun `higher score patients appear before lower score patients`() { + val p1 = UUID.randomUUID() + val p2 = UUID.randomUUID() + + val list = listOf( + overdueAppointment(patientUuid = p1), + overdueAppointment(patientUuid = p2) + ) + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + whenever(returnScoreDao.getAllImmediate()).thenReturn( + listOf( + TestData.returnScore(patientUuid = p1, scoreValue = 10f), + TestData.returnScore(patientUuid = p2, scoreValue = 50f), + ) + ) + + val result = sorter.sort(list) + val uuids = result.map { it.appointment.appointment.patientUuid } + + assertThat(uuids.indexOf(p2)).isLessThan(uuids.indexOf(p1)) + } + + @Test + fun `same seed produces same result`() { + val uuids = (1..10).map { UUID.randomUUID() } + val list = uuids.map { overdueAppointment(patientUuid = it) } + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + val scores = uuids.mapIndexed { index, uuid -> + TestData.returnScore(patientUuid = uuid, scoreValue = (100 - index).toFloat()) + } + + whenever(returnScoreDao.getAllImmediate()).thenReturn(scores) + + val sorter1 = OverdueAppointmentSorter(returnScoreDao, features, Random(123)) + val sorter2 = OverdueAppointmentSorter(returnScoreDao, features, Random(123)) + + val result1 = sorter1.sort(list) + val result2 = sorter2.sort(list) + + assertThat(result1.map { it.appointment.appointment.patientUuid }) + .containsExactlyElementsIn(result2.map { it.appointment.appointment.patientUuid }) + .inOrder() + } + + @Test + fun `sorts by score descending`() { + val p1 = UUID.randomUUID() + val p2 = UUID.randomUUID() + val p3 = UUID.randomUUID() + + val list = listOf( + overdueAppointment(patientUuid = p1), + overdueAppointment(patientUuid = p2), + overdueAppointment(patientUuid = p3) + ) + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + whenever(returnScoreDao.getAllImmediate()).thenReturn( + listOf( + TestData.returnScore(patientUuid = p1, scoreValue = 10f), + TestData.returnScore(patientUuid = p2, scoreValue = 50f), + TestData.returnScore(patientUuid = p3, scoreValue = 30f), + ) + ) + + val result = sorter.sort(list) + + val uuids = result.map { it.appointment.appointment.patientUuid } + + assertThat(uuids.indexOf(p2)).isLessThan(uuids.indexOf(p1)) + assertThat(uuids.indexOf(p3)).isLessThan(uuids.indexOf(p1)) + } + + @Test + fun `ignores non LikelyToReturnIfCalled score types`() { + val uuid = UUID.randomUUID() + + val list = listOf(overdueAppointment(patientUuid = uuid)) + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + whenever(returnScoreDao.getAllImmediate()).thenReturn( + listOf( + ReturnScore( + uuid = UUID.randomUUID(), + patientUuid = uuid, + scoreType = LikelyToReturnIfNotCalledIn15DaysScoreType, + scoreValue = 100f, + timestamps = Timestamps( + createdAt = Instant.now(), + updatedAt = Instant.now(), + deletedAt = null + ) + ), + TestData.returnScore(uuid, scoreValue = 10f) + ) + ) + + val result = sorter.sort(list) + + val first = result.first().appointment.appointment.patientUuid + + assertThat(first).isEqualTo(uuid) + } + + @Test + fun `uses default score when missing`() { + val p1 = UUID.randomUUID() + val p2 = UUID.randomUUID() + + val list = listOf( + overdueAppointment(patientUuid = p1), + overdueAppointment(patientUuid = p2) + ) + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + whenever(returnScoreDao.getAllImmediate()).thenReturn( + listOf(TestData.returnScore(patientUuid = p1, scoreValue = 50f)) + ) + + val result = sorter.sort(list) + + val sorted = result.map { it.appointment.appointment.patientUuid } + + assertThat(sorted.first()).isEqualTo(p1) + } + + @Test + fun `picks patients only from top 20 and next 30 buckets`() { + val uuids = (1..10).map { UUID.randomUUID() } + val list = uuids.map { overdueAppointment(patientUuid = it) } + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + val scores = uuids.mapIndexed { index, uuid -> + TestData.returnScore(patientUuid = uuid, scoreValue = (100 - index).toFloat()) + } + + whenever(returnScoreDao.getAllImmediate()).thenReturn(scores) + + val result = sorter.sort(list) + + val top20 = uuids.take(2) + val next30 = uuids.subList(2, 5) + + val picked = result.take(2).map { it.appointment.appointment.patientUuid } + + picked.forEach { + assertThat(it in (top20 + next30)).isTrue() + } + } + + @Test + fun `handles single item safely`() { + val uuid = UUID.randomUUID() + + val list = listOf(overdueAppointment(patientUuid = uuid)) + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + whenever(returnScoreDao.getAllImmediate()).thenReturn( + listOf(TestData.returnScore(patientUuid = uuid, scoreValue = 10f)) + ) + + val result = sorter.sort(list) + + assertThat(result).hasSize(1) + } + + @Test + fun `all patients get default score when no valid score type`() { + val uuids = (1..5).map { UUID.randomUUID() } + val list = uuids.map { overdueAppointment(patientUuid = it) } + + whenever(features.isEnabled(Feature.SortOverdueBasedOnReturnScore)) + .thenReturn(true) + + whenever(returnScoreDao.getAllImmediate()).thenReturn( + uuids.map { + ReturnScore( + uuid = UUID.randomUUID(), + patientUuid = it, + scoreType = LikelyToReturnIfNotCalledIn15DaysScoreType, + scoreValue = 100f, + timestamps = Timestamps( + createdAt = Instant.now(), + updatedAt = Instant.now(), + deletedAt = null + ) + ) + } + ) + + val result = sorter.sort(list) + + assertThat(result).hasSize(list.size) + } +} diff --git a/app/src/test/java/org/simple/clinic/home/overdue/OverdueEffectHandlerTest.kt b/app/src/test/java/org/simple/clinic/home/overdue/OverdueEffectHandlerTest.kt index a3c0913a71f..1b41864addd 100644 --- a/app/src/test/java/org/simple/clinic/home/overdue/OverdueEffectHandlerTest.kt +++ b/app/src/test/java/org/simple/clinic/home/overdue/OverdueEffectHandlerTest.kt @@ -9,6 +9,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import org.simple.clinic.TestData import org.simple.clinic.analytics.NetworkCapabilitiesProvider import org.simple.clinic.facility.FacilityConfig import org.simple.clinic.mobius.EffectHandlerTestCase @@ -19,9 +20,8 @@ import org.simple.clinic.overdue.callresult.Outcome import org.simple.clinic.overdue.download.OverdueDownloadScheduler import org.simple.clinic.overdue.download.OverdueListFileFormat.CSV import org.simple.clinic.util.PagerFactory -import org.simple.clinic.util.scheduler.TestSchedulersProvider -import org.simple.clinic.TestData import org.simple.clinic.util.TestUserClock +import org.simple.clinic.util.scheduler.TestSchedulersProvider import java.time.Instant import java.time.LocalDate import java.util.UUID @@ -48,6 +48,8 @@ class OverdueEffectHandlerTest { private val viewEffectHandler = OverdueViewEffectHandler(uiActions) private val appointmentRepository = mock() private val overdueAppointmentSelector = mock() + private val overdueAppointmentSorter = mock() + private val effectHandler = OverdueEffectHandler( schedulers = TestSchedulersProvider.trampoline(), appointmentRepository = appointmentRepository, @@ -57,6 +59,7 @@ class OverdueEffectHandlerTest { overdueDownloadScheduler = overdueDownloadScheduler, userClock = TestUserClock(Instant.parse("2018-01-01T00:00:00Z")), overdueAppointmentSelector = overdueAppointmentSelector, + overdueAppointmentSorter = overdueAppointmentSorter, viewEffectsConsumer = viewEffectHandler::handle ).build() private val effectHandlerTestCase = EffectHandlerTestCase(effectHandler) @@ -160,7 +163,8 @@ class OverdueEffectHandlerTest { overdueDownloadScheduler = overdueDownloadScheduler, userClock = TestUserClock(Instant.parse("2018-03-01T00:00:00Z")), overdueAppointmentSelector = overdueAppointmentSelector, - viewEffectsConsumer = viewEffectHandler::handle + viewEffectsConsumer = viewEffectHandler::handle, + overdueAppointmentSorter = overdueAppointmentSorter, ).build() val effectHandlerTestCase = EffectHandlerTestCase(effectHandler) @@ -242,6 +246,16 @@ class OverdueEffectHandlerTest { facilityId = facility.uuid )) doReturn Observable.just(overdueAppointments) + whenever(overdueAppointmentSorter.sort(listOf(pendingAppointment))) + .thenReturn( + listOf( + SortedOverdueAppointment( + appointment = pendingAppointment, + score = 0f, + bucket = OverdueBucket.REMAINING + ) + ) + ) // when effectHandlerTestCase.dispatch(LoadOverdueAppointments( overdueSince = LocalDate.parse("2018-04-03"), @@ -252,6 +266,9 @@ class OverdueEffectHandlerTest { effectHandlerTestCase.assertOutgoingEvents(OverdueAppointmentsLoaded( overdueAppointmentSections = OverdueAppointmentSections( pendingAppointments = listOf(pendingAppointment), + pendingDebugInfo = mapOf( + pendingAppointment.appointment.patientUuid to (0f to OverdueBucket.REMAINING) + ), agreedToVisitAppointments = listOf(agreedToVisitAppointment), remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = listOf(removedAppointment), diff --git a/app/src/test/java/org/simple/clinic/home/overdue/OverdueUiRendererTest.kt b/app/src/test/java/org/simple/clinic/home/overdue/OverdueUiRendererTest.kt index 46c2ebd50f9..0ca1b55c795 100644 --- a/app/src/test/java/org/simple/clinic/home/overdue/OverdueUiRendererTest.kt +++ b/app/src/test/java/org/simple/clinic/home/overdue/OverdueUiRendererTest.kt @@ -1,18 +1,18 @@ package org.simple.clinic.home.overdue +import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions -import org.junit.Test -import org.simple.clinic.home.overdue.PendingListState.SEE_LESS import org.simple.clinic.TestData +import org.simple.clinic.home.overdue.PendingListState.SEE_LESS import java.util.UUID class OverdueUiRendererTest { private val ui = mock() private val uiRenderer = OverdueUiRenderer( - ui = ui + ui = ui ) private val defaultModel = OverdueModel.create() @@ -31,7 +31,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = agreedToVisitAppointments, remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = removedFromOverdueAppointments, - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() ) ) .selectedOverdueAppointmentsChanged(selectedAppointments) @@ -54,7 +55,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = agreedToVisitAppointments, remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = removedFromOverdueAppointments, - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() ), selectedAppointments, overdueListSectionStates = overdueListSectionStates @@ -76,7 +78,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = emptyList(), remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = emptyList(), - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() ) ) val overdueListSectionStates = OverdueListSectionStates( @@ -99,7 +102,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = emptyList(), remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = emptyList(), - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() ), emptySet(), overdueListSectionStates = overdueListSectionStates @@ -143,7 +147,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = emptyList(), remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = emptyList(), - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() )) .selectedOverdueAppointmentsChanged(selectedAppointments) @@ -157,7 +162,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = emptyList(), remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = emptyList(), - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() ), selectedOverdueAppointments = selectedAppointments, overdueListSectionStates = selectedAppointmentsModel.overdueListSectionStates @@ -183,7 +189,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = emptyList(), remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = emptyList(), - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() )) .selectedOverdueAppointmentsChanged(emptySet()) @@ -197,7 +204,8 @@ class OverdueUiRendererTest { agreedToVisitAppointments = emptyList(), remindToCallLaterAppointments = emptyList(), removedFromOverdueAppointments = emptyList(), - moreThanAnYearOverdueAppointments = emptyList() + moreThanAnYearOverdueAppointments = emptyList(), + pendingDebugInfo = emptyMap() ), selectedOverdueAppointments = emptySet(), overdueListSectionStates = selectedAppointmentsModel.overdueListSectionStates diff --git a/app/src/test/java/org/simple/clinic/home/overdue/OverdueUpdateTest.kt b/app/src/test/java/org/simple/clinic/home/overdue/OverdueUpdateTest.kt index e2a7b42b09d..997e41980da 100644 --- a/app/src/test/java/org/simple/clinic/home/overdue/OverdueUpdateTest.kt +++ b/app/src/test/java/org/simple/clinic/home/overdue/OverdueUpdateTest.kt @@ -7,6 +7,7 @@ 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.analytics.NetworkConnectivityStatus.ACTIVE import org.simple.clinic.analytics.NetworkConnectivityStatus.INACTIVE import org.simple.clinic.facility.FacilityConfig @@ -18,7 +19,6 @@ import org.simple.clinic.home.overdue.OverdueAppointmentSectionTitle.REMOVED_FRO import org.simple.clinic.home.overdue.PendingListState.SEE_ALL import org.simple.clinic.home.overdue.PendingListState.SEE_LESS import org.simple.clinic.overdue.download.OverdueListFileFormat.CSV -import org.simple.clinic.TestData import java.time.LocalDate import java.util.Optional import java.util.UUID @@ -161,6 +161,7 @@ class OverdueUpdateTest { val overdueAppointmentSections = OverdueAppointmentSections( pendingAppointments = pendingAppointments, + pendingDebugInfo = emptyMap(), agreedToVisitAppointments = agreedToVisitAppointments, remindToCallLaterAppointments = remindToCallLaterAppointments, removedFromOverdueAppointments = removedFromOverdueAppointments, diff --git a/app/src/testFixtures/kotlin/org/simple/clinic/TestData.kt b/app/src/testFixtures/kotlin/org/simple/clinic/TestData.kt index 25f0e6275b3..91e57da9e7b 100644 --- a/app/src/testFixtures/kotlin/org/simple/clinic/TestData.kt +++ b/app/src/testFixtures/kotlin/org/simple/clinic/TestData.kt @@ -96,6 +96,9 @@ import org.simple.clinic.questionnaire.component.properties.InputFieldValidation import org.simple.clinic.questionnaire.component.properties.IntegerType import org.simple.clinic.questionnaire.component.properties.StringType import org.simple.clinic.questionnaireresponse.QuestionnaireResponse +import org.simple.clinic.returnscore.LikelyToReturnIfCalledScoreType +import org.simple.clinic.returnscore.ReturnScore +import org.simple.clinic.returnscore.ScoreType import org.simple.clinic.scanid.IndiaNHIDDateOfBirth import org.simple.clinic.scanid.IndiaNHIDGender import org.simple.clinic.scanid.IndiaNHIDInfoPayload @@ -2004,4 +2007,24 @@ object TestData { ) ) } + + fun returnScore( + uuid: UUID = UUID.fromString("f9a42c9f-01fe-40c5-b625-64b3e9868d5e"), + patientUuid: UUID = UUID.fromString("f9a42c9f-01fe-40c5-b625-64b3e9868d5e"), + scoreType: ScoreType = LikelyToReturnIfCalledScoreType, + scoreValue: Float = 0f, + createdAt: Instant = Instant.now(), + updatedAt: Instant = Instant.now(), + deletedAt: Instant? = null, + ) = ReturnScore( + uuid = uuid, + patientUuid = patientUuid, + scoreType = scoreType, + scoreValue = scoreValue, + timestamps = Timestamps( + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt + ) + ) } diff --git a/gradle.properties b/gradle.properties index eacd7a6dbb3..6c4b0e6a68a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -47,13 +47,9 @@ disableScreenshot=false allowRootedDevice=true maestroTests=false org.gradle.unsafe.configuration-cache=true - # AGP 9 compatibility flags (temporary) # Remove gradually before AGP 10 android.uniquePackageNames=false android.enableAppCompileTimeRClass=false android.r8.optimizedResourceShrinking=false -# remove this when sentry-android-gradle-plugin releases version 6.x.x -# https://github.com/getsentry/sentry-android-gradle-plugin/issues/1004 -android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9bce57d85e5..4a7b4198074 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ agp = "9.0.1" androidx-cameraView = "1.5.3" androidx-camera = "1.5.3" -androidx-paging = "3.3.6" +androidx-paging = "3.4.2" androidx-room = "2.8.4" androidx-work = "2.11.1" androidx-security-crypto = "1.1.0" @@ -12,11 +12,12 @@ androidx-lifecycle = "2.10.0" androidx-activity = "1.12.4" chucker = "4.3.0" -dagger = "2.59.1" +dagger = "2.59.2" -kotlin = "2.3.10" +kotlin = "2.3.20" +kotlinx-serialization = "1.10.0" -ksp = "2.3.5" +ksp = "2.3.6" ktlint = "0.36.0" @@ -38,7 +39,7 @@ coroutines = "1.10.2" compose-compiler = "1.5.13" -androidx-compose-bom = "2026.02.00" +androidx-compose-bom = "2026.03.00" composeThemeAdapter = "0.36.0" @@ -116,7 +117,7 @@ itemanimators = "com.mikepenz:itemanimators:1.1.0" itext7 = "com.itextpdf:itext7-core:7.2.5" -jackson-core = "com.fasterxml.jackson.core:jackson-core:2.21.0" +jackson-core = "com.fasterxml.jackson.core:jackson-core:2.21.1" jbcrypt = "org.mindrot:jbcrypt:0.4" @@ -127,6 +128,7 @@ kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2", v kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } leakcanary = "com.squareup.leakcanary:leakcanary-android:2.14" @@ -160,7 +162,7 @@ okhttp-interceptor-logging = { module = "com.squareup.okhttp3:logging-intercepto openCsv = "com.opencsv:opencsv:5.12.0" play-app-update = "com.google.android.play:app-update-ktx:2.1.0" -play-services-auth = "com.google.android.gms:play-services-auth:21.5.0" +play-services-auth = "com.google.android.gms:play-services-auth:21.5.1" play-services-location = "com.google.android.gms:play-services-location:21.3.0" play-services-mlkit-barcode = "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1" @@ -179,7 +181,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.32.0" +sentry-android = "io.sentry:sentry-android:8.36.0" signaturepad = "com.github.gcacace:signature-pad:1.3.1" @@ -223,13 +225,13 @@ sqlCipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlCipher android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-lint = { id = "com.android.lint", version.ref = "agp" } -androidx-benchmark = { id = "androidx.benchmark", version = "1.4.1" } +androidx-benchmark = { id = "androidx.benchmark", version = "1.5.0-alpha04" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } 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 = "6.0.0" } +sentry = { id = "io.sentry.android.gradle", version = "6.2.0" } [bundles] androidx-camera = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-view", "androidx-camera-lifecycle"]