diff --git a/.gitignore b/.gitignore index fda16bc81..16783928c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ debug-keystore-base64.txt .claude/ .firebaserc firebase.json + +.env +.DS_Store diff --git a/app/build.gradle b/app/build.gradle index 2aada76b8..8dcdc02c1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -212,6 +212,8 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.recyclerview:recyclerview:1.4.0' + // Monthly Recap: animated welcome character on the recap language screen. + implementation 'com.airbnb.android:lottie:6.4.0' //Paging implementation "androidx.paging:paging-runtime-ktx:3.2.1" diff --git a/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/CbacDaoMonthlyRecapTest.kt b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/CbacDaoMonthlyRecapTest.kt new file mode 100644 index 000000000..7563258fd --- /dev/null +++ b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/CbacDaoMonthlyRecapTest.kt @@ -0,0 +1,338 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.piramalswasthya.sakhi.database.room.InAppDb +import org.piramalswasthya.sakhi.database.room.SyncState +import org.piramalswasthya.sakhi.model.CbacCache + +/** + * Phase 4.4 — REAL Room instrumented test for the Monthly Recap CBAC metric query. + * + * Unlike the JVM unit test [org.piramalswasthya.sakhi.repositories.CbacRecapDataSourceTest] + * (which mocks the DAO and therefore never executes any SQL), this test runs the + * ACTUAL generated `CbacDao_Impl.countCurrentAshaScreenings(...)` against real rows + * in a real (in-memory) instance of the production [InAppDb]. It proves the SQL + * row semantics: the ownership predicate, the [start, end) window, COUNT(DISTINCT + * benId), and sync-state independence. + * + * Production query under test (READ-ONLY, unchanged by this test): + * SELECT COUNT(DISTINCT benId) FROM CBAC + * WHERE fillDate >= :startInclusive AND fillDate < :endExclusive + * AND (ashaId = :userId OR (ashaId = 0 AND createdBy = :userName)) + * + * Isolation: a fresh in-memory database per test (@Before/@After). The in-memory + * builder uses the framework SQLite open helper, NOT the app's SQLCipher factory, + * so encryption config is irrelevant to query semantics and the real app database + * file is never touched. Room enables `PRAGMA foreign_keys = ON` (the schema has + * FKs), so CBAC rows require real BENEFICIARY parents, which require a HOUSEHOLD + * parent — those are inserted honestly (foreign keys are never disabled). + */ +@RunWith(AndroidJUnit4::class) +class CbacDaoMonthlyRecapTest { + + private lateinit var db: InAppDb + private lateinit var cbacDao: CbacDao + private lateinit var support: SupportSQLiteDatabase + + // Test-only identities. NEVER production user id 299. + private val userId = 42 + private val userName = "test_asha" + private val otherUserId = 77 + private val otherUserName = "other_asha" + + // Frozen recap window [start, end). Synthetic millis keep boundaries crisp. + private val windowStart = 1_000_000L + private val windowEnd = 2_000_000L + private val inWindow = 1_500_000L + + // Single parent household (FK root) shared by all test beneficiaries. + private val hhId = 9_001L + + @Before + fun setUp() { + val ctx = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(ctx, InAppDb::class.java) + .allowMainThreadQueries() + .build() + cbacDao = db.cbacDao + // Opening the writable handle runs Room's onOpen -> PRAGMA foreign_keys = ON. + support = db.openHelper.writableDatabase + insertMinimalRow("HOUSEHOLD", mapOf("householdId" to hhId)) + } + + @After + fun tearDown() { + db.close() + } + + // --------------------------------------------------------------------- + // 1. Current ASHA local row -> counted + // --------------------------------------------------------------------- + @Test + fun case01_currentAsha_localRow_isCounted() { + insertCbac(benId = 101, ashaId = userId, createdBy = userName, fillDate = inWindow) + assertEquals(1, count()) + } + + // --------------------------------------------------------------------- + // 2. Current ASHA downloaded row (ashaId = 0, createdBy = me) -> counted + // --------------------------------------------------------------------- + @Test + fun case02_currentAsha_downloadedRow_isCounted() { + insertCbac(benId = 102, ashaId = 0, createdBy = userName, fillDate = inWindow) + assertEquals(1, count()) + } + + // --------------------------------------------------------------------- + // 3. Downloaded row belonging to another username -> excluded + // --------------------------------------------------------------------- + @Test + fun case03_downloaded_otherUsername_isExcluded() { + insertCbac(benId = 103, ashaId = 0, createdBy = otherUserName, fillDate = inWindow) + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 4. Downloaded row with NULL createdBy -> excluded + // --------------------------------------------------------------------- + @Test + fun case04_downloaded_nullCreatedBy_isExcluded() { + insertCbac(benId = 104, ashaId = 0, createdBy = null, fillDate = inWindow) + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 5. Downloaded row with blank createdBy -> excluded (exact-equality SQL: + // '' never equals 'test_asha'). Blank ownership is NOT supported, which + // is the correct behaviour; the production query is left unchanged. + // --------------------------------------------------------------------- + @Test + fun case05_downloaded_blankCreatedBy_isExcluded() { + insertCbac(benId = 105, ashaId = 0, createdBy = "", fillDate = inWindow) + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 6. Another ASHA's local row (ashaId = 77) -> excluded + // --------------------------------------------------------------------- + @Test + fun case06_otherAsha_localRow_isExcluded() { + insertCbac(benId = 106, ashaId = otherUserId, createdBy = otherUserName, fillDate = inWindow) + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 7. Local + downloaded copy of the SAME beneficiary -> COUNT(DISTINCT benId) = 1 + // --------------------------------------------------------------------- + @Test + fun case07_localAndDownloadedDuplicate_distinctBenId_countsOne() { + insertCbac(benId = 107, ashaId = userId, createdBy = userName, fillDate = inWindow) + insertCbac(benId = 107, ashaId = 0, createdBy = userName, fillDate = inWindow + 1) + assertEquals(1, count()) + } + + // --------------------------------------------------------------------- + // 8. Two different beneficiaries (one local, one downloaded) -> 2 + // --------------------------------------------------------------------- + @Test + fun case08_twoDifferentBeneficiaries_countsTwo() { + insertCbac(benId = 108, ashaId = userId, createdBy = userName, fillDate = inWindow) + insertCbac(benId = 109, ashaId = 0, createdBy = userName, fillDate = inWindow) + assertEquals(2, count()) + } + + // --------------------------------------------------------------------- + // 9. fillDate == startInclusive -> included (>=) + // --------------------------------------------------------------------- + @Test + fun case09_startInclusiveBoundary_isIncluded() { + insertCbac(benId = 110, ashaId = userId, createdBy = userName, fillDate = windowStart) + assertEquals(1, count()) + } + + // --------------------------------------------------------------------- + // 10. fillDate == endExclusive -> excluded (< end) + // --------------------------------------------------------------------- + @Test + fun case10_endExclusiveBoundary_isExcluded() { + insertCbac(benId = 111, ashaId = userId, createdBy = userName, fillDate = windowEnd) + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 11. fillDate < startInclusive -> excluded + // --------------------------------------------------------------------- + @Test + fun case11_beforeWindow_isExcluded() { + insertCbac(benId = 112, ashaId = userId, createdBy = userName, fillDate = windowStart - 1) + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 12. fillDate > endExclusive -> excluded + // --------------------------------------------------------------------- + @Test + fun case12_afterWindow_isExcluded() { + insertCbac(benId = 113, ashaId = userId, createdBy = userName, fillDate = windowEnd + 1) + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 13. Sync state must NOT filter: UNSYNCED, SYNCING, SYNCED all count. + // Three distinct beneficiaries -> 3 proves every row was counted. + // --------------------------------------------------------------------- + @Test + fun case13_syncStates_doNotMatter_allCount() { + insertCbac(benId = 114, ashaId = userId, createdBy = userName, fillDate = inWindow, syncState = SyncState.UNSYNCED) + insertCbac(benId = 115, ashaId = userId, createdBy = userName, fillDate = inWindow, syncState = SyncState.SYNCING) + insertCbac(benId = 116, ashaId = userId, createdBy = userName, fillDate = inWindow, syncState = SyncState.SYNCED) + assertEquals(3, count()) + } + + // --------------------------------------------------------------------- + // 14. No matching rows -> 0 + // --------------------------------------------------------------------- + @Test + fun case14_emptyResult_isZero() { + assertEquals(0, count()) + } + + // --------------------------------------------------------------------- + // 15. Same beneficiary inside AND outside the window contributes exactly 1 + // to the target-month count. + // --------------------------------------------------------------------- + @Test + fun case15_sameBeneficiaryInsideAndOutside_countsOneForMonth() { + insertCbac(benId = 117, ashaId = userId, createdBy = userName, fillDate = inWindow) + insertCbac(benId = 117, ashaId = userId, createdBy = userName, fillDate = windowEnd + 5_000) + assertEquals(1, count()) + } + + // --------------------------------------------------------------------- + // 16. Cross-month local/download date drift (accepted upstream limitation): + // a local copy dated in month A and a downloaded copy of the SAME + // beneficiary dated in month B each contribute 1 to their own month. + // COUNT(DISTINCT benId) does NOT de-duplicate across different months. + // --------------------------------------------------------------------- + @Test + fun case16_crossMonth_localAndDownload_eachMonthCountsOne() { + val monthAStart = windowStart // [1_000_000, 2_000_000) + val monthAEnd = windowEnd + val monthBStart = windowEnd // [2_000_000, 3_000_000) + val monthBEnd = 3_000_000L + + // Local copy dated in month A, downloaded copy (same benId) dated in month B. + insertCbac(benId = 118, ashaId = userId, createdBy = userName, fillDate = 1_500_000L) + insertCbac(benId = 118, ashaId = 0, createdBy = userName, fillDate = 2_500_000L) + + assertEquals(1, count(start = monthAStart, end = monthAEnd)) + assertEquals(1, count(start = monthBStart, end = monthBEnd)) + } + + // ====================== fixtures / helpers ====================== + + /** Runs the REAL DAO query. Defaults target the logged-in test ASHA + window. */ + private fun count( + uId: Int = userId, + uName: String = userName, + start: Long = windowStart, + end: Long = windowEnd, + ): Int = runBlocking { + cbacDao.countCurrentAshaScreenings(uId, uName, start, end) + } + + /** Inserts one CBAC row via the real DAO after ensuring its FK parent exists. */ + private fun insertCbac( + benId: Long, + ashaId: Int, + createdBy: String?, + fillDate: Long, + syncState: SyncState = SyncState.SYNCED, + ) = runBlocking { + ensureBeneficiary(benId) + cbacDao.upsert( + CbacCache( + benId = benId, + ashaId = ashaId, + fillDate = fillDate, + createdBy = createdBy, + syncState = syncState, + ) + ) + } + + /** + * Guarantees a BENEFICIARY parent row for [benId] so the CBAC foreign key is + * satisfiable. Idempotent (INSERT OR IGNORE) so the same beneficiary can back + * several CBAC rows. The beneficiary's own columns are irrelevant to the + * CBAC-only query, so they get schema-valid dummies. + */ + private fun ensureBeneficiary(benId: Long) { + insertMinimalRow( + "BENEFICIARY", + mapOf("beneficiaryId" to benId, "householdId" to hhId), + ) + } + + /** + * Inserts a schema-valid row into [table], supplying ONLY the columns SQLite + * actually requires (NOT NULL with no default) plus [overrides]. The column + * list and declared types are read from PRAGMA table_info at runtime, so this + * needs no knowledge of the (large) entity internals and stays correct if the + * schema grows. Foreign keys are NOT disabled — parents are inserted for real, + * in FK order (HOUSEHOLD before BENEFICIARY). INSERT OR IGNORE keeps it + * idempotent for shared parents. Dummy values: INTEGER->0, REAL->0.0, + * BLOB->empty, TEXT/other->"". + */ + private fun insertMinimalRow(table: String, overrides: Map) { + val cols = ArrayList() + val args = ArrayList() + support.query("PRAGMA table_info(`$table`)").use { c -> + val nameIdx = c.getColumnIndexOrThrow("name") + val typeIdx = c.getColumnIndexOrThrow("type") + val notNullIdx = c.getColumnIndexOrThrow("notnull") + val defaultIdx = c.getColumnIndexOrThrow("dflt_value") + while (c.moveToNext()) { + val name = c.getString(nameIdx) + val required = c.getInt(notNullIdx) == 1 && c.isNull(defaultIdx) + when { + overrides.containsKey(name) -> { + cols += name + args += overrides[name] + } + required -> { + cols += name + args += dummyForType(c.getString(typeIdx)) + } + // otherwise: nullable or has a default -> omit + } + } + } + val columnList = cols.joinToString(", ") { "`$it`" } + val placeholders = cols.joinToString(", ") { "?" } + support.execSQL( + "INSERT OR IGNORE INTO `$table` ($columnList) VALUES ($placeholders)", + args.toTypedArray(), + ) + } + + private fun dummyForType(declaredType: String?): Any { + val t = (declaredType ?: "").uppercase() + return when { + t.contains("INT") -> 0L + t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") -> 0.0 + t.contains("BLOB") -> ByteArray(0) + else -> "" // TEXT / CHAR / CLOB / untyped + } + } +} diff --git a/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/EligibleCoupleRecapDaoTest.kt b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/EligibleCoupleRecapDaoTest.kt new file mode 100644 index 000000000..54323a1f7 --- /dev/null +++ b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/EligibleCoupleRecapDaoTest.kt @@ -0,0 +1,167 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.piramalswasthya.sakhi.database.room.InAppDb + +/** + * Phase 6 — REAL Room instrumented test for the Eligible Couple recap count + * (`EcrDao.countCurrentAshaEligibleCouples`, Option B: one DISTINCT-couple number + * across registration + tracking). Runs the ACTUAL generated UNION SQL against + * real rows in an in-memory instance of the production [InAppDb]. + * + * Proves: `createdBy = :userName` ownership, the `[start, end)` windows over + * `dateOfReg`/`visitDate`, and that the UNION/DISTINCT-benId dedup counts a couple + * ONCE even when it appears in BOTH the registration and tracking tables. FKs stay + * enforced (EC → BENEFICIARY → HOUSEHOLD parents inserted honestly). Fresh DB per + * test; framework SQLite (not SQLCipher). + */ +@RunWith(AndroidJUnit4::class) +class EligibleCoupleRecapDaoTest { + + private lateinit var db: InAppDb + private lateinit var support: SupportSQLiteDatabase + + private val userName = "test_asha" + private val otherUserName = "other_asha" + private val windowStart = 1_000_000L + private val windowEnd = 2_000_000L + private val inWindow = 1_500_000L + private val hhId = 7_000L // shared parent household for all EC beneficiaries + + @Before + fun setUp() { + val ctx = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(ctx, InAppDb::class.java).allowMainThreadQueries().build() + support = db.openHelper.writableDatabase + insertMinimalRow("HOUSEHOLD", mapOf("householdId" to hhId, "ashaId" to 1, "isDraft" to 0)) + } + + @After + fun tearDown() = db.close() + + @Test + fun ownRegistration_inWindow_isCounted() { + insertEcReg(benId = 101, createdBy = userName, dateOfReg = inWindow) + assertEquals(1, count()) + } + + @Test + fun ownTracking_inWindow_isCounted() { + insertEcTracking(benId = 102, createdBy = userName, visitDate = inWindow) + assertEquals(1, count()) + } + + @Test + fun sameCouple_registeredAndTracked_countedOnce() { + insertEcReg(benId = 103, createdBy = userName, dateOfReg = inWindow) + insertEcTracking(benId = 103, createdBy = userName, visitDate = inWindow + 10) + assertEquals(1, count()) // DISTINCT benId across both tables + } + + @Test + fun twoDistinctCouples_oneRegOneTracking_countsTwo() { + insertEcReg(benId = 104, createdBy = userName, dateOfReg = inWindow) + insertEcTracking(benId = 105, createdBy = userName, visitDate = inWindow) + assertEquals(2, count()) + } + + @Test + fun otherAsha_isExcluded() { + insertEcReg(benId = 106, createdBy = otherUserName, dateOfReg = inWindow) + insertEcTracking(benId = 107, createdBy = otherUserName, visitDate = inWindow) + assertEquals(0, count()) + } + + @Test + fun beforeWindow_isExcluded() { + insertEcReg(benId = 108, createdBy = userName, dateOfReg = windowStart - 1) + assertEquals(0, count()) + } + + @Test + fun startInclusive_endExclusive_boundaries() { + insertEcReg(benId = 109, createdBy = userName, dateOfReg = windowStart) // included + insertEcTracking(benId = 110, createdBy = userName, visitDate = windowEnd) // excluded + assertEquals(1, count()) + } + + // ============================ helpers ============================ + + private fun count(): Int = runBlocking { + db.ecrDao.countCurrentAshaEligibleCouples(userName, windowStart, windowEnd) + } + + private fun ensureBeneficiary(benId: Long) { + insertMinimalRow( + "BENEFICIARY", + mapOf("beneficiaryId" to benId, "householdId" to hhId, "ashaId" to 1, "isDraft" to 0), + ) + } + + private fun insertEcReg(benId: Long, createdBy: String, dateOfReg: Long) { + ensureBeneficiary(benId) + insertMinimalRow( + "ELIGIBLE_COUPLE_REG", + mapOf("benId" to benId, "createdBy" to createdBy, "dateOfReg" to dateOfReg), + ) + } + + private fun insertEcTracking(benId: Long, createdBy: String, visitDate: Long) { + ensureBeneficiary(benId) + insertMinimalRow( + "ELIGIBLE_COUPLE_TRACKING", + mapOf("benId" to benId, "createdBy" to createdBy, "visitDate" to visitDate), + ) + } + + /** PRAGMA-driven minimal INSERT (NOT NULL/no-default cols + overrides); FKs never disabled. */ + private fun insertMinimalRow(table: String, overrides: Map) { + val cols = ArrayList() + val args = ArrayList() + support.query("PRAGMA table_info(`$table`)").use { c -> + val nameIdx = c.getColumnIndexOrThrow("name") + val typeIdx = c.getColumnIndexOrThrow("type") + val notNullIdx = c.getColumnIndexOrThrow("notnull") + val defaultIdx = c.getColumnIndexOrThrow("dflt_value") + while (c.moveToNext()) { + val name = c.getString(nameIdx) + val required = c.getInt(notNullIdx) == 1 && c.isNull(defaultIdx) + when { + overrides.containsKey(name) -> { + cols += name; args += overrides[name] + } + + required -> { + cols += name; args += dummyForType(c.getString(typeIdx)) + } + } + } + } + val columnList = cols.joinToString(", ") { "`$it`" } + val placeholders = cols.joinToString(", ") { "?" } + support.execSQL( + "INSERT OR IGNORE INTO `$table` ($columnList) VALUES ($placeholders)", + args.toTypedArray(), + ) + } + + private fun dummyForType(declaredType: String?): Any { + val t = (declaredType ?: "").uppercase() + return when { + t.contains("INT") -> 0L + t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") -> 0.0 + t.contains("BLOB") -> ByteArray(0) + else -> "" + } + } +} diff --git a/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/ImmunizationRecapDaoTest.kt b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/ImmunizationRecapDaoTest.kt new file mode 100644 index 000000000..5ea4f005c --- /dev/null +++ b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/ImmunizationRecapDaoTest.kt @@ -0,0 +1,176 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.piramalswasthya.sakhi.database.room.InAppDb + +/** + * Phase 6 — REAL Room instrumented test for the Immunization recap count + * (`ImmunizationDao.countCurrentAshaDosesAdministered`: vaccine DOSES the ASHA + * administered). Runs the ACTUAL generated SQL against real rows in an in-memory + * instance of the production [InAppDb]. + * + * Proves: `createdBy = :userName` ownership (no ashaId on this table), the + * `[start, end)` window over the user-entered vaccination `date`, per-DOSE counting + * (multiple doses for one child each count), NULL-date exclusion, and that the + * composite PK `(beneficiaryId, vaccineId)` makes the count re-sync-proof (inserting + * the same dose twice counts once). FKs stay enforced (IMMUNIZATION → BENEFICIARY and + * → VACCINE parents inserted honestly). Fresh DB per test; framework SQLite (not + * SQLCipher). + */ +@RunWith(AndroidJUnit4::class) +class ImmunizationRecapDaoTest { + + private lateinit var db: InAppDb + private lateinit var support: SupportSQLiteDatabase + + private val userName = "test_asha" + private val otherUserName = "other_asha" + private val windowStart = 1_000_000L + private val windowEnd = 2_000_000L + private val inWindow = 1_500_000L + private val hhId = 7_000L // shared parent household for all beneficiaries + + @Before + fun setUp() { + val ctx = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(ctx, InAppDb::class.java).allowMainThreadQueries().build() + support = db.openHelper.writableDatabase + insertMinimalRow("HOUSEHOLD", mapOf("householdId" to hhId, "ashaId" to 1, "isDraft" to 0)) + } + + @After + fun tearDown() = db.close() + + @Test + fun ownDose_inWindow_isCounted() { + insertDose(benId = 101, vaccineId = 1, createdBy = userName, date = inWindow) + assertEquals(1, count()) + } + + @Test + fun multipleDosesSameChild_eachCounted() { + // Three distinct vaccines for one child in the window -> 3 doses (NOT 1 child). + insertDose(benId = 102, vaccineId = 1, createdBy = userName, date = inWindow) + insertDose(benId = 102, vaccineId = 2, createdBy = userName, date = inWindow + 10) + insertDose(benId = 102, vaccineId = 3, createdBy = userName, date = inWindow + 20) + assertEquals(3, count()) + } + + @Test + fun dosesForTwoChildren_countTwo() { + insertDose(benId = 103, vaccineId = 1, createdBy = userName, date = inWindow) + insertDose(benId = 104, vaccineId = 1, createdBy = userName, date = inWindow) + assertEquals(2, count()) + } + + @Test + fun reDownloadSameDose_countedOnce() { + // Same (beneficiaryId, vaccineId) inserted twice -> composite PK keeps one row. + insertDose(benId = 105, vaccineId = 1, createdBy = userName, date = inWindow) + insertDose(benId = 105, vaccineId = 1, createdBy = userName, date = inWindow) + assertEquals(1, count()) + } + + @Test + fun otherAsha_isExcluded() { + insertDose(benId = 106, vaccineId = 1, createdBy = otherUserName, date = inWindow) + insertDose(benId = 107, vaccineId = 2, createdBy = otherUserName, date = inWindow) + assertEquals(0, count()) + } + + @Test + fun nullDate_isExcluded() { + insertDose(benId = 108, vaccineId = 1, createdBy = userName, date = null) + assertEquals(0, count()) + } + + @Test + fun beforeWindow_isExcluded() { + insertDose(benId = 109, vaccineId = 1, createdBy = userName, date = windowStart - 1) + assertEquals(0, count()) + } + + @Test + fun startInclusive_endExclusive_boundaries() { + insertDose(benId = 110, vaccineId = 1, createdBy = userName, date = windowStart) // included + insertDose(benId = 111, vaccineId = 1, createdBy = userName, date = windowEnd) // excluded + assertEquals(1, count()) + } + + // ============================ helpers ============================ + + private fun count(): Int = runBlocking { + db.vaccineDao.countCurrentAshaDosesAdministered(userName, windowStart, windowEnd) + } + + private fun ensureBeneficiary(benId: Long) { + insertMinimalRow( + "BENEFICIARY", + mapOf("beneficiaryId" to benId, "householdId" to hhId, "ashaId" to 1, "isDraft" to 0), + ) + } + + private fun ensureVaccine(vaccineId: Int) { + insertMinimalRow("VACCINE", mapOf("vaccineId" to vaccineId)) + } + + private fun insertDose(benId: Long, vaccineId: Int, createdBy: String, date: Long?) { + ensureBeneficiary(benId) + ensureVaccine(vaccineId) + insertMinimalRow( + "IMMUNIZATION", + mapOf("beneficiaryId" to benId, "vaccineId" to vaccineId, "createdBy" to createdBy, "date" to date), + ) + } + + /** PRAGMA-driven minimal INSERT (NOT NULL/no-default cols + overrides); FKs never disabled. */ + private fun insertMinimalRow(table: String, overrides: Map) { + val cols = ArrayList() + val args = ArrayList() + support.query("PRAGMA table_info(`$table`)").use { c -> + val nameIdx = c.getColumnIndexOrThrow("name") + val typeIdx = c.getColumnIndexOrThrow("type") + val notNullIdx = c.getColumnIndexOrThrow("notnull") + val defaultIdx = c.getColumnIndexOrThrow("dflt_value") + while (c.moveToNext()) { + val name = c.getString(nameIdx) + val required = c.getInt(notNullIdx) == 1 && c.isNull(defaultIdx) + when { + overrides.containsKey(name) -> { + cols += name; args += overrides[name] + } + + required -> { + cols += name; args += dummyForType(c.getString(typeIdx)) + } + } + } + } + val columnList = cols.joinToString(", ") { "`$it`" } + val placeholders = cols.joinToString(", ") { "?" } + support.execSQL( + "INSERT OR IGNORE INTO `$table` ($columnList) VALUES ($placeholders)", + args.toTypedArray(), + ) + } + + private fun dummyForType(declaredType: String?): Any { + val t = (declaredType ?: "").uppercase() + return when { + t.contains("INT") -> 0L + t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") -> 0.0 + t.contains("BLOB") -> ByteArray(0) + else -> "" + } + } +} diff --git a/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/MaternalHealthRecapDaoTest.kt b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/MaternalHealthRecapDaoTest.kt new file mode 100644 index 000000000..92f3b53b5 --- /dev/null +++ b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/MaternalHealthRecapDaoTest.kt @@ -0,0 +1,222 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.piramalswasthya.sakhi.database.room.InAppDb + +/** + * Phase 6 — REAL Room instrumented test for the Maternal Health recap count + * (`MaternalHealthDao.countCurrentAshaMothersSupported`: DISTINCT mothers the ASHA + * supported via ANY of pregnancy registration / ANC / PMSMA / delivery / PNC). Runs + * the ACTUAL generated UNION SQL against real rows in an in-memory instance of the + * production [InAppDb]. + * + * Proves: `createdBy = :userName` ownership (no ashaId on these tables), the + * `[start, end)` windows over each table's own activity date, and that UNION / + * DISTINCT-benId counts a woman ONCE even when she appears across several maternal + * forms — and that PNC's download duplicate-row quirk collapses harmlessly. The + * `active`/`isActive` flag is deliberately NOT part of the query, so activities + * done in the window count regardless of later closure. FKs stay enforced + * (maternal → BENEFICIARY → HOUSEHOLD parents inserted honestly). Fresh DB per + * test; framework SQLite (not SQLCipher). + */ +@RunWith(AndroidJUnit4::class) +class MaternalHealthRecapDaoTest { + + private lateinit var db: InAppDb + private lateinit var support: SupportSQLiteDatabase + + private val userName = "test_asha" + private val otherUserName = "other_asha" + private val windowStart = 1_000_000L + private val windowEnd = 2_000_000L + private val inWindow = 1_500_000L + private val hhId = 7_000L // shared parent household for all mothers + + @Before + fun setUp() { + val ctx = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(ctx, InAppDb::class.java).allowMainThreadQueries().build() + support = db.openHelper.writableDatabase + insertMinimalRow("HOUSEHOLD", mapOf("householdId" to hhId, "ashaId" to 1, "isDraft" to 0)) + } + + @After + fun tearDown() = db.close() + + @Test + fun ownPregnancyRegistration_inWindow_isCounted() { + insertPwr(benId = 101, createdBy = userName, dateOfRegistration = inWindow) + assertEquals(1, count()) + } + + @Test + fun ownAnc_inWindow_isCounted() { + insertAnc(benId = 102, createdBy = userName, ancDate = inWindow) + assertEquals(1, count()) + } + + @Test + fun ownPmsma_inWindow_isCounted() { + insertPmsma(benId = 103, createdBy = userName, visitDate = inWindow) + assertEquals(1, count()) + } + + @Test + fun ownDelivery_inWindow_isCounted() { + insertDelivery(benId = 104, createdBy = userName, dateOfDelivery = inWindow) + assertEquals(1, count()) + } + + @Test + fun ownPnc_inWindow_isCounted() { + insertPnc(benId = 105, createdBy = userName, pncDate = inWindow) + assertEquals(1, count()) + } + + @Test + fun sameWoman_acrossMultipleForms_countedOnce() { + insertPwr(benId = 106, createdBy = userName, dateOfRegistration = inWindow) + insertAnc(benId = 106, createdBy = userName, ancDate = inWindow + 10) + insertPmsma(benId = 106, createdBy = userName, visitDate = inWindow + 20) + assertEquals(1, count()) // DISTINCT benId across tables + } + + @Test + fun pncDuplicateRows_sameWoman_countedOnce() { + // Simulates PNC's download id-drop: two rows for the same beneficiary. + insertPnc(benId = 107, createdBy = userName, pncDate = inWindow) + insertPnc(benId = 107, createdBy = userName, pncDate = inWindow) + assertEquals(1, count()) + } + + @Test + fun otherAsha_isExcluded() { + insertPwr(benId = 108, createdBy = otherUserName, dateOfRegistration = inWindow) + insertAnc(benId = 109, createdBy = otherUserName, ancDate = inWindow) + assertEquals(0, count()) + } + + @Test + fun beforeWindow_isExcluded() { + insertPwr(benId = 110, createdBy = userName, dateOfRegistration = windowStart - 1) + assertEquals(0, count()) + } + + @Test + fun startInclusive_endExclusive_boundaries() { + insertPwr(benId = 111, createdBy = userName, dateOfRegistration = windowStart) // included + insertAnc(benId = 112, createdBy = userName, ancDate = windowEnd) // excluded + assertEquals(1, count()) + } + + @Test + fun twoDistinctWomen_countTwo() { + insertPwr(benId = 113, createdBy = userName, dateOfRegistration = inWindow) + insertDelivery(benId = 114, createdBy = userName, dateOfDelivery = inWindow) + assertEquals(2, count()) + } + + // ============================ helpers ============================ + + private fun count(): Int = runBlocking { + db.maternalHealthDao.countCurrentAshaMothersSupported(userName, windowStart, windowEnd) + } + + private fun ensureBeneficiary(benId: Long) { + insertMinimalRow( + "BENEFICIARY", + mapOf("beneficiaryId" to benId, "householdId" to hhId, "ashaId" to 1, "isDraft" to 0), + ) + } + + private fun insertPwr(benId: Long, createdBy: String, dateOfRegistration: Long) { + ensureBeneficiary(benId) + insertMinimalRow( + "PREGNANCY_REGISTER", + mapOf("benId" to benId, "createdBy" to createdBy, "dateOfRegistration" to dateOfRegistration), + ) + } + + private fun insertAnc(benId: Long, createdBy: String, ancDate: Long) { + ensureBeneficiary(benId) + insertMinimalRow( + "PREGNANCY_ANC", + mapOf("benId" to benId, "createdBy" to createdBy, "ancDate" to ancDate), + ) + } + + private fun insertPmsma(benId: Long, createdBy: String, visitDate: Long) { + ensureBeneficiary(benId) + insertMinimalRow( + "PMSMA", + mapOf("benId" to benId, "createdBy" to createdBy, "visitDate" to visitDate), + ) + } + + private fun insertDelivery(benId: Long, createdBy: String, dateOfDelivery: Long) { + ensureBeneficiary(benId) + insertMinimalRow( + "DELIVERY_OUTCOME", + mapOf("benId" to benId, "createdBy" to createdBy, "dateOfDelivery" to dateOfDelivery), + ) + } + + private fun insertPnc(benId: Long, createdBy: String, pncDate: Long) { + ensureBeneficiary(benId) + insertMinimalRow( + "PNC_VISIT", + mapOf("benId" to benId, "createdBy" to createdBy, "pncDate" to pncDate), + ) + } + + /** PRAGMA-driven minimal INSERT (NOT NULL/no-default cols + overrides); FKs never disabled. */ + private fun insertMinimalRow(table: String, overrides: Map) { + val cols = ArrayList() + val args = ArrayList() + support.query("PRAGMA table_info(`$table`)").use { c -> + val nameIdx = c.getColumnIndexOrThrow("name") + val typeIdx = c.getColumnIndexOrThrow("type") + val notNullIdx = c.getColumnIndexOrThrow("notnull") + val defaultIdx = c.getColumnIndexOrThrow("dflt_value") + while (c.moveToNext()) { + val name = c.getString(nameIdx) + val required = c.getInt(notNullIdx) == 1 && c.isNull(defaultIdx) + when { + overrides.containsKey(name) -> { + cols += name; args += overrides[name] + } + + required -> { + cols += name; args += dummyForType(c.getString(typeIdx)) + } + } + } + } + val columnList = cols.joinToString(", ") { "`$it`" } + val placeholders = cols.joinToString(", ") { "?" } + support.execSQL( + "INSERT OR IGNORE INTO `$table` ($columnList) VALUES ($placeholders)", + args.toTypedArray(), + ) + } + + private fun dummyForType(declaredType: String?): Any { + val t = (declaredType ?: "").uppercase() + return when { + t.contains("INT") -> 0L + t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") -> 0.0 + t.contains("BLOB") -> ByteArray(0) + else -> "" + } + } +} diff --git a/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/RegistrationRecapDaoTest.kt b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/RegistrationRecapDaoTest.kt new file mode 100644 index 000000000..f8e73866b --- /dev/null +++ b/app/src/androidTest/java/org/piramalswasthya/sakhi/database/room/dao/RegistrationRecapDaoTest.kt @@ -0,0 +1,217 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.piramalswasthya.sakhi.database.room.InAppDb + +/** + * Phase 6 — REAL Room instrumented test for the Beneficiary + Household recap + * registration counts (`BenDao.countCurrentAshaRegistrations` / + * `HouseholdDao.countCurrentAshaRegistrations`). Runs the ACTUAL generated DAO SQL + * against real rows in an in-memory instance of the production [InAppDb]. + * + * Proves the SQL semantics: `ashaId = :userId` ownership, `isDraft = 0` filter, + * the `[start, end)` window (over `regDate` / `createdTimeStamp`), that a + * deactivated-later beneficiary is still counted, and that a household with a null + * timestamp is excluded. FKs stay enforced; HOUSEHOLD (FK root) parents are + * inserted honestly for BENEFICIARY rows. Fresh DB per test; framework SQLite + * (not SQLCipher), so the real encrypted app DB is never touched. + */ +@RunWith(AndroidJUnit4::class) +class RegistrationRecapDaoTest { + + private lateinit var db: InAppDb + private lateinit var support: SupportSQLiteDatabase + + private val userId = 42 + private val otherUserId = 77 + private val windowStart = 1_000_000L + private val windowEnd = 2_000_000L + private val inWindow = 1_500_000L + private val hhForBen = 8_000L // parent household for beneficiary rows + + @Before + fun setUp() { + val ctx = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(ctx, InAppDb::class.java).allowMainThreadQueries().build() + support = db.openHelper.writableDatabase + } + + @After + fun tearDown() = db.close() + + // ============================ BENEFICIARY ============================ + + @Test + fun beneficiary_ownRegistration_inWindow_isCounted() { + insertBeneficiary(benId = 101, ashaId = userId, isDraft = 0, regDate = inWindow) + assertEquals(1, benCount()) + } + + @Test + fun beneficiary_otherAsha_isExcluded() { + insertBeneficiary(benId = 102, ashaId = otherUserId, isDraft = 0, regDate = inWindow) + assertEquals(0, benCount()) + } + + @Test + fun beneficiary_draft_isExcluded() { + insertBeneficiary(benId = 103, ashaId = userId, isDraft = 1, regDate = inWindow) + assertEquals(0, benCount()) + } + + @Test + fun beneficiary_deactivatedLater_isStillCounted() { + insertBeneficiary(benId = 104, ashaId = userId, isDraft = 0, regDate = inWindow, isDeactivate = 1) + assertEquals(1, benCount()) + } + + @Test + fun beneficiary_beforeWindow_isExcluded() { + insertBeneficiary(benId = 105, ashaId = userId, isDraft = 0, regDate = windowStart - 1) + assertEquals(0, benCount()) + } + + @Test + fun beneficiary_startInclusive_endExclusive_boundaries() { + insertBeneficiary(benId = 106, ashaId = userId, isDraft = 0, regDate = windowStart) // included + insertBeneficiary(benId = 107, ashaId = userId, isDraft = 0, regDate = windowEnd) // excluded + assertEquals(1, benCount()) + } + + @Test + fun beneficiary_twoOwnRegistrations_countsTwo() { + insertBeneficiary(benId = 108, ashaId = userId, isDraft = 0, regDate = inWindow) + insertBeneficiary(benId = 109, ashaId = userId, isDraft = 0, regDate = inWindow + 1) + assertEquals(2, benCount()) + } + + // ============================ HOUSEHOLD ============================ + + @Test + fun household_ownRegistration_inWindow_isCounted() { + insertHousehold(hhId = 201, ashaId = userId, isDraft = 0, createdTimeStamp = inWindow) + assertEquals(1, householdCount()) + } + + @Test + fun household_otherAsha_isExcluded() { + insertHousehold(hhId = 202, ashaId = otherUserId, isDraft = 0, createdTimeStamp = inWindow) + assertEquals(0, householdCount()) + } + + @Test + fun household_draft_isExcluded() { + insertHousehold(hhId = 203, ashaId = userId, isDraft = 1, createdTimeStamp = inWindow) + assertEquals(0, householdCount()) + } + + @Test + fun household_nullTimestamp_isExcluded() { + insertHousehold(hhId = 204, ashaId = userId, isDraft = 0, createdTimeStamp = null) + assertEquals(0, householdCount()) + } + + @Test + fun household_endExclusiveBoundary_isExcluded() { + insertHousehold(hhId = 205, ashaId = userId, isDraft = 0, createdTimeStamp = windowEnd) + assertEquals(0, householdCount()) + } + + // ============================ helpers ============================ + + private fun benCount(): Int = runBlocking { + db.benDao.countCurrentAshaRegistrations(userId, windowStart, windowEnd) + } + + private fun householdCount(): Int = runBlocking { + db.householdDao.countCurrentAshaRegistrations(userId, windowStart, windowEnd) + } + + private fun insertHousehold(hhId: Long, ashaId: Int, isDraft: Int, createdTimeStamp: Long?) { + val overrides = mutableMapOf( + "householdId" to hhId, + "ashaId" to ashaId, + "isDraft" to isDraft, + ) + if (createdTimeStamp != null) overrides["createdTimeStamp"] = createdTimeStamp + insertMinimalRow("HOUSEHOLD", overrides) + } + + private fun insertBeneficiary( + benId: Long, + ashaId: Int, + isDraft: Int, + regDate: Long, + isDeactivate: Int = 0, + ) { + // FK parent household (any values; not counted in the beneficiary query). + insertMinimalRow("HOUSEHOLD", mapOf("householdId" to hhForBen, "ashaId" to ashaId, "isDraft" to 0)) + insertMinimalRow( + "BENEFICIARY", + mapOf( + "beneficiaryId" to benId, + "householdId" to hhForBen, + "ashaId" to ashaId, + "isDraft" to isDraft, + "isDeactivate" to isDeactivate, + "regDate" to regDate, + ), + ) + } + + /** + * Inserts a schema-valid row into [table] supplying only the columns SQLite + * requires (NOT NULL, no default) plus [overrides]; column list/types come from + * PRAGMA table_info at runtime. FKs are never disabled; INSERT OR IGNORE keeps + * shared parents idempotent. + */ + private fun insertMinimalRow(table: String, overrides: Map) { + val cols = ArrayList() + val args = ArrayList() + support.query("PRAGMA table_info(`$table`)").use { c -> + val nameIdx = c.getColumnIndexOrThrow("name") + val typeIdx = c.getColumnIndexOrThrow("type") + val notNullIdx = c.getColumnIndexOrThrow("notnull") + val defaultIdx = c.getColumnIndexOrThrow("dflt_value") + while (c.moveToNext()) { + val name = c.getString(nameIdx) + val required = c.getInt(notNullIdx) == 1 && c.isNull(defaultIdx) + when { + overrides.containsKey(name) -> { + cols += name; args += overrides[name] + } + + required -> { + cols += name; args += dummyForType(c.getString(typeIdx)) + } + } + } + } + val columnList = cols.joinToString(", ") { "`$it`" } + val placeholders = cols.joinToString(", ") { "?" } + support.execSQL( + "INSERT OR IGNORE INTO `$table` ($columnList) VALUES ($placeholders)", + args.toTypedArray(), + ) + } + + private fun dummyForType(declaredType: String?): Any { + val t = (declaredType ?: "").uppercase() + return when { + t.contains("INT") -> 0L + t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") -> 0.0 + t.contains("BLOB") -> ByteArray(0) + else -> "" + } + } +} diff --git a/app/src/debug/res/values/monthly_recap_debug.xml b/app/src/debug/res/values/monthly_recap_debug.xml new file mode 100644 index 000000000..c635e5e2d --- /dev/null +++ b/app/src/debug/res/values/monthly_recap_debug.xml @@ -0,0 +1,18 @@ + + + + auto + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6167f9074..2ef3565b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + @@ -124,6 +126,18 @@ android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" /> + + + + + + + + diff --git a/app/src/main/java/org/piramalswasthya/sakhi/SakhiApplication.kt b/app/src/main/java/org/piramalswasthya/sakhi/SakhiApplication.kt index 066bb1e40..b48ac3a94 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/SakhiApplication.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/SakhiApplication.kt @@ -16,10 +16,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.piramalswasthya.sakhi.database.room.InAppDb import org.piramalswasthya.sakhi.helpers.CrashHandler +import org.piramalswasthya.sakhi.helpers.GamificationConfigProvider import org.piramalswasthya.sakhi.helpers.SyncLogFileWriter import org.piramalswasthya.sakhi.helpers.SyncLogManager import org.piramalswasthya.sakhi.helpers.SyncLogTree import org.piramalswasthya.sakhi.utils.KeyUtils +import org.piramalswasthya.sakhi.work.MonthlyRecapReminderWorker import timber.log.Timber import javax.inject.Inject @@ -39,6 +41,9 @@ class SakhiApplication : Application(), Configuration.Provider { @Inject lateinit var syncLogFileWriter: SyncLogFileWriter + @Inject + lateinit var gamificationConfigProvider: GamificationConfigProvider + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -64,6 +69,13 @@ class SakhiApplication : Application(), Configuration.Provider { FirebaseApp.initializeApp(this) createNotificationChannels() + // Gamification (Monthly Recap, Badges, ...): prime the shared Remote + // Config gate and schedule the recap's own trigger. Both are inert + // until a user is logged in and the mechanic is enabled server-side — + // see GamificationConfigProvider's own doc for the full design. + gamificationConfigProvider.primeAsync() + MonthlyRecapReminderWorker.schedule(this) + Thread.setDefaultUncaughtExceptionHandler(CrashHandler(applicationContext)) // Recover any records orphaned in SYNCING state from a previous crash/kill @@ -117,6 +129,18 @@ class SakhiApplication : Application(), Configuration.Provider { description = "General app notifications" } ) + + // Monthly Recap's own channel, separate from sync noise, so she can + // manage it independently in system settings. + nm.createNotificationChannel( + NotificationChannel( + getString(R.string.notification_recap_channel_id), + getString(R.string.notification_recap_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = getString(R.string.notification_recap_channel_description) + } + ) } } diff --git a/app/src/main/java/org/piramalswasthya/sakhi/adapters/BadgeCarouselAdapter.kt b/app/src/main/java/org/piramalswasthya/sakhi/adapters/BadgeCarouselAdapter.kt new file mode 100644 index 000000000..8e7e2b9ed --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/adapters/BadgeCarouselAdapter.kt @@ -0,0 +1,88 @@ +package org.piramalswasthya.sakhi.adapters + +import android.animation.ObjectAnimator +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.badges.BadgeRepository.BadgeCard +import org.piramalswasthya.sakhi.badges.domain.BadgeDefinitions +import org.piramalswasthya.sakhi.badges.domain.BadgeKind +import org.piramalswasthya.sakhi.databinding.ItemBadgeCarouselBinding + +/** One big, friendly badge per page — for the auto-moving dashboard carousel. */ +class BadgeCarouselAdapter : + ListAdapter(diffCallback) { + + companion object { + private val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BadgeCard, newItem: BadgeCard) = + oldItem.definition.id == newItem.definition.id + + override fun areContentsTheSame(oldItem: BadgeCard, newItem: BadgeCard) = + oldItem == newItem + } + } + + class PageViewHolder(private val binding: ItemBadgeCarouselBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(card: BadgeCard) { + val res = binding.root.resources + val def = card.definition + val state = card.state + + val (iconRes, earnedLook) = BadgeDefinitions.displayIcon(def, state) + binding.ivCarouselIcon.setImageResource(iconRes) + binding.ivCarouselIcon.alpha = if (earnedLook) 1f else 0.85f + binding.tvCarouselTitle.text = res.getString(def.titleRes) + + val target = (state?.nextTarget ?: def.milestones.first()).coerceAtLeast(1L) + val progress = (state?.progress ?: 0L).coerceAtMost(target) // maxed badges never show "9 of 8" + binding.pbCarouselProgress.max = target.toInt() + // animated fill: the bar grows as the badge slides in + ObjectAnimator.ofInt( + binding.pbCarouselProgress, "progress", + 0, progress.coerceAtMost(target).toInt() + ).setDuration(650).start() + + val level = state?.currentLevel ?: 0 + val earnedText = when { + level <= 0 -> null + def.kind == BadgeKind.QUARTERLY || def.kind == BadgeKind.PER_CASE -> + res.getString(R.string.badge_earned_chip) + else -> "🏅 " + res.getString(R.string.badge_level, level) + } + val progressText = + if (progress <= 0L && level == 0) res.getString(R.string.badge_not_started) + else res.getString(R.string.badge_progress_of, progress, target) + val streakText = when { + state == null || state.streakCount <= 0L -> null + def.kind == BadgeKind.STREAK_WEEKLY -> + res.getString(R.string.badge_streak_weeks, state.streakCount) + def.kind == BadgeKind.STREAK_MONTHLY -> + res.getString(R.string.badge_streak_months, state.streakCount) + else -> null + } + // nudge when within reach of the next milestone + val almostThere = (progress in 1 until target && + progress.toDouble() / target >= 0.6) + .let { if (it) res.getString(R.string.badge_almost_there) else null } + binding.tvCarouselStatus.text = + listOfNotNull(earnedText, progressText, streakText, almostThere) + .joinToString(" · ") + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PageViewHolder( + ItemBadgeCarouselBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + + override fun onBindViewHolder(holder: PageViewHolder, position: Int) = + holder.bind(getItem(position)) +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/adapters/BadgeShelfAdapter.kt b/app/src/main/java/org/piramalswasthya/sakhi/adapters/BadgeShelfAdapter.kt new file mode 100644 index 000000000..091313ba2 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/adapters/BadgeShelfAdapter.kt @@ -0,0 +1,101 @@ +package org.piramalswasthya.sakhi.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.badges.BadgeRepository.BadgeCard +import org.piramalswasthya.sakhi.badges.domain.BadgeDefinitions +import org.piramalswasthya.sakhi.badges.domain.BadgeKind +import org.piramalswasthya.sakhi.databinding.ItemBadgeBinding + +class BadgeShelfAdapter : + ListAdapter(diffCallback) { + + companion object { + private val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BadgeCard, newItem: BadgeCard) = + oldItem.definition.id == newItem.definition.id + + override fun areContentsTheSame(oldItem: BadgeCard, newItem: BadgeCard) = + oldItem == newItem + } + } + + class BadgeViewHolder private constructor(private val binding: ItemBadgeBinding) : + RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): BadgeViewHolder { + val binding = ItemBadgeBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return BadgeViewHolder(binding) + } + } + + fun bind(card: BadgeCard) { + val res = binding.root.resources + val def = card.definition + val state = card.state + + val (iconRes, earnedLook) = BadgeDefinitions.displayIcon(def, state) + binding.ivBadgeIcon.setImageResource(iconRes) + // locked tiers render dimmed until the level is actually earned + binding.ivBadgeIcon.alpha = if (earnedLook) 1f else 0.85f + binding.tvBadgeTitle.text = res.getString(def.titleRes) + binding.tvBadgeDesc.text = res.getString(def.descRes) + + val target = (state?.nextTarget ?: def.milestones.first()).coerceAtLeast(1L) + val progress = (state?.progress ?: 0L).coerceAtMost(target) // maxed badges never show "9 of 8" + + binding.tvBadgeLevel.text = when { + def.kind == BadgeKind.QUARTERLY || def.kind == BadgeKind.PER_CASE -> + if (card.timesEarned > 0) + res.getString(R.string.badge_earned_times, card.timesEarned) + else "" + + (state?.currentLevel ?: 0) > 0 -> + res.getString(R.string.badge_level, state!!.currentLevel) + + else -> "" + } + + // never-zero rule: 0 progress shows an invitation, not "0 of N" + val almostThere = progress in 1 until target && + progress.toDouble() / target >= 0.6 + binding.tvBadgeProgress.text = when { + progress <= 0L && (state?.currentLevel ?: 0) == 0 -> + res.getString(R.string.badge_not_started) + almostThere -> res.getString(R.string.badge_progress_of, progress, target) + + " · " + res.getString(R.string.badge_almost_there) + else -> res.getString(R.string.badge_progress_of, progress, target) + } + + binding.pbBadgeProgress.max = target.toInt() + binding.pbBadgeProgress.progress = progress.coerceAtMost(target).toInt() + + binding.tvBadgeStreak.text = when { + state == null || state.streakCount <= 0 -> "" + def.kind == BadgeKind.STREAK_WEEKLY -> listOfNotNull( + res.getString(R.string.badge_streak_weeks, state.streakCount), + res.getString(R.string.badge_grace_remaining, state.graceRemaining) + .takeIf { state.graceRemaining > 0 } + ).joinToString(" · ") + + def.kind == BadgeKind.STREAK_MONTHLY -> + res.getString(R.string.badge_streak_months, state.streakCount) + + else -> "" + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + BadgeViewHolder.from(parent) + + override fun onBindViewHolder(holder: BadgeViewHolder, position: Int) = + holder.bind(getItem(position)) +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeConfettiView.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeConfettiView.kt new file mode 100644 index 000000000..073900272 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeConfettiView.kt @@ -0,0 +1,90 @@ +package org.piramalswasthya.sakhi.badges + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import java.util.Random + +/** + * Dependency-free confetti burst for badge celebrations: coloured pieces + * fountain up from the centre and fall with gravity, fading at the end. + */ +class BadgeConfettiView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : View(context, attrs) { + + private class Piece( + val x0: Float, val y0: Float, val vx: Float, val vy: Float, + val color: Int, val size: Float, val spin: Float + ) + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private var pieces: List = emptyList() + private var t = 0f + private var animator: ValueAnimator? = null + + fun burst() { + if (width == 0) { + post { burst() } + return + } + val rnd = Random() + pieces = List(90) { + Piece( + x0 = width / 2f, + y0 = height * 0.45f, + vx = (rnd.nextFloat() - 0.5f) * width * 2.2f, + vy = -(height * (0.6f + rnd.nextFloat() * 1.1f)), + color = COLORS[rnd.nextInt(COLORS.size)], + size = 8f + rnd.nextFloat() * 14f, + spin = (rnd.nextFloat() - 0.5f) * 720f + ) + } + animator?.cancel() + animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 2400 + addUpdateListener { + t = it.animatedValue as Float + invalidate() + } + start() + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (t <= 0f || pieces.isEmpty()) return + val gravity = height * 2.2f + paint.alpha = (255 * (1f - t * t)).toInt().coerceIn(0, 255) + for (p in pieces) { + val x = p.x0 + p.vx * t + val y = p.y0 + p.vy * t + 0.5f * gravity * t * t + if (y > height + p.size) continue + paint.color = p.color + paint.alpha = (255 * (1f - t * t)).toInt().coerceIn(0, 255) + canvas.save() + canvas.rotate(p.spin * t, x, y) + canvas.drawRoundRect( + x - p.size / 2, y - p.size / 4, x + p.size / 2, y + p.size / 4, + 3f, 3f, paint + ) + canvas.restore() + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animator?.cancel() + } + + companion object { + private val COLORS = intArrayOf( + 0xFFF44336.toInt(), 0xFFFF9800.toInt(), 0xFFFFEB3B.toInt(), + 0xFF4CAF50.toInt(), 0xFF2196F3.toInt(), 0xFF9C27B0.toInt(), + 0xFF00BCD4.toInt(), 0xFFFFD700.toInt() + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeDemoSeeder.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeDemoSeeder.kt new file mode 100644 index 000000000..b9eb9dd04 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeDemoSeeder.kt @@ -0,0 +1,79 @@ +package org.piramalswasthya.sakhi.badges + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.badges.domain.BadgeDates +import org.piramalswasthya.sakhi.badges.domain.BadgeDefinitions +import org.piramalswasthya.sakhi.badges.domain.BadgeFactsReader +import org.piramalswasthya.sakhi.badges.domain.BadgeIds +import org.piramalswasthya.sakhi.badges.domain.StreakEngine +import org.piramalswasthya.sakhi.badges.domain.TaskCompletionBus +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.model.BadgeEarnedCache +import org.piramalswasthya.sakhi.model.BadgeSyncLogCache +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Demo staging for presentations — DEBUG builds only (long-press "My Impact"). + * Stages Steady Syncer exactly one sync short of its next tier and seeds a + * first-tier history for the other badges so the shelf looks lived-in. + * Touches only BADGE_* tables; never fabricates health records. + */ +@Singleton +class BadgeDemoSeeder @Inject constructor( + private val badgeDao: BadgeDao, + private val facts: BadgeFactsReader, + private val streakEngine: StreakEngine, + private val pref: PreferenceDao, + private val bus: TaskCompletionBus +) { + + suspend fun stage(): String = withContext(Dispatchers.IO) { + val userId = pref.getLoggedInUser()?.userId ?: return@withContext "Not logged in" + val now = System.currentTimeMillis() + val def = BadgeDefinitions.byId(BadgeIds.STEADY_SYNCER)!! + val config = badgeDao.getConfig().associate { it.key to it.value } + val milestones = BadgeDefinitions.effectiveMilestones(def, config) + val thisWeek = BadgeDates.weekKey(now) + val keyAt = { off: Int -> BadgeDates.weekKeyAt(off, now) } + + // best tier reachable from history, ignoring the current (not yet synced) week + val completed = (badgeDao.getAllSyncWeeks().toSet() + facts.activityWeeks()) - thisWeek + val best = maxOf( + streakEngine.compute( + completed, 0, emptyList(), def.id, keyAt, + periodIntervalAt = { BadgeDates.weekIntervalAt(it, now) } + ).length, + streakEngine.longestRun(completed, keyAt) + ) + val target = milestones.firstOrNull { it > best } + ?: return@withContext "Steady Syncer is already at its top tier" + + // fill weeks -1..-(target-1): one more sync this week reaches the target + for (off in 1 until target.toInt()) { + val at = BadgeDates.weekIntervalAt(-off, now).first + TimeUnit.DAYS.toMillis(2) + badgeDao.insertSyncLog(BadgeSyncLogCache(keyAt(-off), at)) + } + badgeDao.deleteSyncWeek(thisWeek) + + // lived-in shelf: first tier of each cumulative/streak badge, this quarter's re-earnables + val quarter = BadgeDates.quarterKey(now) + badgeDao.insertEarned( + listOf( + BadgeIds.TIMELY_REPORTER, BadgeIds.MATERNAL_JOURNEY, + BadgeIds.CHILD_FULLY_PROTECTED, BadgeIds.DIGITAL_IDENTITY + ).map { BadgeEarnedCache(userId = userId, badgeId = it, level = 1, earnedAt = now) } + + listOf(BadgeIds.COMPLETE_WORKER, BadgeIds.COMMUNITY_VOICE).map { + BadgeEarnedCache( + userId = userId, badgeId = it, level = 1, + caseRef = quarter, earnedAt = now + ) + } + ) + bus.publish() + "Steady Syncer at ${target - 1} weeks — one sync unlocks tier ${milestones.indexOf(target) + 1}" + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeRepository.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeRepository.kt new file mode 100644 index 000000000..2395d42cd --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/BadgeRepository.kt @@ -0,0 +1,71 @@ +package org.piramalswasthya.sakhi.badges + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.piramalswasthya.sakhi.badges.domain.BadgeDefinition +import org.piramalswasthya.sakhi.badges.domain.BadgeDefinitions +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.model.BadgeStateCache +import javax.inject.Inject +import javax.inject.Singleton + +/** + * UI-facing read model for the shelf and the live progress widget. + * Reads only BADGE_STATE / BADGE_EARNED / BADGE_CONFIG (LLD §4.1). + */ +@Singleton +class BadgeRepository @Inject constructor( + private val badgeDao: BadgeDao, + private val pref: PreferenceDao +) { + + data class BadgeCard( + val definition: BadgeDefinition, + val state: BadgeStateCache?, + /** Distinct milestone levels permanently earned. */ + val earnedLevels: Int, + /** For re-earnable / per-case badges: total recognitions. */ + val timesEarned: Int + ) + + /** + * Empty list ⇔ feature killed remotely, or nobody logged in — UI hides cleanly + * (LLD §5.2). + * + * Scoped to the signed-in ASHA. The badge tables survive a drawer logout, by design, so + * that unsynced awards are not lost; reading them unscoped would show the next ASHA to + * use the device the previous one's shelf. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val shelf: Flow> = flow { emit(pref.getLoggedInUser()?.userId) } + .flatMapLatest { userId -> + if (userId == null) flowOf(emptyList()) else shelfFor(userId) + } + + private fun shelfFor(userId: Int): Flow> = combine( + badgeDao.getStatesFlow(userId), + badgeDao.getEarnedFlow(userId), + badgeDao.getConfigFlow() + ) { states, earned, configRows -> + val config = configRows.associate { it.key to it.value } + if (!BadgeDefinitions.isFeatureEnabled(config)) return@combine emptyList() + + val stateById = states.associateBy { it.badgeId } + BadgeDefinitions.ALL + .filter { BadgeDefinitions.isEnabled(it, config) } + .map { def -> + val earnedRows = earned.filter { it.badgeId == def.id } + BadgeCard( + definition = def, + state = stateById[def.id], + earnedLevels = earnedRows.map { it.level }.distinct().size, + timesEarned = earnedRows.size + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/AwardKeys.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/AwardKeys.kt new file mode 100644 index 000000000..88dd3f7a2 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/AwardKeys.kt @@ -0,0 +1,46 @@ +package org.piramalswasthya.sakhi.badges.domain + +import java.security.MessageDigest + +/** + * Stable, non-identifying discriminators for badges that can be earned more than once at the + * same level (Badge LLD §4). + * + * Two awards of the same badge and level are only different because of what they were for: a + * quarter, or a beneficiary. The server needs to be able to tell them apart — otherwise a + * quarterly badge earned in two quarters collapses to one row on upload and cannot be + * restored — but it must not learn who the beneficiary was. + * + * Quarter keys are not about anyone, so they travel as they are. Beneficiary ids are hashed + * here, once, at the point the award is created, so the identifier is never written to a + * local table either and there is no second form of the same award to reconcile later. The + * digest is deterministic, so the same beneficiary produces the same key on every device and + * after every reinstall, which is exactly what makes the uniqueness constraint work. + * + * Not a security boundary: a beneficiary id drawn from a small space could be recovered by + * an attacker who already had both the id list and the database. It is a data-minimisation + * measure — the identifier does not leave the phone — and the health tables it came from are + * already on the same device behind the same lock. + */ +object AwardKeys { + + /** + * The key for a per-case award. Truncated to 32 hex characters: collision risk over one + * ASHA's caseload is negligible, and the column it lands in is bounded. + */ + fun forCase(rawCaseId: String): String { + if (rawCaseId.isBlank()) return "" + val digest = MessageDigest.getInstance("SHA-256") + .digest(rawCaseId.trim().toByteArray(Charsets.UTF_8)) + return buildString(KEY_CHARS) { + for (byte in digest) { + if (length >= KEY_CHARS) break + append(HEX[(byte.toInt() shr 4) and 0xF]) + append(HEX[byte.toInt() and 0xF]) + } + } + } + + private const val KEY_CHARS = 32 + private val HEX = "0123456789abcdef".toCharArray() +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeCelebrations.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeCelebrations.kt new file mode 100644 index 000000000..574b816a8 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeCelebrations.kt @@ -0,0 +1,25 @@ +package org.piramalswasthya.sakhi.badges.domain + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Newly-earned badge events for the in-app celebration overlay. + * A Channel (not SharedFlow) so each award is shown exactly once, queues + * while the app is backgrounded, and survives configuration changes. + */ +@Singleton +class BadgeCelebrations @Inject constructor() { + + /** (badgeId, level) */ + private val channel = Channel>(capacity = 16) + + val awards: Flow> = channel.receiveAsFlow() + + fun publish(badgeId: String, level: Int) { + channel.trySend(badgeId to level) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeDates.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeDates.kt new file mode 100644 index 000000000..66d6a135d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeDates.kt @@ -0,0 +1,100 @@ +package org.piramalswasthya.sakhi.badges.domain + +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.Locale + +/** + * Device-local period keys (LLD §5.2 "Clock skew / week boundaries"): + * work done at 23:59 on the last day of a period counts for that period. + * Weeks are ISO-8601 (Monday start, week 1 contains Jan 4th). + */ +object BadgeDates { + + private fun isoCalendar(time: Long): GregorianCalendar = + GregorianCalendar().apply { + firstDayOfWeek = Calendar.MONDAY + minimalDaysInFirstWeek = 4 + timeInMillis = time + } + + /** e.g. "2026-W34" */ + fun weekKey(time: Long): String { + val cal = isoCalendar(time) + return String.format( + Locale.US, "%04d-W%02d", cal.weekYear, cal.get(Calendar.WEEK_OF_YEAR) + ) + } + + /** e.g. "2026-08" */ + fun monthKey(time: Long): String { + val cal = isoCalendar(time) + return String.format( + Locale.US, "%04d-%02d", cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1 + ) + } + + /** e.g. "2026-Q3" */ + fun quarterKey(time: Long): String { + val cal = isoCalendar(time) + return String.format( + Locale.US, "%04d-Q%d", cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) / 3 + 1 + ) + } + + fun weekKeyAt(offsetWeeks: Int, now: Long): String { + val cal = isoCalendar(now) + cal.add(Calendar.WEEK_OF_YEAR, offsetWeeks) + return weekKey(cal.timeInMillis) + } + + fun monthKeyAt(offsetMonths: Int, now: Long): String { + val cal = isoCalendar(now) + cal.set(Calendar.DAY_OF_MONTH, 1) + cal.add(Calendar.MONTH, offsetMonths) + return monthKey(cal.timeInMillis) + } + + fun weekIntervalAt(offsetWeeks: Int, now: Long): LongRange { + val cal = isoCalendar(now) + cal.add(Calendar.WEEK_OF_YEAR, offsetWeeks) + cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY) + startOfDay(cal) + val start = cal.timeInMillis + cal.add(Calendar.WEEK_OF_YEAR, 1) + return start until cal.timeInMillis + } + + fun monthIntervalAt(offsetMonths: Int, now: Long): LongRange { + val cal = isoCalendar(now) + cal.set(Calendar.DAY_OF_MONTH, 1) + startOfDay(cal) + cal.add(Calendar.MONTH, offsetMonths) + val start = cal.timeInMillis + cal.add(Calendar.MONTH, 1) + return start until cal.timeInMillis + } + + fun quarterIntervalAt(offsetQuarters: Int, now: Long): LongRange { + val cal = isoCalendar(quarterStart(now)) + cal.add(Calendar.MONTH, offsetQuarters * 3) + val start = cal.timeInMillis + cal.add(Calendar.MONTH, 3) + return start until cal.timeInMillis + } + + fun quarterStart(now: Long): Long { + val cal = isoCalendar(now) + cal.set(Calendar.MONTH, cal.get(Calendar.MONTH) / 3 * 3) + cal.set(Calendar.DAY_OF_MONTH, 1) + startOfDay(cal) + return cal.timeInMillis + } + + private fun startOfDay(cal: Calendar) { + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeDefinitions.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeDefinitions.kt new file mode 100644 index 000000000..bfbf5f8e9 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeDefinitions.kt @@ -0,0 +1,238 @@ +package org.piramalswasthya.sakhi.badges.domain + +import org.piramalswasthya.sakhi.R + +/** + * Badge catalog (LLD §1.1 / §3.1). Rules are data, not conditionals: + * adding or tuning a badge is a change here (or a server config override), + * not an evaluator rewrite. + */ +enum class BadgeKind { + /** Consecutive ISO weeks (streak protection applies). */ + STREAK_WEEKLY, + + /** Consecutive calendar months (streak protection applies). */ + STREAK_MONTHLY, + + /** Threshold met within the current quarter / rolling window; re-earned every quarter. */ + QUARTERLY, + + /** Lifetime distinct-count milestones. */ + CUMULATIVE, + + /** One recognition per beneficiary case. */ + PER_CASE +} + +data class BadgeDefinition( + val id: String, + val kind: BadgeKind, + /** Milestone thresholds; for QUARTERLY/PER_CASE a single threshold. */ + val milestones: List, + val defaultGraceTokens: Int = 0, + val enabledByDefault: Boolean = true, + val titleRes: Int, + val descRes: Int, + /** Fallback / locked icon (tier 1 artwork or placeholder). */ + val iconRes: Int, + /** + * Per-level artwork, index 0..3: milestone tiers for streak/cumulative + * badges, Q1..Q4 for quarterly badges. Empty → iconRes for every level. + */ + val tierIcons: List = emptyList(), + /** + * Whether earning this badge triggers the celebration overlay and + * notification. Off for badges earned by automatic behaviour (syncing), + * where a surprise popup feels unearned. + */ + val celebrate: Boolean = true +) + +object BadgeIds { + const val STEADY_SYNCER = "steady_syncer" + const val TIMELY_REPORTER = "timely_reporter" + const val COMPLETE_WORKER = "complete_worker" + const val COMMUNITY_VOICE = "community_voice" + const val MATERNAL_JOURNEY = "maternal_journey" + const val CHILD_FULLY_PROTECTED = "child_fully_protected" + const val DIGITAL_IDENTITY = "digital_identity" + const val VULNERABLE_BABY = "vulnerable_baby" + const val CRITICAL_REFERRAL = "critical_referral" +} + +/** BADGE_CONFIG keys. Server values override the compiled defaults below. */ +object BadgeConfigKeys { + const val FEATURE_ENABLED = "feature_enabled" + const val COPY_VERSION = "copy_version" + fun milestones(badgeId: String) = "milestones.$badgeId" + fun enabled(badgeId: String) = "enabled.$badgeId" + fun grace(badgeId: String) = "grace.$badgeId" +} + +object BadgeDefinitions { + + val ALL: List = listOf( + BadgeDefinition( + id = BadgeIds.STEADY_SYNCER, + kind = BadgeKind.STREAK_WEEKLY, + milestones = listOf(2, 4, 6, 8), // reviewer: fortnightly cadence — 2/4/6/8 weeks + defaultGraceTokens = 1, // 1-week grace period (LLD §1.1) + celebrate = false, // sync is automatic — no surprise popups for it + titleRes = R.string.badge_steady_syncer_title, + descRes = R.string.badge_steady_syncer_desc, + iconRes = R.drawable.badge_steady_syncer_t1, + tierIcons = listOf( + R.drawable.badge_steady_syncer_t1, + R.drawable.badge_steady_syncer_t2, + R.drawable.badge_steady_syncer_t3, + R.drawable.badge_steady_syncer_t4 + ) + ), + BadgeDefinition( + id = BadgeIds.TIMELY_REPORTER, + kind = BadgeKind.STREAK_MONTHLY, + milestones = listOf(1, 3, 6, 12), // first on-time month = instant bronze (from 2,4,6,12) + titleRes = R.string.badge_timely_reporter_title, + descRes = R.string.badge_timely_reporter_desc, + iconRes = R.drawable.badge_timely_reporter_t1, + tierIcons = listOf( + R.drawable.badge_timely_reporter_t1, + R.drawable.badge_timely_reporter_t2, + R.drawable.badge_timely_reporter_t3, + R.drawable.badge_timely_reporter_t4 + ) + ), + BadgeDefinition( + id = BadgeIds.COMPLETE_WORKER, + kind = BadgeKind.QUARTERLY, + milestones = listOf(3), // 3+ health domains, rolling 90 days (from 4) + titleRes = R.string.badge_complete_worker_title, + descRes = R.string.badge_complete_worker_desc, + iconRes = R.drawable.badge_complete_worker_t1, + tierIcons = listOf( + R.drawable.badge_complete_worker_t1, + R.drawable.badge_complete_worker_t2, + R.drawable.badge_complete_worker_t3, + R.drawable.badge_complete_worker_t4 + ) + ), + BadgeDefinition( + id = BadgeIds.COMMUNITY_VOICE, + kind = BadgeKind.QUARTERLY, + milestones = listOf(2), // 2 of 4 meeting types per quarter (from 4) + titleRes = R.string.badge_community_voice_title, + descRes = R.string.badge_community_voice_desc, + iconRes = R.drawable.badge_community_voice_t1, + tierIcons = listOf( + R.drawable.badge_community_voice_t1, + R.drawable.badge_community_voice_t2, + R.drawable.badge_community_voice_t3, + R.drawable.badge_community_voice_t4 + ) + ), + BadgeDefinition( + id = BadgeIds.MATERNAL_JOURNEY, + kind = BadgeKind.CUMULATIVE, + milestones = listOf(1, 5, 15, 30), // the very first completed journey is celebrated (from 5,15,30,50) + titleRes = R.string.badge_maternal_journey_title, + descRes = R.string.badge_maternal_journey_desc, + iconRes = R.drawable.badge_maternal_journey_t1, + tierIcons = listOf( + R.drawable.badge_maternal_journey_t1, + R.drawable.badge_maternal_journey_t2, + R.drawable.badge_maternal_journey_t3, + R.drawable.badge_maternal_journey_t4 + ) + ), + BadgeDefinition( + id = BadgeIds.CHILD_FULLY_PROTECTED, + kind = BadgeKind.CUMULATIVE, + milestones = listOf(3, 10, 25, 60), // from 10,25,60,100 + titleRes = R.string.badge_child_fully_protected_title, + descRes = R.string.badge_child_fully_protected_desc, + iconRes = R.drawable.badge_child_fully_protected_t1, + tierIcons = listOf( + R.drawable.badge_child_fully_protected_t1, + R.drawable.badge_child_fully_protected_t2, + R.drawable.badge_child_fully_protected_t3, + R.drawable.badge_child_fully_protected_t4 + ) + ), + BadgeDefinition( + id = BadgeIds.DIGITAL_IDENTITY, + kind = BadgeKind.CUMULATIVE, + milestones = listOf(10, 25, 75, 150), // from 25,75,150,300 + titleRes = R.string.badge_digital_identity_title, + descRes = R.string.badge_digital_identity_desc, + iconRes = R.drawable.badge_digital_identity_t1, + tierIcons = listOf( + R.drawable.badge_digital_identity_t1, + R.drawable.badge_digital_identity_t2, + R.drawable.badge_digital_identity_t3, + R.drawable.badge_digital_identity_t4 + ) + ), + BadgeDefinition( + id = BadgeIds.VULNERABLE_BABY, + kind = BadgeKind.PER_CASE, + milestones = listOf(1), + titleRes = R.string.badge_vulnerable_baby_title, + descRes = R.string.badge_vulnerable_baby_desc, + iconRes = R.drawable.badge_vulnerable_baby + ), + BadgeDefinition( + id = BadgeIds.CRITICAL_REFERRAL, + kind = BadgeKind.PER_CASE, + milestones = listOf(1), + // LLD §1.3: temporarily deferred until the referral signal gains + // data-model support. Rule is implemented; enable via server config. + enabledByDefault = false, + titleRes = R.string.badge_critical_referral_title, + descRes = R.string.badge_critical_referral_desc, + iconRes = R.drawable.badge_critical_referral + ) + ) + + fun byId(id: String): BadgeDefinition? = ALL.firstOrNull { it.id == id } + + /** Effective milestones for a badge: server override or compiled default. */ + fun effectiveMilestones(def: BadgeDefinition, config: Map): List = + config[BadgeConfigKeys.milestones(def.id)] + ?.split(",") + ?.mapNotNull { it.trim().toLongOrNull() } + ?.takeIf { it.isNotEmpty() } + ?: def.milestones + + fun effectiveGrace(def: BadgeDefinition, config: Map): Int = + config[BadgeConfigKeys.grace(def.id)]?.toIntOrNull() ?: def.defaultGraceTokens + + fun isEnabled(def: BadgeDefinition, config: Map): Boolean = + config[BadgeConfigKeys.enabled(def.id)]?.toBooleanStrictOrNull() ?: def.enabledByDefault + + fun isFeatureEnabled(config: Map): Boolean = + config[BadgeConfigKeys.FEATURE_ENABLED]?.toBooleanStrictOrNull() ?: true + + /** + * Artwork for the badge's current state: (icon res, earned-look). + * Streak/cumulative badges show the highest earned tier (tier 1 dimmed + * while locked); quarterly badges show the current calendar quarter's + * artwork, dimmed until earned this quarter. + */ + fun displayIcon( + def: BadgeDefinition, + state: org.piramalswasthya.sakhi.model.BadgeStateCache? + ): Pair { + val level = state?.currentLevel ?: 0 + return when (def.kind) { + BadgeKind.QUARTERLY -> { + val quarter = java.util.Calendar.getInstance().get(java.util.Calendar.MONTH) / 3 + (def.tierIcons.getOrNull(quarter) ?: def.iconRes) to (level > 0) + } + + BadgeKind.PER_CASE -> def.iconRes to ((state?.progress ?: 0L) > 0L) + + else -> (def.tierIcons.getOrNull((level - 1).coerceAtLeast(0)) + ?: def.iconRes) to (level > 0) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeEvaluator.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeEvaluator.kt new file mode 100644 index 000000000..1a11985a2 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeEvaluator.kt @@ -0,0 +1,348 @@ +package org.piramalswasthya.sakhi.badges.domain + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.model.BadgeEarnedCache +import org.piramalswasthya.sakhi.model.BadgeStateCache +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Core engine (LLD §3, §5.1). Recompute, never increment: every run + * recalculates badge progress from current local facts and overwrites only + * the mutable BADGE_STATE. Awarded milestones go to the append-only + * BADGE_EARNED log ("once earned, never revoked") whose unique constraint + * makes double-runs and reinstalls idempotent. + */ +@Singleton +class BadgeEvaluator @Inject constructor( + private val badgeDao: BadgeDao, + private val facts: BadgeFactsReader, + private val streakEngine: StreakEngine, + private val pref: PreferenceDao, + private val celebrations: BadgeCelebrations, + @ApplicationContext private val context: Context +) { + + private val mutex = Mutex() + + @Volatile + private var lastRunAt = 0L + + suspend fun evaluateAll() = withContext(Dispatchers.IO) { + mutex.withLock { + // collapse the burst of triggers at app launch (bus + workers) + // into one pass — full table scans are costly on low-end devices + if (System.currentTimeMillis() - lastRunAt < RUN_THROTTLE_MS) return@withLock + lastRunAt = System.currentTimeMillis() + val userId = try { + pref.getLoggedInUser()?.userId + } catch (e: Exception) { + null + } + if (userId == null) { + Timber.w("Badges: evaluation skipped — no logged-in user") + return@withLock + } + + val config = try { + badgeDao.getConfig().associate { it.key to it.value } + } catch (e: Exception) { + emptyMap() + } + // Remote kill-switch (LLD §5.2): evaluation skipped without crashes. + if (!BadgeDefinitions.isFeatureEnabled(config)) return@withLock + + val freezes = try { + badgeDao.getFreezes() + } catch (e: Exception) { + emptyList() + } + val now = System.currentTimeMillis() + val states = mutableListOf() + val earned = mutableListOf() + val priorEarned = try { + badgeDao.getEarned(userId) + } catch (e: Exception) { + emptyList() + } + + for (def in BadgeDefinitions.ALL) { + if (!BadgeDefinitions.isEnabled(def, config)) continue + try { + evaluate( + def, config, freezes, userId, now, states, earned, + hasHistory = priorEarned.any { it.badgeId == def.id } + ) + } catch (e: Exception) { + // One badge family failing must not stop the others (LLD §5.2) + Timber.w(e, "Badges: evaluation failed for ${def.id}") + } + } + + // never revoked: a state can't show a lower tier than one permanently + // earned (reinstall backfill, lowered milestones, demo history) + val maxEarned = priorEarned.groupBy { it.badgeId }.mapValues { e -> e.value.maxOf { it.level } } + badgeDao.upsertStates(states.map { s -> + val kind = BadgeDefinitions.byId(s.badgeId)?.kind + val floor = maxEarned[s.badgeId] ?: 0 + val tiered = kind == BadgeKind.STREAK_WEEKLY || kind == BadgeKind.STREAK_MONTHLY || + kind == BadgeKind.CUMULATIVE + if (tiered && s.currentLevel < floor) s.copy(currentLevel = floor) else s + }) + // The first evaluation on an install awards whatever the existing records + // already earned, which is history rather than achievement, so it stays silent. + // Read before the insert and cleared after, so a crash mid-run repeats the + // silent pass instead of firing a backlog of notifications on the next launch. + val isBackfillRun = !pref.isBadgeBackfillDone + if (earned.isNotEmpty()) { + val insertedIds = badgeDao.insertEarned(earned) + if (!isBackfillRun) { + // several tiers can land in one run (backfill, first pull) — + // celebrate only the top tier reached per badge, not each one + val newRows = insertedIds.zip(earned) + .filter { it.first != -1L } + .map { it.second } + .filter { BadgeDefinitions.byId(it.badgeId)?.celebrate == true } + .groupBy { it.badgeId } + .map { (_, rows) -> rows.maxBy { it.level } } + // system notification for the first new award (works backgrounded) + newRows.firstOrNull()?.let { celebrate(it) } + // in-app game-style overlay for each newly earned badge + newRows.forEach { celebrations.publish(it.badgeId, it.level) } + } + } + if (isBackfillRun) pref.isBadgeBackfillDone = true + Timber.d("Badges: evaluated ${states.size} badges, ${earned.size} candidate awards") + } + } + + /** Daytime celebration when a new milestone unlocks (checkAndUnlock). */ + private fun celebrate(newRow: BadgeEarnedCache) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) return + val def = BadgeDefinitions.byId(newRow.badgeId) ?: return + val name = pref.getLoggedInUser()?.name ?: "" + val body = context.getString( + R.string.badge_unlock_notification, name, context.getString(def.titleRes) + ) + val manager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CELEBRATION_CHANNEL_ID, + context.getString(R.string.badge_shelf_title), + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + } + val intent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP } + val pending = intent?.let { + PendingIntent.getActivity( + context, CELEBRATION_ID, it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + manager.notify( + CELEBRATION_ID, + NotificationCompat.Builder(context, CELEBRATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(context.getString(R.string.badge_shelf_title)) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(pending) + .setAutoCancel(true) + .build() + ) + } catch (e: Exception) { + Timber.w(e, "Badges: celebration notification failed") + } + } + + companion object { + private const val CELEBRATION_CHANNEL_ID = "badge_celebration" + private const val CELEBRATION_ID = 20003 + private const val RUN_THROTTLE_MS = 10_000L + + // ponytail: 2-year quarterly backfill; raise if older history matters + private const val PAST_QUARTERS_LOOKBACK = 8 + } + + private suspend fun evaluate( + def: BadgeDefinition, + config: Map, + freezes: List, + userId: Int, + now: Long, + states: MutableList, + earned: MutableList, + hasHistory: Boolean + ) { + val milestones = BadgeDefinitions.effectiveMilestones(def, config) + + when (def.kind) { + BadgeKind.STREAK_WEEKLY, BadgeKind.STREAK_MONTHLY -> { + val weekly = def.kind == BadgeKind.STREAK_WEEKLY + // sync log ∪ activity weeks: activity is recomputable from + // re-synced records, so streak history survives a reinstall + val completed = + if (weekly) badgeDao.getAllSyncWeeks().toSet() + facts.activityWeeks() + else facts.onTimeIncentiveMonths() + val periodKeyAt = { off: Int -> + if (weekly) BadgeDates.weekKeyAt(off, now) else BadgeDates.monthKeyAt(off, now) + } + val streak = streakEngine.compute( + completedPeriods = completed, + graceTokens = BadgeDefinitions.effectiveGrace(def, config), + freezes = freezes, + badgeId = def.id, + periodKeyAt = periodKeyAt, + periodIntervalAt = { off -> + if (weekly) BadgeDates.weekIntervalAt(off, now) + else BadgeDates.monthIntervalAt(off, now) + } + ) + // once earned, never revoked: award on the best run ever, so a + // broken streak or a reinstall keeps previously earned tiers + val bestRun = maxOf(streak.length, streakEngine.longestRun(completed, periodKeyAt)) + val level = milestones.count { bestRun >= it } + states += BadgeStateCache( + userId = userId, + badgeId = def.id, + currentLevel = level, + progress = streak.length, + nextTarget = milestones.firstOrNull { streak.length < it } ?: milestones.last(), + streakCount = streak.length, + graceRemaining = streak.graceRemaining, + lastEvaluatedAt = now + ) + for (lvl in 1..level) { + earned += BadgeEarnedCache( + userId = userId, badgeId = def.id, level = lvl, earnedAt = now + ) + } + } + + BadgeKind.QUARTERLY -> { + val since = when (def.id) { + // rolling 90-day window (LLD §3.1) + BadgeIds.COMPLETE_WORKER -> now - TimeUnit.DAYS.toMillis(90) + else -> BadgeDates.quarterStart(now) + } + val measure = when (def.id) { + BadgeIds.COMPLETE_WORKER -> facts.activeDomainsSince(since) + else -> facts.meetingTypesSince(since) + } + val threshold = milestones.first() + val met = measure >= threshold + states += BadgeStateCache( + userId = userId, + badgeId = def.id, + currentLevel = if (met) 1 else 0, + progress = measure, + nextTarget = threshold, + lastEvaluatedAt = now + ) + if (met) { + // re-earned every quarter: caseRef keys the quarter + earned += BadgeEarnedCache( + userId = userId, badgeId = def.id, level = 1, + caseRef = BadgeDates.quarterKey(now), earnedAt = now + ) + } + // reinstall restore: while this badge has no earned history, + // recompute past quarters from re-synced record dates + // (calendar quarters approximate Complete Worker's rolling 90d) + if (!hasHistory) { + for (q in 1..PAST_QUARTERS_LOOKBACK) { + val window = BadgeDates.quarterIntervalAt(-q, now) + val past = when (def.id) { + BadgeIds.COMPLETE_WORKER -> + facts.activeDomainsSince(window.first, window.last + 1) + + else -> facts.meetingTypesSince(window.first, window.last + 1) + } + if (past >= threshold) { + earned += BadgeEarnedCache( + userId = userId, badgeId = def.id, level = 1, + caseRef = BadgeDates.quarterKey(window.first), earnedAt = now + ) + } + } + } + } + + BadgeKind.CUMULATIVE -> { + val count = when (def.id) { + BadgeIds.MATERNAL_JOURNEY -> facts.completedMaternalJourneys().size + BadgeIds.CHILD_FULLY_PROTECTED -> facts.fullyImmunizedChildren().size + BadgeIds.DIGITAL_IDENTITY -> facts.abhaGeneratedBens().size + else -> 0 + }.toLong() + val level = milestones.count { count >= it } + states += BadgeStateCache( + userId = userId, + badgeId = def.id, + currentLevel = level, + progress = count, + nextTarget = milestones.firstOrNull { count < it } ?: milestones.last(), + lastEvaluatedAt = now + ) + for (lvl in 1..level) { + earned += BadgeEarnedCache( + userId = userId, badgeId = def.id, level = lvl, earnedAt = now + ) + } + } + + BadgeKind.PER_CASE -> { + val cases = when (def.id) { + BadgeIds.VULNERABLE_BABY -> facts.vulnerableBabiesCaredFor() + BadgeIds.CRITICAL_REFERRAL -> facts.nrcReferredChildren() + else -> emptyList() + } + states += BadgeStateCache( + userId = userId, + badgeId = def.id, + currentLevel = if (cases.isEmpty()) 0 else 1, + progress = cases.size.toLong(), + nextTarget = milestones.first(), + lastEvaluatedAt = now + ) + for (case in cases) { + // One recognition per beneficiary. The beneficiary id is hashed here, at + // the point the award is made, rather than on the way to the server: the + // digest is then the only form of the key that exists, so the local + // uniqueness constraint and the server's agree and a restored award is + // the same row as the one evaluation would derive (AwardKeys). + earned += BadgeEarnedCache( + userId = userId, badgeId = def.id, level = 1, + caseRef = AwardKeys.forCase(case), earnedAt = now + ) + } + } + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeFactsReader.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeFactsReader.kt new file mode 100644 index 000000000..9cdede9d7 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/BadgeFactsReader.kt @@ -0,0 +1,304 @@ +package org.piramalswasthya.sakhi.badges.domain + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.json.JSONObject +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.database.room.InAppDb +import timber.log.Timber +import java.util.TimeZone +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Read-only facts over the existing health tables (LLD §3). Never writes. + * + * Guards (LLD §5.2): every fact is wrapped per-table in try/catch and date + * columns are resolved at runtime (INTEGER columns only) — a missing table, + * renamed column or TEXT date makes that table contribute 0, never crash. + */ +@Singleton +class BadgeFactsReader @Inject constructor( + private val db: InAppDb, + @ApplicationContext private val context: Context +) { + + private val sql get() = db.openHelper.readableDatabase + + // ───────────────────────────────────────────────────────────── + // Runtime schema resolution + // ───────────────────────────────────────────────────────────── + + private val columnCache = mutableMapOf>() + + /** column name (lowercase) → declared type (uppercase), empty map if table missing. */ + private fun columns(table: String): Map = + columnCache.getOrPut(table) { + try { + val map = mutableMapOf() + sql.query("PRAGMA table_info(`$table`)").use { c -> + val nameIdx = c.getColumnIndex("name") + val typeIdx = c.getColumnIndex("type") + while (c.moveToNext()) { + map[c.getString(nameIdx).lowercase()] = + (c.getString(typeIdx) ?: "").uppercase() + } + } + map + } catch (e: Exception) { + Timber.w(e, "Badges: cannot inspect table $table") + emptyMap() + } + } + + private fun tableExists(table: String) = columns(table).isNotEmpty() + + /** First INTEGER-typed creation-date column, or null. */ + private fun integerDateColumn(table: String): String? { + val cols = columns(table) + return DATE_COLUMN_CANDIDATES.firstOrNull { cols[it]?.contains("INT") == true } + } + + /** "AND isDraft = 0" where the column exists (LLD §3 enforcement). */ + private fun draftFilter(table: String, alias: String = ""): String { + val prefix = if (alias.isEmpty()) "" else "$alias." + return if (columns(table).containsKey("isdraft")) " AND ${prefix}isDraft = 0" else "" + } + + private fun safely(what: String, fallback: T, block: () -> T): T = + try { + block() + } catch (e: Exception) { + Timber.w(e, "Badges: fact '$what' failed — contributing $fallback") + fallback + } + + private fun queryStrings(query: String, args: Array = emptyArray()): List { + val out = mutableListOf() + sql.query(query, args).use { c -> + while (c.moveToNext()) c.getString(0)?.let { out.add(it) } + } + return out + } + + private fun queryLong(query: String): Long = + sql.query(query).use { c -> if (c.moveToFirst()) c.getLong(0) else 0L } + + /** Count of non-draft records in [table] created in [since, until); 0 when unresolvable. */ + private fun countNewRecordsSince(table: String, since: Long, until: Long = Long.MAX_VALUE): Long = + safely("count $table", 0L) { + if (!tableExists(table)) return@safely 0L + val dateCol = integerDateColumn(table) ?: return@safely 0L + queryLong( + "SELECT COUNT(*) FROM `$table` WHERE `$dateCol` >= $since AND `$dateCol` < $until${draftFilter(table)}" + ) + } + + // ───────────────────────────────────────────────────────────── + // Badge signals (LLD §3.1) + // ───────────────────────────────────────────────────────────── + + /** Timely Reporter: months whose incentive record was created on time (createdDate ≤ endDate). */ + fun onTimeIncentiveMonths(): Set = safely("onTimeIncentiveMonths", emptySet()) { + val out = mutableSetOf() + sql.query( + "SELECT createdDate, endDate FROM INCENTIVE_RECORD WHERE createdDate <= endDate" + ).use { c -> + while (c.moveToNext()) out.add(BadgeDates.monthKey(c.getLong(1))) + } + out + } + + /** Complete Worker: distinct health domains with at least one new record in [since, until). */ + fun activeDomainsSince(since: Long, until: Long = Long.MAX_VALUE): Long = + safely("activeDomains", 0L) { + DOMAIN_TABLES.count { (_, tables) -> + tables.any { countNewRecordsSince(it, since, until) > 0 } + }.toLong() + } + + /** Community Voice: distinct meeting types with at least one record in [since, until). */ + fun meetingTypesSince(since: Long, until: Long = Long.MAX_VALUE): Long = + safely("meetingTypes", 0L) { + MEETING_TABLES.count { countNewRecordsSince(it, since, until) > 0 }.toLong() + } + + /** + * Weeks with any recorded work, derived from record timestamps across all + * badge-mapped tables. Auto-sync fires on every save, so an activity week + * is a sync week in practice — and because re-downloaded records keep + * their original dates, this restores Steady Syncer history after a + * reinstall without any backend (union with the local BADGE_SYNC_LOG). + */ + fun activityWeeks(): Set = safely("activityWeeks", emptySet()) { + val out = mutableSetOf() + val dayMs = TimeUnit.DAYS.toMillis(1) + // Bucket by LOCAL day, not UTC. Dividing an epoch millisecond by a day gives the UTC + // day, so in IST every record saved between 00:00 and 05:30 falls into the previous + // one; when that local day is a Monday the reconstructed instant lands in the + // previous ISO week and Steady Syncer silently loses an activity week after a + // reinstall. India does not observe DST, so one current offset is exact here rather + // than merely close. + val offset = TimeZone.getDefault().getOffset(System.currentTimeMillis()).toLong() + for (table in MAPPED_TABLES) { + if (!tableExists(table)) continue + val dateCol = integerDateColumn(table) ?: continue + safely("activityWeeks:$table", Unit) { + sql.query( + "SELECT DISTINCT (`$dateCol` + $offset) / $dayMs FROM `$table` " + + "WHERE `$dateCol` > 0${draftFilter(table)}" + ).use { c -> + while (c.moveToNext()) { + // Midday of that local day: far enough from either boundary that + // rounding cannot push the key into a neighbouring week. + out.add(BadgeDates.weekKey(c.getLong(0) * dayMs - offset + dayMs / 2)) + } + } + } + } + out + } + + /** + * Maternal Journey: distinct completed journeys — registered pregnancy with + * ≥4 ANC visits (structured + dynamic history aggregated) ending in an + * institutional delivery. First-trimester check applies only when an + * INTEGER ANC date column is resolvable. + */ + fun completedMaternalJourneys(): List = safely("maternalJourneys", emptyList()) { + if (!tableExists("PREGNANCY_REGISTER") || !tableExists("DELIVERY_OUTCOME")) { + return@safely emptyList() + } + val ancStructured = if (tableExists("PREGNANCY_ANC")) + "SELECT COUNT(*) FROM PREGNANCY_ANC a WHERE a.benId = pr.benId${draftFilter("PREGNANCY_ANC", "a")}" + else "SELECT 0" + val ancDynamic = if (tableExists("ALL_VISIT_HISTORY_ANC")) + "SELECT COUNT(DISTINCT v.visitDay) FROM ALL_VISIT_HISTORY_ANC v WHERE v.benId = pr.benId" + else "SELECT 0" + + val firstTrimester = + if (columns("PREGNANCY_ANC")["ancdate"]?.contains("INT") == true && + columns("PREGNANCY_REGISTER")["lmp"]?.contains("INT") == true + ) { + val trimesterMs = TimeUnit.DAYS.toMillis(97) + " AND EXISTS(SELECT 1 FROM PREGNANCY_ANC t WHERE t.benId = pr.benId " + + "AND t.ancDate <= pr.lmp + $trimesterMs)" + } else "" + + queryStrings( + """ + SELECT DISTINCT pr.benId FROM PREGNANCY_REGISTER pr + JOIN DELIVERY_OUTCOME d ON d.benId = pr.benId + WHERE IFNULL(d.placeOfDelivery, '') != '' + AND LOWER(d.placeOfDelivery) NOT LIKE '%home%' + ${draftFilter("PREGNANCY_REGISTER", "pr")} + ${draftFilter("DELIVERY_OUTCOME", "d")} + AND (($ancStructured) + ($ancDynamic)) >= 4 + $firstTrimester + """.trimIndent() + ) + } + + /** + * Child Fully Protected: beneficiaries whose administered doses cover every + * CHILD vaccine expected within the 0–1 year schedule. + */ + fun fullyImmunizedChildren(): List = safely("fullyImmunized", emptyList()) { + if (!tableExists("IMMUNIZATION") || !tableExists("VACCINE")) return@safely emptyList() + val oneYearMs = TimeUnit.DAYS.toMillis(365) + val expected = queryLong( + "SELECT COUNT(*) FROM VACCINE WHERE category = 'CHILD' AND maxAllowedAgeInMillis <= $oneYearMs" + ) + if (expected == 0L) return@safely emptyList() + queryStrings( + """ + SELECT i.beneficiaryId FROM IMMUNIZATION i + JOIN VACCINE v ON v.vaccineId = i.vaccineId + WHERE v.category = 'CHILD' AND v.maxAllowedAgeInMillis <= $oneYearMs + AND i.date IS NOT NULL + GROUP BY i.beneficiaryId + HAVING COUNT(DISTINCT i.vaccineId) >= $expected + """.trimIndent() + ) + } + + /** Digital Identity: beneficiaries with a valid generated ABHA. */ + fun abhaGeneratedBens(): List = safely("abhaGenerated", emptyList()) { + if (!tableExists("ABHA_GENERATED")) return@safely emptyList() + queryStrings( + "SELECT DISTINCT beneficiaryID FROM ABHA_GENERATED WHERE IFNULL(healthIdNumber, '') != ''" + ) + } + + /** + * Vulnerable Baby Cared For: LBW (<2.5kg) or SNCU babies with all seven + * HBNC visits (days 1,3,7,14,21,28,42) completed. HBNC.benId is the + * infant's beneficiary id → INFANT_REG.childBenId. + */ + fun vulnerableBabiesCaredFor(): List = safely("vulnerableBabies", emptyList()) { + if (!tableExists("HBNC") || !tableExists("INFANT_REG")) return@safely emptyList() + queryStrings( + """ + SELECT h.benId FROM HBNC h + JOIN INFANT_REG i ON i.childBenId = h.benId + WHERE (i.isSNCU = 'Yes' OR IFNULL(i.weight, 99) < 2.5) + AND h.homeVisitDate IN (1,3,7,14,21,28,42) + GROUP BY h.benId + HAVING COUNT(DISTINCT h.homeVisitDate) >= 7 + """.trimIndent() + ) + } + + /** + * Critical Referral Made: SAM child referred to NRC, parsed from the + * dynamic form JSON (same contract as CUFYFormRepository). + */ + fun nrcReferredChildren(): List = safely("nrcReferred", emptyList()) { + if (!tableExists("children_under_five_all_visit")) return@safely emptyList() + val key = context.getString(R.string.is_child_referred_nrc) + val out = mutableSetOf() + sql.query("SELECT benId, formDataJson FROM children_under_five_all_visit").use { c -> + while (c.moveToNext()) { + try { + val fields = JSONObject(c.getString(1)).optJSONObject("fields") ?: continue + if (fields.optString(key) == "Yes") out.add(c.getString(0)) + } catch (_: Exception) { + // malformed free-form JSON — skip record (LLD §5.2) + } + } + } + out.toList() + } + + companion object { + private val DATE_COLUMN_CANDIDATES = + listOf("createddate", "createdat", "updateddate", "updatedat", "date", "visitdate") + + /** Complete Worker domains (LLD §1.1): Child, Maternal, Immunization, FP, Disease. */ + private val DOMAIN_TABLES = mapOf( + "child" to listOf("INFANT_REG", "CHILD_REG", "HBNC", "children_under_five_all_visit"), + "maternal" to listOf( + "PREGNANCY_REGISTER", "PREGNANCY_ANC", "ALL_VISIT_HISTORY_ANC", + "DELIVERY_OUTCOME", "PNC_VISIT", "PMSMA" + ), + "immunization" to listOf("IMMUNIZATION"), + "family_planning" to listOf("ELIGIBLE_COUPLE_REG", "ELIGIBLE_COUPLE_TRACKING", "FPOT"), + "disease" to listOf( + "TB_SCREENING", "TB_SUSPECTED", "MALARIA_SCREENING", "AES_SCREENING", + "KALAZAR_SCREENING", "FILARIA_SCREENING", "LEPROSY_SCREENING" + ) + ) + + /** Community Voice meeting types (LLD §3.1): MAA, NDD (deworming), AHD, U-WIN. */ + private val MEETING_TABLES = + listOf("MAA_MEETING", "DewormingMeeting", "AHDMeeting", "UWIN_SESSION") + + /** Tables whose invalidation should wake the evaluator (TaskCompletionBus). */ + val MAPPED_TABLES: Array = ( + DOMAIN_TABLES.values.flatten() + MEETING_TABLES + listOf( + "INCENTIVE_RECORD", "IMMUNIZATION", "ABHA_GENERATED" + ) + ).distinct().toTypedArray() + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/StreakEngine.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/StreakEngine.kt new file mode 100644 index 000000000..8c2afaf44 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/StreakEngine.kt @@ -0,0 +1,94 @@ +package org.piramalswasthya.sakhi.badges.domain + +import org.piramalswasthya.sakhi.model.BadgeStreakFreezeCache +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Streak fairness (LLD §3.3). A missed period, checked in order: + * 1. Freeze window → period excluded, streak unchanged, no token consumed. + * 2. Grace token → one token consumed, streak continues. + * 3. Streak break → active streak resets (earned milestones stay in BADGE_EARNED). + */ +@Singleton +class StreakEngine @Inject constructor() { + + data class Streak(val length: Long, val graceRemaining: Int) + + /** + * Walks periods backwards from the current one. The current, still-running + * period counts if completed but never breaks the streak or consumes grace. + * + * @param periodKeyAt offset (0 = current, -1 = previous …) → period key + * @param periodIntervalAt offset → [start, end] millis of that period + */ + fun compute( + completedPeriods: Set, + graceTokens: Int, + freezes: List, + badgeId: String, + periodKeyAt: (Int) -> String, + periodIntervalAt: (Int) -> LongRange, + maxLookback: Int = MAX_LOOKBACK + ): Streak { + if (completedPeriods.isEmpty()) return Streak(0, graceTokens) + + val applicableFreezes = freezes.filter { it.badgeId.isEmpty() || it.badgeId == badgeId } + val remaining = completedPeriods.toMutableSet() + var length = 0L + var grace = graceTokens + + if (remaining.remove(periodKeyAt(0))) length++ + + for (offset in -1 downTo -maxLookback) { + // nothing older left to bridge to — don't burn grace on the tail + if (remaining.isEmpty()) break + + when { + remaining.remove(periodKeyAt(offset)) -> length++ + + inFreezeWindow(periodIntervalAt(offset), applicableFreezes) -> Unit + + grace > 0 -> grace-- + + else -> return Streak(length, grace) + } + } + return Streak(length, grace) + } + + /** + * Longest consecutive run anywhere in history ("once earned, never + * revoked" across streak breaks and reinstalls). Conservative: no grace + * or freeze bridging for past runs — those only protect the live streak. + */ + fun longestRun( + completedPeriods: Set, + periodKeyAt: (Int) -> String, + maxLookback: Int = MAX_LOOKBACK + ): Long { + if (completedPeriods.isEmpty()) return 0 + val remaining = completedPeriods.toMutableSet() + var best = 0L + var run = 0L + var offset = 0 + while (remaining.isNotEmpty() && offset >= -maxLookback) { + if (remaining.remove(periodKeyAt(offset))) { + run++ + if (run > best) best = run + } else { + run = 0 + } + offset-- + } + return best + } + + private fun inFreezeWindow(period: LongRange, freezes: List): Boolean = + freezes.any { it.startDate <= period.last && it.endDate >= period.first } + + companion object { + // ponytail: 10-year lookback bound; raise if multi-decade streaks become real + private const val MAX_LOOKBACK = 520 + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/TaskCompletionBus.kt b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/TaskCompletionBus.kt new file mode 100644 index 000000000..0a3717d13 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/badges/domain/TaskCompletionBus.kt @@ -0,0 +1,73 @@ +package org.piramalswasthya.sakhi.badges.domain + +import androidx.room.InvalidationTracker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import org.piramalswasthya.sakhi.database.room.InAppDb +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Signals new records in badge-mapped health tables to wake the evaluator + * (LLD §2.1). Fed by Room's InvalidationTracker so every repository save path + * publishes automatically — no per-repository wiring. Bursts of saves are + * debounced into one recompute; recompute semantics (LLD §5.1) make the + * new-vs-edit distinction irrelevant for correctness. + */ +@Singleton +class TaskCompletionBus @Inject constructor( + private val db: InAppDb, + private val evaluator: BadgeEvaluator +) { + + private val started = AtomicBoolean(false) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // replay = 1: the startup publish() must survive until the collector + // subscribes — without it, launch-time evaluation silently never runs + // unless a sync happens to invalidate a table afterwards + private val events = MutableSharedFlow( + replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + /** Manual publish for callers outside Room (e.g. sync completion). */ + fun publish() { + events.tryEmit(Unit) + } + + /** Idempotent; call once from the home entry point. */ + @OptIn(FlowPreview::class) + fun start() { + if (!started.compareAndSet(false, true)) return + try { + db.invalidationTracker.addObserver( + object : InvalidationTracker.Observer(BadgeFactsReader.MAPPED_TABLES) { + override fun onInvalidated(tables: Set) = publish() + } + ) + } catch (e: Exception) { + Timber.e(e, "Badges: could not observe health tables") + } + scope.launch { + events.debounce(DEBOUNCE_MS).collect { + try { + evaluator.evaluateAll() + } catch (e: Exception) { + Timber.e(e, "Badges: evaluation from bus failed") + } + } + } + publish() // evaluate once on startup so UI is fresh + } + + companion object { + private const val DEBOUNCE_MS = 3_000L + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/InAppDb.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/InAppDb.kt index 9a296cdc3..1bb2cd038 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/room/InAppDb.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/InAppDb.kt @@ -16,7 +16,9 @@ import org.piramalswasthya.sakhi.database.converters.SyncStateConverter import org.piramalswasthya.sakhi.database.room.dao.ABHAGenratedDao import org.piramalswasthya.sakhi.database.room.dao.AdolescentHealthDao import org.piramalswasthya.sakhi.database.room.dao.AesDao +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao import org.piramalswasthya.sakhi.database.room.dao.BenDao +import org.piramalswasthya.sakhi.database.room.dao.EveningNotifDao import org.piramalswasthya.sakhi.database.room.dao.BeneficiaryIdsAvailDao import org.piramalswasthya.sakhi.database.room.dao.CbacDao import org.piramalswasthya.sakhi.database.room.dao.CdrDao @@ -60,7 +62,9 @@ import org.piramalswasthya.sakhi.database.room.dao.dynamicSchemaDao.FormResponse import org.piramalswasthya.sakhi.database.room.dao.dynamicSchemaDao.FormResponseJsonDaoHBYC import org.piramalswasthya.sakhi.database.room.dao.dynamicSchemaDao.FormSchemaDao import org.piramalswasthya.sakhi.database.room.dao.dynamicSchemaDao.InfantDao +import org.piramalswasthya.sakhi.database.room.dao.MonthlyRecapDao import org.piramalswasthya.sakhi.model.ABHAModel +import org.piramalswasthya.sakhi.model.MonthlyRecapCache import org.piramalswasthya.sakhi.helpers.DatabaseKeyManager import org.piramalswasthya.sakhi.helpers.RoomDbEncryptionHelper import org.piramalswasthya.sakhi.database.room.dao.dynamicSchemaDao.FilariaMdaCampaignJsonDao @@ -113,6 +117,14 @@ import org.piramalswasthya.sakhi.model.TBScreeningCache import org.piramalswasthya.sakhi.model.TBSuspectedCache import org.piramalswasthya.sakhi.model.UwinCache import org.piramalswasthya.sakhi.model.MaaMeetingEntity +import org.piramalswasthya.sakhi.model.BadgeConfigCache +import org.piramalswasthya.sakhi.model.BadgeEarnedCache +import org.piramalswasthya.sakhi.model.FormSaveLogCache +import org.piramalswasthya.sakhi.model.NotifHistoryCache +import org.piramalswasthya.sakhi.model.NotifTemplateCache +import org.piramalswasthya.sakhi.model.BadgeStateCache +import org.piramalswasthya.sakhi.model.BadgeStreakFreezeCache +import org.piramalswasthya.sakhi.model.BadgeSyncLogCache import org.piramalswasthya.sakhi.model.NotificationEntity import org.piramalswasthya.sakhi.model.TBConfirmedTreatmentCache import org.piramalswasthya.sakhi.model.Vaccine @@ -203,10 +215,22 @@ import org.piramalswasthya.sakhi.model.dynamicEntity.mosquitonetEntity.MosquitoN ANCFormResponseJsonEntity::class, FilariaMDACampaignFormResponseJsonEntity::class, TBConfirmedTreatmentCache::class, - NotificationEntity::class + NotificationEntity::class, + //Badges (LLD §4.1) + BadgeEarnedCache::class, + BadgeStateCache::class, + BadgeSyncLogCache::class, + BadgeStreakFreezeCache::class, + BadgeConfigCache::class, + //Evening Notification (Notification LLD §4) + NotifTemplateCache::class, + NotifHistoryCache::class, + FormSaveLogCache::class, + //Monthly Recap (Recap LLD §3) + MonthlyRecapCache::class ], views = [BenBasicCache::class], - version = 63, exportSchema = false + version = 66, exportSchema = false ) @TypeConverters( @@ -251,6 +275,7 @@ abstract class InAppDb : RoomDatabase() { abstract val saasBahuSammelanDao: SaasBahuSammelanDao abstract val generalOpdDao: GeneralOpdDao abstract val maaMeetingDao: MaaMeetingDao + abstract val monthlyRecapDao: MonthlyRecapDao abstract val uwinDao: UwinDao abstract val referalDao: NcdReferalDao @@ -273,6 +298,10 @@ abstract class InAppDb : RoomDatabase() { abstract val syncDao: SyncDao + abstract val badgeDao: BadgeDao + + abstract val eveningNotifDao: EveningNotifDao + companion object { @Volatile private var INSTANCE: InAppDb? = null @@ -342,6 +371,139 @@ abstract class InAppDb : RoomDatabase() { // } + // Evening Notification module (Notification LLD §4) — additive only. + val MIGRATION_64_65 = object : Migration(64, 65) { + override fun migrate(database: SupportSQLiteDatabase) { + try { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `notification_templates` ( + `templateId` TEXT NOT NULL, + `bucket` TEXT NOT NULL, + `language` TEXT NOT NULL, + `bodyTemplate` TEXT NOT NULL, + `libraryVersion` INTEGER NOT NULL, + PRIMARY KEY(`templateId`) + ) + """.trimIndent() + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_notification_templates_bucket_language` " + + "ON `notification_templates` (`bucket`, `language`)" + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `notification_history` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `templateId` TEXT NOT NULL, + `shownDate` TEXT NOT NULL, + `bucket` TEXT NOT NULL + ) + """.trimIndent() + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_notification_history_bucket` " + + "ON `notification_history` (`bucket`)" + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `form_save_log` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `formType` TEXT NOT NULL, + `beneficiaryId` INTEGER NOT NULL, + `ashaWorkerId` INTEGER NOT NULL, + `savedAt` INTEGER NOT NULL, + `dateKey` TEXT NOT NULL + ) + """.trimIndent() + ) + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_form_save_log_beneficiaryId_formType_dateKey` " + + "ON `form_save_log` (`beneficiaryId`, `formType`, `dateKey`)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_form_save_log_dateKey` " + + "ON `form_save_log` (`dateKey`)" + ) + } catch (_: Exception) { + } + } + } + + // Badges module (LLD §4.1) — additive only, no health-table changes. + val MIGRATION_63_64 = object : Migration(63, 64) { + override fun migrate(database: SupportSQLiteDatabase) { + try { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `BADGE_EARNED` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `userId` INTEGER NOT NULL, + `badgeId` TEXT NOT NULL, + `level` INTEGER NOT NULL, + `caseRef` TEXT NOT NULL, + `earnedAt` INTEGER NOT NULL, + `synced` INTEGER NOT NULL + ) + """.trimIndent() + ) + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_BADGE_EARNED_userId_badgeId_level_caseRef` " + + "ON `BADGE_EARNED` (`userId`, `badgeId`, `level`, `caseRef`)" + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `BADGE_STATE` ( + `userId` INTEGER NOT NULL, + `badgeId` TEXT NOT NULL, + `currentLevel` INTEGER NOT NULL, + `progress` INTEGER NOT NULL, + `nextTarget` INTEGER NOT NULL, + `streakCount` INTEGER NOT NULL, + `graceRemaining` INTEGER NOT NULL, + `lastEvaluatedAt` INTEGER NOT NULL, + PRIMARY KEY(`userId`, `badgeId`) + ) + """.trimIndent() + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `BADGE_SYNC_LOG` ( + `weekKey` TEXT NOT NULL, + `syncCompletedAt` INTEGER NOT NULL, + PRIMARY KEY(`weekKey`) + ) + """.trimIndent() + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `BADGE_STREAK_FREEZE` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `badgeId` TEXT NOT NULL, + `startDate` INTEGER NOT NULL, + `endDate` INTEGER NOT NULL + ) + """.trimIndent() + ) + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_BADGE_STREAK_FREEZE_badgeId_startDate_endDate` " + + "ON `BADGE_STREAK_FREEZE` (`badgeId`, `startDate`, `endDate`)" + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `BADGE_CONFIG` ( + `key` TEXT NOT NULL, + `value` TEXT NOT NULL, + `updatedAt` INTEGER NOT NULL, + PRIMARY KEY(`key`) + ) + """.trimIndent() + ) + } catch (_: Exception) { + } + } + } + val MIGRATION_62_63 = object : Migration(62, 63) { override fun migrate(database: SupportSQLiteDatabase) { try { @@ -410,6 +572,48 @@ abstract class InAppDb : RoomDatabase() { } } + // Monthly Recap foundation: one snapshot per (userId, recapYearMonth). + // Additive only — no existing table, data or encryption behaviour changes. + // + // Renumbered twice. First from 60->61 to 63->64, when release 2.11 merged + // into main and upstream had already taken 60->61 (isDeath normalisation), + // 61->62 and 62->63 (the NOTIFICATION table). Then from 63->64 to 65->66, + // when the badges and evening-notification modules landed on this branch + // ahead of it and took 63->64 and 64->65. + // + // Both times for the same reason: a version number is a promise about what a + // phone already sitting on it contains. Reusing one would leave a device that + // had migrated to the other module's 64 believing MONTHLY_RECAP exists, and + // it would crash on the first recap query rather than fail at upgrade time. + val MIGRATION_65_66 = object : Migration(65, 66) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `MONTHLY_RECAP` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`userId` INTEGER NOT NULL, " + + "`recapYearMonth` INTEGER NOT NULL, " + + "`windowStartMillis` INTEGER NOT NULL, " + + "`windowEndMillis` INTEGER NOT NULL, " + + "`status` TEXT NOT NULL, " + + "`language` TEXT, " + + "`variantSeed` INTEGER NOT NULL, " + + "`snapshotVersion` INTEGER NOT NULL, " + + "`metricsJson` TEXT, " + + "`progressScene` INTEGER NOT NULL, " + + "`totalScenes` INTEGER, " + + "`createdAt` INTEGER NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, " + + "`startedAt` INTEGER, " + + "`completedAt` INTEGER)" + ) + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS " + + "`index_MONTHLY_RECAP_userId_recapYearMonth` " + + "ON `MONTHLY_RECAP` (`userId`, `recapYearMonth`)" + ) + } + } + val MIGRATION_58_59 = object : Migration(58, 59) { override fun migrate(database: SupportSQLiteDatabase) { @@ -3465,7 +3669,10 @@ abstract class InAppDb : RoomDatabase() { MIGRATION_59_60, MIGRATION_60_61, MIGRATION_61_62, - MIGRATION_62_63 + MIGRATION_62_63, + MIGRATION_63_64, + MIGRATION_64_65, + MIGRATION_65_66 ).build() diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/BadgeDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/BadgeDao.kt new file mode 100644 index 000000000..e04cebe57 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/BadgeDao.kt @@ -0,0 +1,93 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.sakhi.model.BadgeConfigCache +import org.piramalswasthya.sakhi.model.BadgeEarnedCache +import org.piramalswasthya.sakhi.model.BadgeStateCache +import org.piramalswasthya.sakhi.model.BadgeStreakFreezeCache +import org.piramalswasthya.sakhi.model.BadgeSyncLogCache + +@Dao +interface BadgeDao { + + // ─── BADGE_STATE ─── + /** + * Scoped to one ASHA: the drawer logout keeps the database, so another user's rows can + * still be present and an unscoped read shows them her progress. + */ + @Query("SELECT * FROM BADGE_STATE WHERE userId = :userId") + fun getStatesFlow(userId: Int): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertStates(states: List) + + // ─── BADGE_EARNED (append-only; unique constraint gives idempotency) ─── + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertEarned(earned: List): List + + @Query("SELECT * FROM BADGE_EARNED WHERE userId = :userId") + fun getEarnedFlow(userId: Int): Flow> + + @Query("SELECT * FROM BADGE_EARNED WHERE userId = :userId") + suspend fun getEarned(userId: Int): List + + /** + * Filtered by user as well as by sync flag. The push names the active ASHA in its body, + * so an unscoped read would attribute a previous user's awards to whoever is logged in + * now — and then mark them synced, making it permanent. + */ + @Query("SELECT * FROM BADGE_EARNED WHERE synced = 0 AND userId = :userId") + suspend fun getUnsyncedEarned(userId: Int): List + + @Query("UPDATE BADGE_EARNED SET synced = 1 WHERE id IN (:ids)") + suspend fun markEarnedSynced(ids: List) + + // ─── BADGE_SYNC_LOG ─── + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSyncLog(log: BadgeSyncLogCache) + + @Query("SELECT weekKey FROM BADGE_SYNC_LOG") + suspend fun getAllSyncWeeks(): List + + @Query("DELETE FROM BADGE_SYNC_LOG WHERE weekKey = :weekKey") + suspend fun deleteSyncWeek(weekKey: String) + + // ─── BADGE_STREAK_FREEZE (replaced wholesale on every config pull) ─── + @Query("DELETE FROM BADGE_STREAK_FREEZE") + suspend fun clearFreezes() + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertFreezes(freezes: List) + + /** + * Delete and re-insert in one transaction. + * + * Done separately the window between them is a state the evaluator can read: a nightly + * evaluation landing there sees no freezes at all and breaks a streak the server had + * explicitly protected, which is the one thing a freeze exists to prevent. A crash + * between the two left the table empty permanently. + */ + @Transaction + suspend fun replaceFreezes(freezes: List) { + clearFreezes() + insertFreezes(freezes) + } + + @Query("SELECT * FROM BADGE_STREAK_FREEZE") + suspend fun getFreezes(): List + + // ─── BADGE_CONFIG ─── + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertConfig(rows: List) + + @Query("SELECT * FROM BADGE_CONFIG") + suspend fun getConfig(): List + + @Query("SELECT * FROM BADGE_CONFIG") + fun getConfigFlow(): Flow> +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/BenDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/BenDao.kt index 7e233497c..704a3e7bd 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/BenDao.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/BenDao.kt @@ -17,6 +17,27 @@ interface BenDao { @Update suspend fun updateBen(ben: BenRegCache) + /** + * Monthly Recap (READ-ONLY): counts beneficiaries (family members) the current + * ASHA registered in the window [startInclusive, endExclusive). Ownership is + * `ashaId = :userId` (local saves set the logged-in userId; downloaded rows + * preserve the original owner's ashaId). Drafts excluded; deactivated-later + * beneficiaries are intentionally still counted (the achievement is "registered + * this month"). `beneficiaryId` is the PRIMARY KEY and the temp id is migrated + * in place on sync, so each registration is exactly one row. Returns an + * aggregate only. + */ + @Query( + "SELECT COUNT(*) FROM BENEFICIARY " + + "WHERE ashaId = :userId AND isDraft = 0 " + + "AND regDate >= :startInclusive AND regDate < :endExclusive" + ) + suspend fun countCurrentAshaRegistrations( + userId: Int, + startInclusive: Long, + endExclusive: Long, + ): Int + @Query("UPDATE BENEFICIARY SET syncState = :unsynced ,processed = :proccess , serverUpdatedStatus =:updateStatus WHERE householdId = :householdId") suspend fun updateBenToSync( householdId: Long, diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/CbacDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/CbacDao.kt index 8e7f05277..3f8b0759e 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/CbacDao.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/CbacDao.kt @@ -49,4 +49,32 @@ interface CbacDao { @Query("UPDATE CBAC SET syncState = 0 WHERE syncState = 1") suspend fun resetSyncingToUnsynced() + + /** + * Monthly Recap (READ-ONLY): counts distinct beneficiaries with a completed + * CBAC screening in the window [startInclusive, endExclusive), owned by the + * current ASHA. + * + * Ownership predicate: + * ashaId = :userId (locally authored rows) + * OR (ashaId = 0 AND createdBy = :userName) (server-downloaded rows; + * the pull is scoped to this username, and a NULL/blank/different + * createdBy fails the equality, so foreign or unproven rows are excluded) + * + * Sync state is intentionally NOT filtered, so completed UNSYNCED/SYNCING/ + * SYNCED work all counts (offline-first). COUNT(DISTINCT benId) collapses a + * local row and its re-downloaded copy (same benId) into one screening event. + * Returns only an aggregate — no beneficiary id leaves the DAO. + */ + @Query( + "SELECT COUNT(DISTINCT benId) FROM CBAC " + + "WHERE fillDate >= :startInclusive AND fillDate < :endExclusive " + + "AND (ashaId = :userId OR (ashaId = 0 AND createdBy = :userName))" + ) + suspend fun countCurrentAshaScreenings( + userId: Int, + userName: String, + startInclusive: Long, + endExclusive: Long, + ): Int } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/EcrDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/EcrDao.kt index ea53d7716..1ddf57f80 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/EcrDao.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/EcrDao.kt @@ -19,6 +19,31 @@ interface EcrDao { @Query("select count(*) from ELIGIBLE_COUPLE_REG") suspend fun ecrCount(): Int + /** + * Monthly Recap (READ-ONLY): counts DISTINCT couples the current ASHA either + * registered OR tracked for family planning in the window [startInclusive, + * endExclusive). Ownership is `createdBy = :userName` (local saves set the + * logged-in userName; downloaded rows preserve the original owner's createdBy). + * The UNION de-duplicates `benId` across the two tables and within each, and + * `benId` is stable across sync (FK onUpdate=CASCADE), so a local row and its + * re-downloaded copy — and a couple that is BOTH registered and tracked — each + * count exactly once. Returns an aggregate only. + */ + @Query( + "SELECT COUNT(*) FROM (" + + "SELECT benId FROM ELIGIBLE_COUPLE_REG " + + "WHERE createdBy = :userName AND dateOfReg >= :startInclusive AND dateOfReg < :endExclusive " + + "UNION " + + "SELECT benId FROM ELIGIBLE_COUPLE_TRACKING " + + "WHERE createdBy = :userName AND visitDate >= :startInclusive AND visitDate < :endExclusive" + + ")" + ) + suspend fun countCurrentAshaEligibleCouples( + userName: String, + startInclusive: Long, + endExclusive: Long, + ): Int + @Query("SELECT * FROM ELIGIBLE_COUPLE_REG WHERE benId =:benId limit 1") suspend fun getSavedECR(benId: Long): EligibleCoupleRegCache? diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/EveningNotifDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/EveningNotifDao.kt new file mode 100644 index 000000000..25d1910b0 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/EveningNotifDao.kt @@ -0,0 +1,64 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.piramalswasthya.sakhi.model.FormSaveLogCache +import org.piramalswasthya.sakhi.model.NotifHistoryCache +import org.piramalswasthya.sakhi.model.NotifTemplateCache + +@Dao +interface EveningNotifDao { + + // ─── form_save_log ─── + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun logSaves(saves: List) + + @Query("SELECT COUNT(*) FROM form_save_log WHERE dateKey = :dateKey") + suspend fun countForDay(dateKey: String): Int + + @Query("SELECT COUNT(*) FROM form_save_log") + suspend fun lifetimeCount(): Long + + // ─── notification_templates ─── + @Query("DELETE FROM notification_templates") + suspend fun clearTemplates() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTemplates(templates: List) + + /** + * Swap the library in one transaction. + * + * Apart, the gap between the delete and the insert is a state the 9 PM alarm can read: + * it finds no template for the bucket and the evening notification is silently skipped + * for that day. A crash in the gap leaves the table empty until the next version bump, + * which - because sync only applies a strictly higher version - may be never. + */ + @Transaction + suspend fun replaceTemplates(templates: List) { + clearTemplates() + insertTemplates(templates) + } + + @Query("SELECT * FROM notification_templates WHERE bucket = :bucket AND language = :language") + suspend fun templatesFor(bucket: String, language: String): List + + @Query("SELECT MAX(libraryVersion) FROM notification_templates") + suspend fun libraryVersion(): Int? + + // ─── notification_history ─── + @Insert + suspend fun logShown(entry: NotifHistoryCache) + + @Query("SELECT COUNT(*) FROM notification_history WHERE shownDate = :dateKey") + suspend fun shownCountForDay(dateKey: String): Int + + @Query("SELECT templateId FROM notification_history WHERE bucket = :bucket ORDER BY id DESC LIMIT :recent") + suspend fun recentTemplateIds(bucket: String, recent: Int): List + + @Query("SELECT * FROM notification_history ORDER BY id DESC LIMIT 1") + suspend fun lastShown(): NotifHistoryCache? +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/HouseholdDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/HouseholdDao.kt index 3c66bcc23..fb5956711 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/HouseholdDao.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/HouseholdDao.kt @@ -19,6 +19,26 @@ interface HouseholdDao { @Update suspend fun update(household: HouseholdCache) + /** + * Monthly Recap (READ-ONLY): counts households the current ASHA registered in + * the window [startInclusive, endExclusive). Ownership is `ashaId = :userId` + * (local saves set the logged-in userId; downloaded rows preserve the original + * owner's ashaId). Drafts excluded. `householdId` is the PRIMARY KEY, so each + * registration is one row (COUNT counts it once); rows with a null/0 + * createdTimeStamp fall outside the range and are excluded. Returns an + * aggregate only. + */ + @Query( + "SELECT COUNT(*) FROM HOUSEHOLD " + + "WHERE ashaId = :userId AND isDraft = 0 " + + "AND createdTimeStamp >= :startInclusive AND createdTimeStamp < :endExclusive" + ) + suspend fun countCurrentAshaRegistrations( + userId: Int, + startInclusive: Long, + endExclusive: Long, + ): Int + @Query("UPDATE HOUSEHOLD SET processed = :proccess , serverUpdatedStatus =:updateStatus WHERE householdId = :householdId") suspend fun updateHouseholdToSync( householdId: Long, diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/ImmunizationDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/ImmunizationDao.kt index 95817c217..10a5e3562 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/ImmunizationDao.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/ImmunizationDao.kt @@ -73,4 +73,26 @@ interface ImmunizationDao { @Query("UPDATE IMMUNIZATION SET syncState = 0 WHERE syncState = 1") suspend fun resetSyncingToUnsynced() + + /** + * Monthly Recap (read-only): count of vaccine DOSES the current ASHA administered + * in the window `[startInclusive, endExclusive)`. Each `IMMUNIZATION` row is one + * `(beneficiaryId, vaccineId)` dose — child and mother doses alike (no category + * filter, by design). + * + * Ownership is the ASHA's own `createdBy` (this table has no `ashaId`); combined + * with the `ashaId`-scoped server pull this counts only doses this ASHA personally + * recorded. The composite primary key `(beneficiaryId, vaccineId)` means a + * re-downloaded dose replaces its row rather than adding one, so `COUNT(*)` can + * never be inflated by re-sync. Windowed on the user-entered vaccination `date` + * (rows with a NULL `date` fall outside any window and are excluded). + */ + @Query( + "SELECT COUNT(*) FROM IMMUNIZATION WHERE createdBy = :userName AND date >= :startInclusive AND date < :endExclusive" + ) + suspend fun countCurrentAshaDosesAdministered( + userName: String, + startInclusive: Long, + endExclusive: Long, + ): Int } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/MaternalHealthDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/MaternalHealthDao.kt index 0526e68b2..05537b40b 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/MaternalHealthDao.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/MaternalHealthDao.kt @@ -88,4 +88,34 @@ interface MaternalHealthDao { @Query("UPDATE pregnancy_anc SET syncState = 0 WHERE syncState = 1") suspend fun resetAncSyncingToUnsynced() + + /** + * Monthly Recap (read-only): count of DISTINCT pregnant women / mothers the current + * ASHA supported in the window `[startInclusive, endExclusive)` via ANY maternal + * activity — pregnancy registration, ANC, PMSMA, delivery outcome or PNC. + * + * Ownership is the ASHA's own `createdBy` (these tables have no `ashaId`); combined + * with the `ashaId`-scoped server pull this counts only records this ASHA personally + * created. The `active`/`isActive` flag is intentionally NOT filtered, so an activity + * genuinely performed in the window still counts even if its record was later + * closed/superseded (e.g. the pregnancy progressed). `UNION` + `COUNT(DISTINCT benId)` + * means a woman seen across several forms — or across multiple visits — counts once, + * and PNC's download duplicate-row quirk collapses harmlessly. + */ + @Query( + """ + SELECT COUNT(DISTINCT benId) FROM ( + SELECT benId FROM PREGNANCY_REGISTER WHERE createdBy = :userName AND dateOfRegistration >= :startInclusive AND dateOfRegistration < :endExclusive + UNION SELECT benId FROM PREGNANCY_ANC WHERE createdBy = :userName AND ancDate >= :startInclusive AND ancDate < :endExclusive + UNION SELECT benId FROM PMSMA WHERE createdBy = :userName AND visitDate >= :startInclusive AND visitDate < :endExclusive + UNION SELECT benId FROM DELIVERY_OUTCOME WHERE createdBy = :userName AND dateOfDelivery >= :startInclusive AND dateOfDelivery < :endExclusive + UNION SELECT benId FROM PNC_VISIT WHERE createdBy = :userName AND pncDate >= :startInclusive AND pncDate < :endExclusive + ) + """ + ) + suspend fun countCurrentAshaMothersSupported( + userName: String, + startInclusive: Long, + endExclusive: Long, + ): Int } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/MonthlyRecapDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/MonthlyRecapDao.kt new file mode 100644 index 000000000..fe35a5a96 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/MonthlyRecapDao.kt @@ -0,0 +1,80 @@ +package org.piramalswasthya.sakhi.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.sakhi.model.MonthlyRecapCache + +/** + * Minimal DAO for Monthly Recap snapshots. All operations target one + * (userId, recapYearMonth) identity; nothing loads all recaps. + * Suspend/Flow only — never called on the main thread. + */ +@Dao +interface MonthlyRecapDao { + + /** + * IGNORE on conflict: the unique (userId, recapYearMonth) index makes the + * database the final uniqueness boundary. Returns -1 when a row already won. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(recap: MonthlyRecapCache): Long + + @Query("SELECT * FROM MONTHLY_RECAP WHERE userId = :userId AND recapYearMonth = :yearMonth LIMIT 1") + suspend fun get(userId: Int, yearMonth: Int): MonthlyRecapCache? + + @Query("SELECT * FROM MONTHLY_RECAP WHERE userId = :userId AND recapYearMonth = :yearMonth LIMIT 1") + fun observe(userId: Int, yearMonth: Int): Flow + + @Query( + "UPDATE MONTHLY_RECAP SET language = :languageToken, updatedAt = :now " + + "WHERE userId = :userId AND recapYearMonth = :yearMonth" + ) + suspend fun setLanguage(userId: Int, yearMonth: Int, languageToken: String, now: Long) + + @Query( + "UPDATE MONTHLY_RECAP SET status = 'IN_PROGRESS', startedAt = COALESCE(startedAt, :now), " + + "updatedAt = :now WHERE userId = :userId AND recapYearMonth = :yearMonth " + + "AND status != 'COMPLETED'" + ) + suspend fun markStarted(userId: Int, yearMonth: Int, now: Long) + + /** + * Persists the CURRENT scene index (the scene being shown — the resume + * target), not a "completed" marker: reopening starts AT this scene. + */ + @Query( + "UPDATE MONTHLY_RECAP SET progressScene = :scene, updatedAt = :now " + + "WHERE userId = :userId AND recapYearMonth = :yearMonth AND status = 'IN_PROGRESS'" + ) + suspend fun updateProgress(userId: Int, yearMonth: Int, scene: Int, now: Long) + + /** Records how many scenes the frozen story has (bounds progress clamping/resume). */ + @Query( + "UPDATE MONTHLY_RECAP SET totalScenes = :totalScenes, updatedAt = :now " + + "WHERE userId = :userId AND recapYearMonth = :yearMonth" + ) + suspend fun setTotalScenes(userId: Int, yearMonth: Int, totalScenes: Int, now: Long) + + @Query( + "UPDATE MONTHLY_RECAP SET status = 'COMPLETED', completedAt = COALESCE(completedAt, :now), " + + "updatedAt = :now WHERE userId = :userId AND recapYearMonth = :yearMonth" + ) + suspend fun markCompleted(userId: Int, yearMonth: Int, now: Long) + + /** + * Freezes the aggregate metrics payload — but ONLY when none is stored yet + * (metricsJson IS NULL), making the database the final freeze boundary: the + * first writer wins and later writers cannot clobber it. Returns the number of + * rows updated (1 = this caller froze it, 0 = someone already did). + * Touches only metricsJson + updatedAt, so language, status, progress, + * variantSeed, createdAt and snapshot identity are all preserved. + */ + @Query( + "UPDATE MONTHLY_RECAP SET metricsJson = :metricsJson, updatedAt = :now " + + "WHERE userId = :userId AND recapYearMonth = :yearMonth AND metricsJson IS NULL" + ) + suspend fun setMetricsIfAbsent(userId: Int, yearMonth: Int, metricsJson: String, now: Long): Int +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/shared_preferences/PreferenceDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/shared_preferences/PreferenceDao.kt index 425e83f5a..01dcd5b45 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/database/shared_preferences/PreferenceDao.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/shared_preferences/PreferenceDao.kt @@ -223,6 +223,53 @@ class PreferenceDao @Inject constructor(@ApplicationContext private val context: pref.edit().putBoolean("FIRST TIME FULL PULL DONE", value).apply() } + /** + * Badges: whether the first evaluation on this install has finished. + * + * That first run awards everything the ASHA's existing records already earned — a + * backfill of history, not a series of achievements — so it is the one run that must + * stay silent. Recorded explicitly rather than inferred from an empty BADGE_EARNED + * table, because those two are only the same thing until somebody's genuine first badge + * arrives on an empty table and gets swallowed as history. + */ + var isBadgeBackfillDone: Boolean + get() = pref.getBoolean("BADGES FIRST EVALUATION DONE", false) + set(value) { + pref.edit().putBoolean("BADGES FIRST EVALUATION DONE", value).apply() + } + + /** + * Monthly Recap: earliest moment THIS INSTALL can be considered to have been + * accumulating the ASHA's records locally. The recap counts rows in the local + * database, so a snapshot may only be frozen for a month this install was + * present for; see [org.piramalswasthya.sakhi.helpers.recapFreezeBlocker]. + * + * Stamped once, then stable. Seeded from the package's first-install time so + * devices that have been in use for months are NOT blocked when this build + * lands, while a re-install (which resets first-install time) correctly reads + * as a new device. A bogus/future value is clamped to [nowMillis]. + * + * Cleared with everything else by [deleteForLogout], which the sign-in flow + * pairs with clearAllTables() — so the marker and the database it describes + * are wiped together. + */ + private val recapLocalDataSinceKey = "MONTHLY RECAP LOCAL DATA SINCE" + + fun recapLocalDataSince(nowMillis: Long): Long { + val stored = pref.getLong(recapLocalDataSinceKey, 0L) + if (stored > 0L) return stored + val seed = firstInstallTimeMillis()?.takeIf { it in 1..nowMillis } ?: nowMillis + pref.edit().putLong(recapLocalDataSinceKey, seed).apply() + return seed + } + + private fun firstInstallTimeMillis(): Long? = try { + context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + } catch (e: Exception) { + Timber.w(e, "Monthly Recap: first-install time unavailable; treating install as new") + null + } + var isDevModeEnabled: Boolean get() = pref.getBoolean("DEV-MODE", false) set(value) { diff --git a/app/src/main/java/org/piramalswasthya/sakhi/di/AppModule.kt b/app/src/main/java/org/piramalswasthya/sakhi/di/AppModule.kt index df3a28450..548cb351a 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/di/AppModule.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/di/AppModule.kt @@ -16,17 +16,21 @@ import org.piramalswasthya.sakhi.database.room.NcdReferalDao import org.piramalswasthya.sakhi.database.room.dao.ABHAGenratedDao import org.piramalswasthya.sakhi.database.room.dao.AdolescentHealthDao import org.piramalswasthya.sakhi.database.room.dao.AesDao +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao import org.piramalswasthya.sakhi.database.room.dao.BenDao import org.piramalswasthya.sakhi.database.room.dao.BeneficiaryIdsAvailDao import org.piramalswasthya.sakhi.database.room.dao.CbacDao import org.piramalswasthya.sakhi.database.room.dao.CdrDao import org.piramalswasthya.sakhi.database.room.dao.ChildRegistrationDao import org.piramalswasthya.sakhi.database.room.dao.DeliveryOutcomeDao +import org.piramalswasthya.sakhi.database.room.dao.EveningNotifDao import org.piramalswasthya.sakhi.database.room.dao.FilariaDao import org.piramalswasthya.sakhi.database.room.dao.GeneralOpdDao import org.piramalswasthya.sakhi.database.room.dao.HbncDao import org.piramalswasthya.sakhi.database.room.dao.HbycDao import org.piramalswasthya.sakhi.database.room.dao.HouseholdDao +import org.piramalswasthya.sakhi.database.room.dao.EcrDao +import org.piramalswasthya.sakhi.database.room.dao.MonthlyRecapDao import org.piramalswasthya.sakhi.database.room.dao.ImmunizationDao import org.piramalswasthya.sakhi.database.room.dao.IncentiveDao import org.piramalswasthya.sakhi.database.room.dao.InfantRegDao @@ -57,9 +61,15 @@ import org.piramalswasthya.sakhi.database.room.dao.dynamicSchemaDao.FormResponse import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao import org.piramalswasthya.sakhi.helpers.AnalyticsHelper import org.piramalswasthya.sakhi.helpers.ApiAnalyticsInterceptor +import org.piramalswasthya.sakhi.helpers.LocalMonthlyRecapAvailability +import org.piramalswasthya.sakhi.helpers.GamificationConfigProvider +import org.piramalswasthya.sakhi.helpers.MonthlyRecapAvailabilityProvider +import org.piramalswasthya.sakhi.helpers.RecapClock +import org.piramalswasthya.sakhi.helpers.SystemRecapClock import org.piramalswasthya.sakhi.helpers.TokenExpiryManager import org.piramalswasthya.sakhi.network.AbhaApiService import org.piramalswasthya.sakhi.network.AmritApiService +import org.piramalswasthya.sakhi.network.BadgeApiService import org.piramalswasthya.sakhi.network.interceptors.AccountDeactivationInterceptor import org.piramalswasthya.sakhi.network.interceptors.ContentTypeInterceptor import org.piramalswasthya.sakhi.network.interceptors.LoggingInterceptor @@ -156,11 +166,13 @@ object AppModule { fun provideHttpLoggingInterceptor(@ApplicationContext context: Context): HttpLoggingInterceptor { val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 val loggingInterceptor = HttpLoggingInterceptor(LoggingInterceptor()).apply { + // BODY logging stringifies every sync payload — a serious CPU/memory + // cost on low-end devices. Debug builds only. level = - // if (isDebuggable) + if (isDebuggable) HttpLoggingInterceptor.Level.BODY -// else -// HttpLoggingInterceptor.Level.NONE + else + HttpLoggingInterceptor.Level.NONE } return loggingInterceptor } @@ -265,6 +277,23 @@ object AppModule { @Provides fun provideHouseholdDao(database: InAppDb): HouseholdDao = database.householdDao + @Singleton + @Provides + fun provideMonthlyRecapDao(database: InAppDb): MonthlyRecapDao = database.monthlyRecapDao + + @Singleton + @Provides + fun provideRecapClock(): RecapClock = SystemRecapClock() + + @Singleton + @Provides + fun provideMonthlyRecapAvailability( + clock: RecapClock, + configProvider: GamificationConfigProvider, + pref: PreferenceDao, + ): MonthlyRecapAvailabilityProvider = + LocalMonthlyRecapAvailability(clock, configProvider, pref) + @Singleton @Provides fun provideBenDao(database: InAppDb): BenDao = database.benDao @@ -282,6 +311,9 @@ object AppModule { @Provides fun provideCbacDao(database: InAppDb): CbacDao = database.cbacDao + @Provides + fun provideEcrDao(database: InAppDb): EcrDao = database.ecrDao + @Singleton @Provides fun provideVaccineDao(database: InAppDb): ImmunizationDao = database.vaccineDao @@ -435,4 +467,28 @@ object AppModule { @Provides fun provideNcdReferDao(database: InAppDb): NcdReferalDao = database.referalDao + @Singleton + @Provides + fun provideBadgeDao(database: InAppDb): BadgeDao = database.badgeDao + + @Singleton + @Provides + fun provideEveningNotifDao(database: InAppDb): EveningNotifDao = database.eveningNotifDao + + // Badges module (LLD §4.2): rides the existing authenticated client with + // its token insert + 401 refresh behaviour. + @Singleton + @Provides + fun provideBadgeApiService( + moshi: Moshi, + @Named(UAT_CLIENT) httpClient: OkHttpClient + ): BadgeApiService { + return Retrofit.Builder() + .baseUrl(KeyUtils.baseTMCUrl()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .client(httpClient) + .build() + .create(BadgeApiService::class.java) + } + } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/GamificationConfigProvider.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/GamificationConfigProvider.kt new file mode 100644 index 000000000..b2a4c3173 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/GamificationConfigProvider.kt @@ -0,0 +1,115 @@ +package org.piramalswasthya.sakhi.helpers + +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import org.piramalswasthya.sakhi.BuildConfig +import org.piramalswasthya.sakhi.R +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * ONE Remote Config provider for every gamification mechanic (Monthly Recap, + * Badges, and — when built — Notifications and the Consistency Dashboard). + * + * Design, locked 2026-08-10: a single master switch plus a shared pilot + * allowlist, with one boolean key PER mechanic. Not one switch for the whole + * module (can't pilot or kill one mechanic independently of the others), and + * not a separate Remote Config wrapper per mechanic (same plumbing built four + * times). A mechanic is ON only when ALL of: + * 1. [KEY_MASTER_ENABLED] is true — the one big kill switch + * 2. the mechanic's own key is true — e.g. [KEY_BADGES_ENABLED] + * 3. the pilot allowlist is empty, OR the logged-in userId is in it + * + * FAIL CLOSED by construction: every default in remote_config_defaults.xml is + * false/empty, so a phone that has never fetched, or a fetch that fails, shows + * NOTHING rather than guessing something is enabled. + * + * Debug builds bypass Remote Config entirely and force every mechanic ON — + * that decision already existed per-mechanic (BadgeGates, MonthlyRecapGates) + * and is preserved here so local dev/demo work is unaffected by this change. + */ +@Singleton +class GamificationConfigProvider @Inject constructor() { + + enum class Mechanic(val remoteKey: String) { + BADGES("badges_enabled"), + MONTHLY_RECAP("monthly_recap_enabled"), + // Reserve the keys now so Notifications and the Consistency Dashboard + // need no new provider plumbing when their turn comes — only a new + // enum entry, a default in the XML, and one line at their gate. + NOTIFICATIONS("notifications_enabled"), + CONSISTENCY_DASHBOARD("consistency_dashboard_enabled"), + } + + companion object { + const val KEY_MASTER_ENABLED = "gamification_master_enabled" + const val KEY_PILOT_USER_IDS = "gamification_pilot_user_ids" + } + + private val remoteConfig: FirebaseRemoteConfig by lazy { + FirebaseRemoteConfig.getInstance().apply { + setConfigSettingsAsync( + remoteConfigSettings { + // Rollout flags should reach a phone within the hour, not + // wait for the app's default 12h Remote Config cache. + minimumFetchIntervalInSeconds = TimeUnit.HOURS.toSeconds(1) + } + ) + setDefaultsAsync(R.xml.remote_config_defaults) + } + } + + /** + * Call once, early (SakhiApplication.onCreate), off the main thread. A failed + * or slow fetch is NOT an error here — [remoteConfig] already holds the + * fail-closed XML defaults until a fetch succeeds, so every caller of + * [isMechanicEnabled] is safe to use immediately after the app starts. + */ + fun primeAsync() { + if (BuildConfig.DEBUG) return // debug never reads Remote Config at all + remoteConfig.fetchAndActivate() + .addOnCompleteListener { task -> + if (!task.isSuccessful) { + Timber.w("GamificationConfigProvider: fetch failed, staying on cached/default values") + } + } + } + + /** + * The one gate every mechanic calls. [userId] is the currently logged-in + * ASHA's id (null when nobody is logged in — always closed in that case). + */ + fun isMechanicEnabled(mechanic: Mechanic, userId: Int?): Boolean { + if (BuildConfig.DEBUG) return true + + val masterOn = safeBool(KEY_MASTER_ENABLED) + if (!masterOn) return false + + val mechanicOn = safeBool(mechanic.remoteKey) + if (!mechanicOn) return false + + return isUserInPilot(userId) + } + + /** Empty allowlist = everyone (the intended state for full go-live). */ + private fun isUserInPilot(userId: Int?): Boolean { + val raw = try { + remoteConfig.getString(KEY_PILOT_USER_IDS) + } catch (e: Exception) { + Timber.e(e, "GamificationConfigProvider: bad $KEY_PILOT_USER_IDS") + return false // malformed allowlist must never silently mean "everyone" + } + val ids = raw.split(',').map { it.trim() }.filter { it.isNotEmpty() } + if (ids.isEmpty()) return true + return userId != null && userId.toString() in ids + } + + private fun safeBool(key: String): Boolean = try { + remoteConfig.getBoolean(key) + } catch (e: Exception) { + Timber.e(e, "GamificationConfigProvider: bad boolean for $key") + false // a corrupt value must fail closed, never fail open + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapAvailability.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapAvailability.kt new file mode 100644 index 000000000..8baea157d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapAvailability.kt @@ -0,0 +1,85 @@ +package org.piramalswasthya.sakhi.helpers + +import org.piramalswasthya.sakhi.BuildConfig +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import javax.inject.Inject + +/** + * Rollout gates for the Monthly Recap. + * + * Tri-state per gate: true / false / null (unknown). Availability is true only + * when every required gate is explicitly true — any false OR UNKNOWN gate fails + * closed to unavailable (dashboard renders HIDDEN). + */ +data class MonthlyRecapGates( + val emergencySwitchOn: Boolean? = null, + val moduleEnabled: Boolean? = null, + val userEligible: Boolean? = null, + val activeWindowOpen: Boolean? = null, + val configFresh: Boolean? = null, +) { + fun available(): Boolean = + emergencySwitchOn == true && + moduleEnabled == true && + userEligible == true && + activeWindowOpen == true && + configFresh == true +} + +/** Replaceable availability boundary consumed by the dashboard strip. */ +interface MonthlyRecapAvailabilityProvider { + suspend fun isAvailable(): Boolean +} + +/** + * Local availability provider, backed by the shared [GamificationConfigProvider] + * (one Remote Config provider for every gamification mechanic — see its own doc). + * + * Debug builds keep the rollout/eligibility gates locally enabled for + * development; the active-window gate always uses the real first-seven-day + * calendar rule ([isMonthlyRecapWindowOpen]) even in debug, so the strip is + * shown only on days 1..[ACTIVE_WINDOW_DAYS] of the month, never every day. + * + * Release reads the real gates: `gamification_master_enabled` maps to + * [MonthlyRecapGates.emergencySwitchOn], `monthly_recap_enabled` (plus the + * shared pilot allowlist) maps to [MonthlyRecapGates.moduleEnabled] AND + * [MonthlyRecapGates.userEligible] together (the provider's single + * `isMechanicEnabled` call already folds allowlist membership in), and + * [MonthlyRecapGates.configFresh] is true whenever a user is logged in — a + * fetch failure does not need its own flag because Remote Config already falls + * back to the fail-closed XML defaults on failure, which [configProvider] + * reads regardless of fetch outcome. + */ +class LocalMonthlyRecapAvailability @Inject constructor( + private val clock: RecapClock, + private val configProvider: GamificationConfigProvider, + private val pref: PreferenceDao, +) : MonthlyRecapAvailabilityProvider { + override suspend fun isAvailable(): Boolean = + if (BuildConfig.DEBUG) { + MonthlyRecapGates( + emergencySwitchOn = true, + moduleEnabled = true, + userEligible = true, + activeWindowOpen = isMonthlyRecapWindowOpen(clock.now()), + configFresh = true, + ).available() + } else { + val userId = pref.getLoggedInUser()?.userId + // The shared provider already folds master-switch + mechanic-key + + // pilot-allowlist into one boolean, so the three legacy gate fields + // below intentionally carry the SAME value — the tri-state struct + // is kept only because MonthlyRecapGates is the existing, tested + // contract MonthlyRecapStripViewModel and its tests build against. + val recapOn = configProvider.isMechanicEnabled( + GamificationConfigProvider.Mechanic.MONTHLY_RECAP, userId + ) + MonthlyRecapGates( + emergencySwitchOn = recapOn, + moduleEnabled = recapOn, + userEligible = recapOn, + activeWindowOpen = isMonthlyRecapWindowOpen(clock.now()), + configFresh = userId != null, + ).available() + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapDataReadiness.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapDataReadiness.kt new file mode 100644 index 000000000..6148993e2 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapDataReadiness.kt @@ -0,0 +1,56 @@ +package org.piramalswasthya.sakhi.helpers + +import org.piramalswasthya.sakhi.model.RecapFreezeBlocker + +/** + * Whether this install's local database can be trusted for a recap month. + * + * [isFullPullComplete] mirrors PreferenceDao's one-shot download flag. + * [localDataSince] is the earliest moment this install can be considered to have + * been accumulating the ASHA's records (see PreferenceDao.recapLocalDataSince). + */ +data class RecapDataReadiness( + val isFullPullComplete: Boolean, + val localDataSince: Long, +) + +/** + * THE single decision point for whether a freshly calculated payload may be FROZEN. + * + * Why this exists — the recap counts rows in the device's own Room database. For a + * phone in continuous use that is complete by construction, because the ASHA + * created every row on it. It is NOT complete when the install is newer than the + * month being summarised, or when the one-shot server download has not finished: + * the pull is insert-if-absent, silently skips records whose parent row is missing, + * and never re-runs, so a gap is permanent. + * + * Every failure mode here loses rows and none can add them, so an ungated freeze + * would under-credit an ASHA's work — the worst possible bug in a feature whose + * only job is to motivate her. Blocking is therefore fail-closed: hide this month + * and re-evaluate on the next open, rather than freeze a number we cannot stand behind. + * + * Deliberately NOT covered: a download that finished but silently dropped records on + * a device old enough to pass [RecapFreezeBlocker.DEVICE_MISSED_PART_OF_MONTH]. + * `isFullPullComplete` is set unconditionally by the terminal sync worker and the + * pull workers treat skipped rows as success, so no on-device flag can detect that. + * It is handled operationally instead — pilot allowlist, plus the diagnostics written + * alongside the payload. + * + * Pure and side-effect free so every branch is unit-testable on the JVM. + */ +fun recapFreezeBlocker( + readiness: RecapDataReadiness, + windowStartMillis: Long, + hasCountableWork: Boolean, +): RecapFreezeBlocker = when { + !readiness.isFullPullComplete -> RecapFreezeBlocker.FULL_PULL_INCOMPLETE + + // <= 0 means "unknown", which fails closed rather than comparing as very old. + readiness.localDataSince <= 0L || + readiness.localDataSince > windowStartMillis -> + RecapFreezeBlocker.DEVICE_MISSED_PART_OF_MONTH + + !hasCountableWork -> RecapFreezeBlocker.NO_COUNTABLE_WORK + + else -> RecapFreezeBlocker.NONE +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapMetricsCodec.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapMetricsCodec.kt new file mode 100644 index 000000000..298b3512a --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/MonthlyRecapMetricsCodec.kt @@ -0,0 +1,51 @@ +package org.piramalswasthya.sakhi.helpers + +import com.google.gson.Gson +import org.piramalswasthya.sakhi.model.MonthlyRecapMetricsContract +import org.piramalswasthya.sakhi.model.MonthlyRecapMetricsPayload +import timber.log.Timber + +/** + * Encodes/decodes the aggregate Monthly Recap metrics payload stored in + * MonthlyRecapCache.metricsJson (Gson — the project's existing JSON library). + * + * Decoding is defensive: + * - corrupt/foreign JSON -> null (never crashes), + * - an unsupported schema version -> null (never silently reinterpreted as the + * current schema), + * - logs stay free of any record-level/health data (the payload is aggregates + * only, and we do not log its contents). + * + * A null decode is treated by the repository as "no valid metric yet" and leads + * to a deterministic regeneration from local data. + */ +object MonthlyRecapMetricsCodec { + + private val gson = Gson() + + fun encode(payload: MonthlyRecapMetricsPayload): String = gson.toJson(payload) + + fun decodeOrNull(json: String?): MonthlyRecapMetricsPayload? { + if (json.isNullOrBlank()) return null + return try { + val payload = gson.fromJson(json, MonthlyRecapMetricsPayload::class.java) + when { + payload == null -> null + payload.payloadSchemaVersion != MonthlyRecapMetricsContract.PAYLOAD_SCHEMA_VERSION -> { + Timber.w( + "Monthly Recap: unsupported metrics schema version %d (expected %d); ignoring", + payload.payloadSchemaVersion, + MonthlyRecapMetricsContract.PAYLOAD_SCHEMA_VERSION, + ) + null + } + + else -> payload + } + } catch (e: Exception) { + // Deliberately do NOT log the JSON body. + Timber.w(e, "Monthly Recap: corrupt metrics payload; will regenerate") + null + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapClock.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapClock.kt new file mode 100644 index 000000000..6477a1d88 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapClock.kt @@ -0,0 +1,89 @@ +package org.piramalswasthya.sakhi.helpers + +import java.util.Calendar +import javax.inject.Inject + +/** + * Small testable time source for the Monthly Recap. Production uses the device + * clock/timezone; tests supply fixed calendars — no emulator clock changes needed. + * (Calendar, not java.time: the project has minSdk 25 and no core-library + * desugaring, matching existing date utilities.) + */ +interface RecapClock { + fun now(): Calendar +} + +class SystemRecapClock @Inject constructor() : RecapClock { + override fun now(): Calendar = Calendar.getInstance() +} + +/** + * The immediately previous completed calendar month, with its frozen + * start-inclusive / end-exclusive activity window in device-local time. + */ +data class RecapMonthWindow( + val year: Int, + /** 1..12 */ + val month: Int, + /** Canonical key, e.g. 202606 for June 2026. Numeric — never compared as text. */ + val yearMonth: Int, + /** Inclusive: first instant of the recap month. */ + val startMillis: Long, + /** Exclusive: first instant of the following month. */ + val endMillisExclusive: Long, +) + +/** + * Resolves the immediately previous completed calendar month for [now]. + * Handles January → previous-December rollover and leap years via Calendar + * arithmetic (no hardcoded dates, no string month comparison). + */ +fun previousMonthWindow(now: Calendar): RecapMonthWindow { + val startOfCurrentMonth = startOfCurrentMonth(now) + val startOfPreviousMonth = (startOfCurrentMonth.clone() as Calendar).apply { + add(Calendar.MONTH, -1) + } + val year = startOfPreviousMonth.get(Calendar.YEAR) + val month = startOfPreviousMonth.get(Calendar.MONTH) + 1 + return RecapMonthWindow( + year = year, + month = month, + yearMonth = year * 100 + month, + startMillis = startOfPreviousMonth.timeInMillis, + endMillisExclusive = startOfCurrentMonth.timeInMillis, + ) +} + +/** + * Number of calendar days the dashboard strip stays available at the start of + * each month (days 1..[ACTIVE_WINDOW_DAYS]). Single source of truth for the + * product rule; a future backend config can replace this without touching the + * strip. Kept as a value, not scattered literals. + */ +const val ACTIVE_WINDOW_DAYS = 7 + +/** + * True when [now] falls in the current month's active strip window: + * `[00:00 on day 1, 00:00 on day ACTIVE_WINDOW_DAYS+1)` in device-local time + * (start-inclusive, end-exclusive). e.g. with 7 days, open on days 1..7 and + * closed from 00:00 of the 8th. This is a fixed calendar window — it does NOT + * depend on when the ASHA first opened the app or tapped the strip. + */ +fun isMonthlyRecapWindowOpen(now: Calendar): Boolean { + val windowStart = startOfCurrentMonth(now) + val windowEndExclusive = (windowStart.clone() as Calendar).apply { + add(Calendar.DAY_OF_MONTH, ACTIVE_WINDOW_DAYS) + } + val nowMillis = now.timeInMillis + return nowMillis >= windowStart.timeInMillis && nowMillis < windowEndExclusive.timeInMillis +} + +/** First instant (00:00) of [now]'s calendar month, in device-local time. */ +private fun startOfCurrentMonth(now: Calendar): Calendar = + (now.clone() as Calendar).apply { + set(Calendar.DAY_OF_MONTH, 1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapContentCodec.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapContentCodec.kt new file mode 100644 index 000000000..915f88db8 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapContentCodec.kt @@ -0,0 +1,171 @@ +package org.piramalswasthya.sakhi.helpers + +import android.content.Context +import com.google.gson.Gson +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.model.RecapContentLibrary +import timber.log.Timber + +/** + * Phase 7 — decodes AND strictly validates the Monthly Recap content library. + * + * [decodeOrNull] is a pure String→model function (JVM-unit-testable against the + * real bundled file). [loadBundled] reads `res/raw/recap_content.json` — the + * bundled library is the offline/default source; a future server-hosted library + * would be decoded by this same codec and simply take precedence. + * + * Validation is deliberately STRICT and all-or-nothing: bad content must fail + * loudly here (→ null → recap stays closed) rather than silently dropping a + * category and quietly shrinking an ASHA's story. Every rejection is logged + * with its reason so a bad content push is diagnosable from logcat. + */ +object RecapContentCodec { + + /** Schema versions this build knows how to render. */ + private val SUPPORTED_SCHEMA_VERSIONS = setOf(1) + + /** Languages the app can present; both must be fully populated. */ + private val REQUIRED_LANGUAGES = setOf("hi", "as") + + /** Categories the metrics engine can emit; content is required for each. */ + private val REQUIRED_CATEGORIES = setOf( + "NCD", "HOUSEHOLD", "BENEFICIARY", "ELIGIBLE_COUPLE", "MATERNAL_HEALTH", "IMMUNIZATION", + ) + + private val gson = Gson() + + /** + * Decodes and fully validates the content library, or returns null. + * + * Validation runs INSIDE the try deliberately. Gson can hand back objects that + * violate their own Kotlin types (see the warning on [RecapContentLibrary]), so + * validation itself is a place a runtime failure can surface. Keeping it inside + * means this never throws at the caller: bad content always degrades to null, + * and the recap simply does not open. + */ + fun decodeOrNull(json: String?): RecapContentLibrary? { + if (json.isNullOrBlank()) return null + return try { + val library = gson.fromJson(json, RecapContentLibrary::class.java) + ?: return null + val rejection = validate(library) + if (rejection == null) { + library + } else { + Timber.e("Recap content rejected: $rejection") + null + } + } catch (e: Exception) { + Timber.w(e, "Recap content: unusable library") + null + } + } + + /** Returns null when the library is fully usable, else the reason it was rejected. */ + private fun validate(library: RecapContentLibrary): String? { + if (library.schemaVersion !in SUPPORTED_SCHEMA_VERSIONS) { + return "unsupported schemaVersion=${library.schemaVersion} (supported=$SUPPORTED_SCHEMA_VERSIONS)" + } + if (library.contentVersion <= 0) return "contentVersion must be positive" + + // --- band definitions: non-empty, unique ids, and gapless from 1 upward --- + val bands = library.bandDefinitions + if (bands.isEmpty()) return "no bandDefinitions" + val bandIds = bands.map { it.id } + if (bandIds.size != bandIds.toSet().size) return "duplicate band ids: $bandIds" + if (bands.none { it.maxCount == null }) return "no open-ended top band (every high count would be unmatched)" + bands.forEach { band -> + if (band.minCount < 1) return "band ${band.id} has minCount<1 (0 is the omit case)" + if (band.maxCount != null && band.maxCount < band.minCount) { + return "band ${band.id} has maxCount() + library.languages.forEach { language -> + if (language.intro.isEmpty()) return "language ${language.id}: no intro line" + if (language.closing.isEmpty()) return "language ${language.id}: no closing line" + if (language.intro.any { it.isBlank() } || language.closing.any { it.isBlank() }) { + return "language ${language.id}: blank intro/closing line" + } + + val categoryIds = language.categories.map { it.id } + if (categoryIds.size != categoryIds.toSet().size) { + return "language ${language.id}: duplicate category ids" + } + val missingCategories = REQUIRED_CATEGORIES - categoryIds.toSet() + if (missingCategories.isNotEmpty()) { + return "language ${language.id}: missing categories $missingCategories" + } + + language.categories.forEach { category -> + val categoryBandIds = category.bands.map { it.id } + if (categoryBandIds.size != categoryBandIds.toSet().size) { + return "${language.id}/${category.id}: duplicate band ids" + } + val missingBands = bandIds.toSet() - categoryBandIds.toSet() + if (missingBands.isNotEmpty()) { + return "${language.id}/${category.id}: missing bands $missingBands" + } + category.bands.forEach { band -> + if (band.sentences.isEmpty()) { + return "${language.id}/${category.id}/${band.id}: no sentences" + } + band.sentences.forEach { sentence -> + if (sentence.id.isBlank()) { + return "${language.id}/${category.id}/${band.id}: blank sentence id" + } + if (!seenSentenceIds.add(sentence.id)) { + return "duplicate sentence id: ${sentence.id}" + } + if (sentence.text.isBlank()) return "sentence ${sentence.id}: blank text" + // Extract every {token}; require exactly one and only {count}. + val tokens = BRACE_TOKEN_REGEX.findAll(sentence.text).map { it.value }.toList() + val countTokens = tokens.count { it == RecapSceneComposer.COUNT_PLACEHOLDER } + if (countTokens != 1) { + return "sentence ${sentence.id}: expected exactly one ${RecapSceneComposer.COUNT_PLACEHOLDER}, found $countTokens" + } + tokens.firstOrNull { it != RecapSceneComposer.COUNT_PLACEHOLDER }?.let { + return "sentence ${sentence.id}: unsupported token $it" + } + } + } + } + } + return null + } + + fun loadBundled(context: Context): RecapContentLibrary? = try { + context.resources.openRawResource(R.raw.recap_content) + .bufferedReader(Charsets.UTF_8) + .use { it.readText() } + .let(::decodeOrNull) + } catch (e: Exception) { + Timber.w(e, "Failed to read bundled recap content") + null + } + + /** Counts high enough to cover any realistic monthly total. */ + private const val BAND_COVERAGE_PROBE = 5_000 + + /** + * Matches any single `{token}`. Both braces are escaped so the pattern is + * valid on BOTH the JVM regex engine (unit tests) AND Android's stricter + * ICU engine (device) — an unescaped `}` compiles on the JVM but throws + * PatternSyntaxException on device. The validator inspects each match and + * requires it to equal {count}; this avoids a lookahead entirely. + */ + private val BRACE_TOKEN_REGEX = Regex("\\{[^}]*\\}") +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapNotificationState.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapNotificationState.kt new file mode 100644 index 000000000..897b15f73 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapNotificationState.kt @@ -0,0 +1,29 @@ +package org.piramalswasthya.sakhi.helpers + +import android.content.Context + +/** + * Tiny standalone "have we already invited her to this month's recap" flag. + * Deliberately its own SharedPreferences file rather than a Room table or a + * PreferenceDao addition — this is diagnostics-adjacent state for ONE worker, + * not something any other part of the app needs to read. + * + * Scoped by (userId, yearMonth) so a shared/re-logged-in device never spams a + * second ASHA with a different ASHA's recap reminder, and so re-notifying only + * ever happens once a NEW month's recap exists. + */ +object RecapNotificationState { + private const val PREFS_NAME = "recap_notification_state" + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private fun key(userId: Int, yearMonth: Int) = "notified_${userId}_$yearMonth" + + fun hasNotified(context: Context, userId: Int, yearMonth: Int): Boolean = + prefs(context).getBoolean(key(userId, yearMonth), false) + + fun markNotified(context: Context, userId: Int, yearMonth: Int) { + prefs(context).edit().putBoolean(key(userId, yearMonth), true).apply() + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapSceneComposer.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapSceneComposer.kt new file mode 100644 index 000000000..d0a0cd6ba --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapSceneComposer.kt @@ -0,0 +1,162 @@ +package org.piramalswasthya.sakhi.helpers + +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.model.MonthlyRecapMetricsPayload +import org.piramalswasthya.sakhi.model.RecapBandDefinition +import org.piramalswasthya.sakhi.model.RecapContentLibrary +import org.piramalswasthya.sakhi.model.RecapMetricStatus + +/** One playback scene of the personalised Monthly Recap story. */ +data class RecapScene( + val type: Type, + /** Resolved, display-ready text ({count} already substituted). */ + val text: String, + /** + * Lottie raw resource for this scene. [RecapSceneComposer.compose] always + * assigns one — [RecapSceneComposer.WELCOME_ANIMATION] for WELCOME, a rotated + * entry from [RecapSceneComposer.STORY_ANIMATIONS] for CATEGORY, and + * [RecapSceneComposer.GOODBYE_ANIMATION] for GOODBYE. + * + * Nullable only so a scene can legitimately carry no animation; it does not + * mark any particular scene type. Playback simply skips the Lottie when null. + */ + val lottieRawRes: Int?, + val categoryId: String? = null, + val count: Int? = null, + /** Stable content id (e.g. "hi_ncd_medium_2"); null for intro/closing lines. */ + val sentenceId: String? = null, +) { + enum class Type { WELCOME, CATEGORY, GOODBYE } +} + +/** + * Phase 7 — composes the personalised scene list for one frozen recap snapshot. + * + * PURE + DETERMINISTIC: output depends only on (frozen metrics payload, + * language token, frozen variantSeed, content library). The same snapshot + * always replays the exact same story — no randomness, no clock — which is why + * nothing new needs persisting beyond the existing snapshot row. + * + * Composition rules (all user-approved): + * - Scene order = the content file's category order; only categories that are + * AVAILABLE with count > 0 appear (deferred/zero are silently omitted). + * - Band = the file's own bandDefinitions (SINGULAR/DOUBLE/MEDIUM/HIGH). + * - Sentence variant = variantSeed-rotated pick from the band's variants. + * - Animation: the six ASHA-didi actions in [STORY_ANIMATIONS] rotate per category + * scene from a seed offset, so within one recap every category scene shows a + * DIFFERENT action and consecutive scenes never repeat. [WELCOME_ANIMATION] and + * [GOODBYE_ANIMATION] reuse two of those six (the greeting wave and the thumbs + * up) rather than being reserved extras — see their KDoc for why. + * - EMPTY MONTH: if no category qualifies, returns an EMPTY list — the recap + * does not open at all (no fabricated celebration). + */ +class RecapSceneComposer(private val library: RecapContentLibrary) { + + fun compose( + payload: MonthlyRecapMetricsPayload, + languageToken: String?, + variantSeed: Long, + ): List { + val language = library.languageOrNull(languageToken) + ?: library.languageOrNull(FALLBACK_LANGUAGE) + ?: return emptyList() + + val categoryScenes = language.categories.mapIndexedNotNull { index, categoryContent -> + val metric = payload.categories.firstOrNull { it.categoryId == categoryContent.id } + ?: return@mapIndexedNotNull null + if (metric.status != RecapMetricStatus.AVAILABLE.name) return@mapIndexedNotNull null + val count = metric.categoryTotal + if (count <= 0) return@mapIndexedNotNull null + + val bandId = bandIdFor(count, library.bandDefinitions) ?: return@mapIndexedNotNull null + val sentences = categoryContent.bandOrNull(bandId)?.sentences + ?.takeIf { it.isNotEmpty() } ?: return@mapIndexedNotNull null + val sentence = sentences[variantIndex(variantSeed, index, sentences.size)] + + RecapScene( + type = RecapScene.Type.CATEGORY, + text = sentence.text.replace(COUNT_PLACEHOLDER, count.toString()), + lottieRawRes = null, // assigned below once the qualifying set is known + categoryId = categoryContent.id, + count = count, + sentenceId = sentence.id, + ) + } + + // User decision: an all-zero month means the recap does not open at all. + if (categoryScenes.isEmpty()) return emptyList() + + // Rotate the six didi actions from a seed offset — consecutive scenes differ. + val animationStart = ((variantSeed % STORY_ANIMATIONS.size) + STORY_ANIMATIONS.size) + .toInt() % STORY_ANIMATIONS.size + val animatedScenes = categoryScenes.mapIndexed { i, scene -> + scene.copy(lottieRawRes = STORY_ANIMATIONS[(animationStart + i) % STORY_ANIMATIONS.size]) + } + + val welcome = language.intro.takeIf { it.isNotEmpty() }?.let { + RecapScene( + type = RecapScene.Type.WELCOME, + text = it[variantIndex(variantSeed, WELCOME_SALT, it.size)], + lottieRawRes = WELCOME_ANIMATION, + ) + } + val goodbye = language.closing.takeIf { it.isNotEmpty() }?.let { + RecapScene( + type = RecapScene.Type.GOODBYE, + text = it[variantIndex(variantSeed, GOODBYE_SALT, it.size)], + lottieRawRes = GOODBYE_ANIMATION, + ) + } + + return listOfNotNull(welcome) + animatedScenes + listOfNotNull(goodbye) + } + + private fun bandIdFor(count: Int, definitions: List): String? = + definitions.firstOrNull { it.contains(count) }?.id + + /** Seed-stable, always-in-range variant pick; salt de-correlates positions. */ + private fun variantIndex(seed: Long, salt: Int, size: Int): Int { + if (size <= 1) return 0 + val mixed = seed + salt * 31L + return (((mixed % size) + size) % size).toInt() + } + + companion object { + const val COUNT_PLACEHOLDER = "{count}" + private const val FALLBACK_LANGUAGE = "hi" + private const val WELCOME_SALT = 101 + private const val GOODBYE_SALT = 202 + + /** + * The six ASHA-didi actions, one per category scene. + * + * The rotation above starts at a seed-derived offset and then steps through + * this list, so within one recap **every category scene gets a different + * action**, consecutive scenes never repeat, and two ASHAs (or two months) + * start on different actions. Six entries covers the six countable + * categories, which is the maximum a single recap can show. + * + * [WELCOME_ANIMATION] and [GOODBYE_ANIMATION] deliberately reuse two of + * these rather than being reserved extras: the greeting wave and the + * thumbs-up are the two actions that actually mean "hello" and "well done", + * and with six assets that semantic fit is worth more than absolute + * uniqueness. In a full six-category recap those two therefore appear twice, + * at opposite ends of the story. Adding two more distinct actions here would + * remove even that. + */ + val STORY_ANIMATIONS = listOf( + R.raw.recap_didi_1, // neutral, hands down + R.raw.recap_didi_2, // resting, slight hand movement + R.raw.recap_didi_3, // hand to chin + R.raw.recap_didi_4, // still, calm + R.raw.recap_didi_5, // greeting wave + R.raw.recap_didi_6, // two-handed thumbs up + ) + + /** Thumbs up — the closing "well done". Also in [STORY_ANIMATIONS]. */ + val GOODBYE_ANIMATION = R.raw.recap_didi_6 + + /** Greeting wave — she opens the story. Also in [STORY_ANIMATIONS]. */ + val WELCOME_ANIMATION = R.raw.recap_didi_5 + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapStoryGate.kt b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapStoryGate.kt new file mode 100644 index 000000000..4e92fc19d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/helpers/RecapStoryGate.kt @@ -0,0 +1,52 @@ +package org.piramalswasthya.sakhi.helpers + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.model.RecapContentLibrary +import org.piramalswasthya.sakhi.repositories.MonthlyRecapRepo +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Phase 7 — the single entry point the UI uses to obtain the personalised + * recap story (bundled content library + frozen snapshot → composed scenes). + * + * Encodes the user-approved EMPTY-MONTH rule at one choke point: when the month + * has no countable work (or no user / unusable content), [composeStory] returns + * an empty list and callers keep the recap CLOSED — the dashboard strip hides + * and playback never opens. Fail-closed, like the availability gate. + * + * Production notes: the bundled library is parsed ONCE per process (memoised in + * this @Singleton — immutable content, so no invalidation is needed) and always + * off the main thread, so the dashboard strip check costs one cached-object + * read + the repo's frozen-metrics fast path. Everything is on-device and + * per-user: no network, no cross-user data — scale-out is per handset. + */ +@Singleton +class RecapStoryGate @Inject constructor( + @ApplicationContext private val context: Context, + private val recapRepo: MonthlyRecapRepo, +) { + + @Volatile + private var cachedLibrary: RecapContentLibrary? = null + + /** Parses the bundled content once per process, always on the IO dispatcher. */ + private suspend fun library(): RecapContentLibrary? = + cachedLibrary ?: withContext(Dispatchers.IO) { + // Benign race: a concurrent first call may parse twice; the content is + // immutable so both results are identical and one simply wins the cache. + RecapContentCodec.loadBundled(context).also { cachedLibrary = it } + } + + /** Composed scene list for the current snapshot; EMPTY = do not open the recap. */ + suspend fun composeStory(): List { + val library = library() ?: return emptyList() + return recapRepo.buildPersonalizedScenes(library) ?: emptyList() + } + + /** True only when at least one celebration scene exists this month. */ + suspend fun hasStory(): Boolean = composeStory().isNotEmpty() +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/BadgeEntities.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/BadgeEntities.kt new file mode 100644 index 000000000..4ffdb9433 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/BadgeEntities.kt @@ -0,0 +1,79 @@ +package org.piramalswasthya.sakhi.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Badges module tables (LLD §4.1). The module is strictly read-only over + * health tables; these are the only tables it writes. + */ + +/** Permanent, append-only log of earned levels. "Once earned, never revoked". */ +@Entity( + tableName = "BADGE_EARNED", + indices = [Index(value = ["userId", "badgeId", "level", "caseRef"], unique = true)] +) +data class BadgeEarnedCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val userId: Int, + val badgeId: String, + val level: Int, + // "" for cumulative/streak levels, quarterKey for re-earnable badges, + // benId for per-case recognitions. Never transmitted to the server. + val caseRef: String = "", + val earnedAt: Long, + val synced: Boolean = false +) + +/** + * Latest progress and streak snapshot, overwritten on every evaluation. + * + * Keyed by (userId, badgeId), not badgeId alone. One device is shared between ASHAs often + * enough that a global key would show the previous user's progress on the shelf: the drawer + * logout deliberately keeps the database so unsynced records survive, so rows outlive the + * session that wrote them. + */ +@Entity(tableName = "BADGE_STATE", primaryKeys = ["userId", "badgeId"]) +data class BadgeStateCache( + val userId: Int, + val badgeId: String, + val currentLevel: Int, + val progress: Long, + val nextTarget: Long, + val streakCount: Long = 0, + val graceRemaining: Int = 0, + val lastEvaluatedAt: Long +) + +/** One row per ISO week with at least one successful sync. */ +@Entity(tableName = "BADGE_SYNC_LOG") +data class BadgeSyncLogCache( + @PrimaryKey + val weekKey: String, + val syncCompletedAt: Long +) + +/** Server-pushed streak-freeze windows (illness / alternate duty). */ +@Entity( + tableName = "BADGE_STREAK_FREEZE", + indices = [Index(value = ["badgeId", "startDate", "endDate"], unique = true)] +) +data class BadgeStreakFreezeCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + // "" applies to all badges + val badgeId: String = "", + val startDate: Long, + val endDate: Long +) + +/** Server-pushed thresholds, copy version and feature flags, as key/value rows. */ +@Entity(tableName = "BADGE_CONFIG") +data class BadgeConfigCache( + @PrimaryKey + val key: String, + val value: String, + val updatedAt: Long +) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/EveningNotificationEntities.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/EveningNotificationEntities.kt new file mode 100644 index 000000000..30e1ba95c --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/EveningNotificationEntities.kt @@ -0,0 +1,63 @@ +package org.piramalswasthya.sakhi.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Evening Notification module tables (Notification LLD §4). + * All notification assembly happens on-device from these tables; + * the server only distributes the versioned template library. + */ + +/** Versioned template library; replaced wholesale on sync (replaceAll). */ +@Entity( + tableName = "notification_templates", + indices = [Index(value = ["bucket", "language"])] +) +data class NotifTemplateCache( + @PrimaryKey + val templateId: String, + /** ZERO / ONE / TWO_PLUS activity bucket */ + val bucket: String, + /** en / hi / as / bn */ + val language: String, + val bodyTemplate: String, + val libraryVersion: Int +) + +/** Delivery log: dedupes per-day delivery and drives template rotation. */ +@Entity( + tableName = "notification_history", + indices = [Index(value = ["bucket"])] +) +data class NotifHistoryCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val templateId: String, + /** "2026-08-23" */ + val shownDate: String, + val bucket: String +) + +/** + * Eligible activity log. The UNIQUE constraint IS the edit-vs-new dedup rule: + * the second save of the same (beneficiary, form, day) is ignored. + */ +@Entity( + tableName = "form_save_log", + indices = [ + Index(value = ["beneficiaryId", "formType", "dateKey"], unique = true), + Index(value = ["dateKey"]) + ] +) +data class FormSaveLogCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val formType: String, + val beneficiaryId: Long, + val ashaWorkerId: Int, + val savedAt: Long, + /** "2026-08-23" */ + val dateKey: String +) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/MonthlyRecapCache.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/MonthlyRecapCache.kt new file mode 100644 index 000000000..f06e587ec --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/MonthlyRecapCache.kt @@ -0,0 +1,115 @@ +package org.piramalswasthya.sakhi.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Language chosen for the Monthly Recap ONLY. This never changes the global + * Sakhi application language. + */ +enum class MonthlyRecapLanguage(val token: String) { + HINDI("HI"), + ASSAMESE("AS"); + + companion object { + fun fromToken(token: String?): MonthlyRecapLanguage? = + entries.firstOrNull { it.token == token } + } +} + +/** Local lifecycle of one monthly recap snapshot. Playback-only concern. */ +enum class RecapStatus { + NOT_STARTED, + IN_PROGRESS, + COMPLETED; + + companion object { + /** Corrupted/unknown persisted values fall back safely to NOT_STARTED. */ + fun fromToken(token: String?): RecapStatus = + entries.firstOrNull { it.name == token } ?: NOT_STARTED + } +} + +/** + * One Monthly Recap snapshot: exactly one row per (userId, recapYearMonth) — + * enforced by a unique index so the database, not in-memory state, is the final + * uniqueness boundary against rapid taps / rotation / process recreation. + * + * The snapshot must stay stable after first creation: the frozen activity window, + * the variant seed and (later) the metric payload never change on reopen. + * [metricsJson] stays NULL in Phase 3 — real on-device metric calculation is + * Phase 4; nothing here fabricates production counts. + * + * Cleared automatically on logout via the existing clearAllTables() flow. + */ +@Entity( + tableName = "MONTHLY_RECAP", + indices = [Index(value = ["userId", "recapYearMonth"], unique = true)] +) +data class MonthlyRecapCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** Logged-in ASHA (User.userId). Never hardcoded. */ + val userId: Int, + + /** Canonical year-month key of the recap month, e.g. 202606 for June 2026. */ + val recapYearMonth: Int, + + /** Frozen activity window: start-inclusive, device-local time at creation. */ + val windowStartMillis: Long, + + /** Frozen activity window: end-exclusive. */ + val windowEndMillis: Long, + + /** RecapStatus token; parse with [recapStatus]. */ + val status: String = RecapStatus.NOT_STARTED.name, + + /** MonthlyRecapLanguage token ("HI"/"AS"); null until explicitly selected. */ + val language: String? = null, + + /** + * Stable identity for message-variant selection: generated once at snapshot + * creation, never regenerated, independent of display language, so reopening + * or switching Hindi/Assamese keeps the same semantic variant. + */ + val variantSeed: Long, + + /** Schema/content versioning for forward compatibility. */ + val snapshotVersion: Int = 1, + + /** Phase 4 boundary: serialized metric payload; NULL until real calculation. */ + val metricsJson: String? = null, + + /** Last safely completed scene index (generic; no beneficiary/category data). */ + val progressScene: Int = 0, + + /** Total scene count; unknown until playback exists (Phase 6). */ + val totalScenes: Int? = null, + + val createdAt: Long, + val updatedAt: Long, + val startedAt: Long? = null, + val completedAt: Long? = null, +) + +/** Safe typed accessors (unknown persisted values never crash). */ +fun MonthlyRecapCache.recapStatus(): RecapStatus = RecapStatus.fromToken(status) + +fun MonthlyRecapCache.recapLanguage(): MonthlyRecapLanguage? = + MonthlyRecapLanguage.fromToken(language) + +/** + * Progress with corrupted values coerced into valid bounds. + * + * The upper bound is `totalScenes - 1`, not `totalScenes`: [progressScene] is an + * INDEX into the scene list, so a story of 5 scenes has valid indices 0..4. + * Clamping here (the resume path) as well as on write also repairs snapshots that + * an earlier build already stored one past the end. + */ +fun MonthlyRecapCache.safeProgressScene(): Int { + val lastIndex = totalScenes?.takeIf { it > 0 }?.let { it - 1 } + val coerced = progressScene.coerceAtLeast(0) + return if (lastIndex != null) coerced.coerceAtMost(lastIndex) else coerced +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/MonthlyRecapMetrics.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/MonthlyRecapMetrics.kt new file mode 100644 index 000000000..e095792c4 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/MonthlyRecapMetrics.kt @@ -0,0 +1,169 @@ +package org.piramalswasthya.sakhi.model + +/** + * Aggregate Monthly Recap metrics — the ONLY thing written into + * MonthlyRecapCache.metricsJson. Strictly privacy-safe aggregates: counts, + * stable identifiers, window bounds and availability status only. MUST NOT carry + * beneficiary ids/names, household ids, health scores, clinical answers, referral + * reasons or any record-level detail. + * + * Shape (Phase 6): the payload is a list of CATEGORIES; each category rolls up one + * or more ACTIVITIES into a category total. Only categories with at least one + * verified, ASHA-owned activity are emitted; deferred/unverifiable categories are + * simply absent (or marked UNAVAILABLE) — never fabricated as 0. + */ + +/** Semantic counting unit for a recap activity metric. */ +enum class RecapCountingUnit { + EVENT, + UNIQUE_BENEFICIARY, + FORM_SUBMISSION, + REGISTRATION, + VISIT, + DOSE, +} + +/** + * Whether a metric/category could be safely computed. + * + * INTERNAL ONLY — this status exists so a deferred category is never mis-rendered + * as a real `0`. It MUST NOT be surfaced in any ASHA-facing text: the UI silently + * shows AVAILABLE results and omits UNAVAILABLE ones (no "we couldn't include X"). + */ +enum class RecapMetricStatus { AVAILABLE, UNAVAILABLE } + +/** One privacy-safe aggregate activity metric. [count] is meaningful only when [status] is AVAILABLE. */ +data class RecapActivityMetric( + /** Stable activity id, e.g. [MonthlyRecapMetricsContract.ACTIVITY_CBAC_SCREENINGS]. */ + val activityId: String, + /** [RecapCountingUnit] name, e.g. "EVENT". */ + val unit: String, + val count: Int, + /** [RecapMetricStatus] name; defaults to AVAILABLE. */ + val status: String = RecapMetricStatus.AVAILABLE.name, +) + +/** + * One recap category (e.g. Maternal Health) rolling up its activities. + * + * [categoryTotal] = sum of the counts of AVAILABLE activities. Because each + * activity is a distinct Room table, no single record can be counted under two + * activities, so the sum never double-counts. [status] is AVAILABLE when at least + * one activity is available, otherwise UNAVAILABLE. + */ +data class RecapCategoryMetric( + val categoryId: String, + val activities: List, + val categoryTotal: Int, + val status: String, +) { + companion object { + /** Builds a category from its activities, computing the total + status per the aggregation rule. */ + fun from(categoryId: String, activities: List): RecapCategoryMetric { + val available = activities.filter { it.status == RecapMetricStatus.AVAILABLE.name } + val total = available.sumOf { it.count } + val status = + if (available.isNotEmpty()) RecapMetricStatus.AVAILABLE.name + else RecapMetricStatus.UNAVAILABLE.name + return RecapCategoryMetric(categoryId, activities, total, status) + } + } +} + +/** + * Why a calculated payload was NOT frozen. Ordered most-fundamental first; the + * first matching reason is the one reported. + * + * INTERNAL ONLY — like [RecapMetricStatus] this never reaches ASHA-facing text. + * A blocked recap is silently hidden and re-evaluated on the next open. + */ +enum class RecapFreezeBlocker { + /** Ready to freeze. */ + NONE, + + /** The one-shot server download has not finished on this install. */ + FULL_PULL_INCOMPLETE, + + /** + * This install did not exist for the whole recap month, so its local database + * cannot be trusted to hold every record the ASHA created that month + * (re-install, new device, or an ASHA who joined mid-month). + */ + DEVICE_MISSED_PART_OF_MONTH, + + /** Nothing countable happened — the existing celebration-only/zero rule. */ + NO_COUNTABLE_WORK, +} + +/** + * Why and under what conditions a recap payload was produced. Written into the + * payload so a wrong count found during UAT is investigable after the fact + * instead of unfalsifiable. + * + * Privacy-safe by the same rule as the rest of the payload: flags, timestamps + * and a version only — never record-level data. + */ +data class RecapDiagnostics( + /** [RecapFreezeBlocker] name. NONE means the payload was frozen. */ + val freezeBlocker: String, + val isFullPullComplete: Boolean, + /** Millis from which this install's local data is considered complete. */ + val localDataSince: Long, + /** When readiness was evaluated (device-local). */ + val evaluatedAt: Long, + /** Build that produced this evaluation, so field reports can be pinned to a build. */ + val appVersionCode: Int, +) + +/** + * Versioned aggregate payload persisted in MonthlyRecapCache.metricsJson and + * frozen once generated for a (userId, recapYearMonth). + * + * [diagnostics] is additive and nullable: payloads frozen before it existed decode + * with null, so PAYLOAD_SCHEMA_VERSION deliberately does NOT change (bumping it + * would invalidate every already-frozen snapshot for a backward-compatible field). + */ +data class MonthlyRecapMetricsPayload( + val payloadSchemaVersion: Int, + val calculationVersion: Int, + val recapYearMonth: Int, + val generatedAt: Long, + val windowStartMillis: Long, + val windowEndMillisExclusive: Long, + val categories: List, + val diagnostics: RecapDiagnostics? = null, +) + +object MonthlyRecapMetricsContract { + /** JSON envelope version; bump only when the payload SHAPE changes. */ + const val PAYLOAD_SCHEMA_VERSION = 2 + + /** Semantic calculation version; bump when counting rules change. */ + const val CALCULATION_VERSION = 2 + + // ---- Category ids (only categories with >=1 verified activity are emitted) ---- + const val CATEGORY_NCD = "NCD" + const val CATEGORY_HOUSEHOLD = "HOUSEHOLD" + const val CATEGORY_BENEFICIARY = "BENEFICIARY" + const val CATEGORY_ELIGIBLE_COUPLE = "ELIGIBLE_COUPLE" + const val CATEGORY_MATERNAL_HEALTH = "MATERNAL_HEALTH" + const val CATEGORY_IMMUNIZATION = "IMMUNIZATION" + + // ---- Activity ids ---- + /** Verified first activity: completed CBAC (NCD screening) events. */ + const val ACTIVITY_CBAC_SCREENINGS = "CBAC_SCREENINGS" + const val ACTIVITY_HOUSEHOLD_REGISTRATIONS = "HOUSEHOLD_REGISTRATIONS" + const val ACTIVITY_BENEFICIARY_REGISTRATIONS = "BENEFICIARY_REGISTRATIONS" + + /** Distinct couples registered OR tracked for family planning (single number). */ + const val ACTIVITY_ELIGIBLE_COUPLE_FP = "ELIGIBLE_COUPLE_FP" + + /** + * Distinct pregnant women / mothers the ASHA supported via ANY maternal activity + * (pregnancy registration, ANC, PMSMA, delivery outcome, PNC) — a single number. + */ + const val ACTIVITY_MATERNAL_HEALTH_MOTHERS = "MATERNAL_HEALTH_MOTHERS_SUPPORTED" + + /** Vaccine doses the ASHA administered (child + mother), counted per dose. */ + const val ACTIVITY_IMMUNIZATION_DOSES = "IMMUNIZATION_DOSES_ADMINISTERED" +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/RecapContent.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/RecapContent.kt new file mode 100644 index 000000000..2a3228752 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/RecapContent.kt @@ -0,0 +1,80 @@ +package org.piramalswasthya.sakhi.model + +/** + * Phase 7 — typed model of the bundled Monthly Recap content library + * (`res/raw/recap_content.json`): the personalised Hindi/Assamese sentence + * matrix (category × band × 3 variants), the band definitions, and the + * intro/closing lines. + * + * This file is the SINGLE source of recap wording. The JSON shape is exactly + * what a future server-hosted content endpoint would return, so lifting the + * library server-side later only swaps the SOURCE — never these models nor the + * composer that consumes them. Text placeholders: `{count}` only (the "this + * month" phrasing is literal text inside each sentence, by design). + * + * ⚠️ EVERY property here MUST keep a default value. Kotlin only emits a synthetic + * no-arg constructor when all parameters have defaults; without one, Gson falls + * back to `Unsafe.allocateInstance` and leaves absent JSON keys as **null even in + * non-null `String` fields**. Those nulls then crash the validator instead of + * being rejected by it. With defaults, a missing key arrives as `""` or `0`, which + * [org.piramalswasthya.sakhi.helpers.RecapContentCodec] already rejects — so + * malformed content fails closed rather than throwing. Do not "tidy" these away. + */ +data class RecapContentLibrary( + val schemaVersion: Int = 0, + val contentVersion: Int = 0, + val bandDefinitions: List = emptyList(), + val languages: List = emptyList(), +) { + /** Case-insensitive lookup so the app's "HI"/"AS" tokens match the file's "hi"/"as". */ + fun languageOrNull(token: String?): RecapLanguageContent? = + token?.let { t -> languages.firstOrNull { it.id.equals(t, ignoreCase = true) } } +} + +/** + * One count band. [maxCount] == null means open-ended (e.g. HIGH = 6+). + * Bands are uniform across categories — a grammatical/magnitude model + * (SINGULAR / DOUBLE / MEDIUM / HIGH), not per-category performance tiers. + */ +data class RecapBandDefinition( + val id: String = "", + val minCount: Int = 0, + val maxCount: Int? = null, +) { + fun contains(count: Int): Boolean = + count >= minCount && (maxCount == null || count <= maxCount) +} + +data class RecapLanguageContent( + val id: String = "", + val name: String = "", + /** Warm welcome line(s) shown before the first scene (no animation). */ + val intro: List = emptyList(), + /** Warm goodbye line(s) for the final scene. */ + val closing: List = emptyList(), + val categories: List = emptyList(), +) { + fun categoryOrNull(categoryId: String): RecapCategoryContent? = + categories.firstOrNull { it.id == categoryId } +} + +data class RecapCategoryContent( + val id: String = "", + val name: String = "", + val countMeaning: String = "", + val unit: String = "", + val bands: List = emptyList(), +) { + fun bandOrNull(bandId: String): RecapBandContent? = + bands.firstOrNull { it.id == bandId } +} + +data class RecapBandContent( + val id: String = "", + val sentences: List = emptyList(), +) + +data class RecapSentence( + val id: String = "", + val text: String = "", +) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/network/BadgeApiService.kt b/app/src/main/java/org/piramalswasthya/sakhi/network/BadgeApiService.kt new file mode 100644 index 000000000..6b791a42d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/network/BadgeApiService.kt @@ -0,0 +1,72 @@ +package org.piramalswasthya.sakhi.network + +import com.squareup.moshi.JsonClass +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +/** + * Badge endpoints (LLD §4.2) on the existing authenticated client. + * The server owns policy; the device owns computation. No beneficiary + * data is ever transmitted. + */ +interface BadgeApiService { + + @GET("flw-api/badges/config") + suspend fun getConfig(): Response + + @GET("flw-api/badges/freezes") + suspend fun getFreezes(): Response + + @GET("flw-api/badges/earned") + suspend fun getEarned(): Response + + @POST("flw-api/badges/earned") + suspend fun postEarned(@Body body: BadgeEarnedPush): Response +} + +@JsonClass(generateAdapter = true) +data class BadgeConfigResponse( + /** key/value pairs, e.g. "milestones.steady_syncer" → "4,8,16,26", "feature_enabled" → "true" */ + val config: Map? +) + +@JsonClass(generateAdapter = true) +data class BadgeFreezesResponse( + val freezes: List? +) + +@JsonClass(generateAdapter = true) +data class BadgeFreezeDTO( + /** null/empty applies to all badges */ + val badgeId: String?, + val startDate: Long, + val endDate: Long +) + +@JsonClass(generateAdapter = true) +data class BadgeEarnedResponse( + val earned: List? +) + +@JsonClass(generateAdapter = true) +data class BadgeEarnedDTO( + val badgeId: String, + val level: Int, + val earnedAt: Long, + /** + * What separates two awards of the same badge at the same level: a quarter key, or an + * opaque digest for per-case awards, and empty for streak and cumulative badges. Never a + * beneficiary identifier — see [org.piramalswasthya.sakhi.badges.domain.AwardKeys]. + * + * Defaulted so a server that predates the field still deserialises. + */ + val awardKey: String = "" +) + +@JsonClass(generateAdapter = true) +data class BadgeEarnedPush( + val userId: Int, + val badges: List +) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/ActivityClassifier.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/ActivityClassifier.kt new file mode 100644 index 000000000..eaefe95e3 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/ActivityClassifier.kt @@ -0,0 +1,75 @@ +package org.piramalswasthya.sakhi.notifications + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Activity classification (Notification LLD §2). + * + * Included: direct, per-beneficiary health service actions (new saves only). + * Excluded by omission: death reports (CDR, MDSR — ethically excluded from + * all gamification), village/community-level forms (VHND, VHNC, PHC Review, + * AHD, Saas-Bahu, NDD, MAA, Pulse Polio, ORS, Filaria MDA, U-WIN), and edits + * (enforced by form_save_log's unique constraint + created-today rule). + */ +object ActivityClassifier { + + /** formType → health table it is recorded in. */ + val INCLUDED: Map = mapOf( + // Household / Beneficiary + "household_registration" to "HOUSEHOLD", + "beneficiary_registration" to "BENEFICIARY", + // Eligible Couple + "ec_registration" to "ELIGIBLE_COUPLE_REG", + "ec_tracking" to "ELIGIBLE_COUPLE_TRACKING", + // Maternal Health + "pw_registration" to "PREGNANCY_REGISTER", + "anc_visit" to "PREGNANCY_ANC", + "anc_visit_dynamic" to "ALL_VISIT_HISTORY_ANC", + "delivery_outcome" to "DELIVERY_OUTCOME", + "pnc_mother_care" to "PNC_VISIT", + "infant_registration" to "INFANT_REG", + "child_registration" to "CHILD_REG", + "pmsma_high_risk" to "PMSMA", + "hwc_referral" to "NCD_REFER", + // Child Care + "hbnc_visit" to "HBNC", + "hbyc_visit" to "ALL_VISIT_HISTORY_HBYC", + "under_five_care" to "children_under_five_all_visit", + "adolescent_care" to "Adolescent_Health_Form_Data", + // Disease Control + "ncd_screening" to "CBAC", + "malaria_screening" to "MALARIA_SCREENING", + "kala_azar_screening" to "KALAZAR_SCREENING", + "aes_je_screening" to "AES_SCREENING", + "filaria_screening" to "FILARIA_SCREENING", + "leprosy_screening" to "LEPROSY_SCREENING", + // Communicable Disease / TB + "tb_screening" to "TB_SCREENING", + "tb_suspected" to "TB_SUSPECTED", + "tb_confirmed" to "TB_CONFIRMED_TREATMENT", + // Immunization + "child_immunization" to "IMMUNIZATION", + // HRP + "hrp_pregnant_assess" to "HRP_PREGNANT_ASSESS", + "hrp_non_pregnant_assess" to "HRP_NON_PREGNANT_ASSESS", + "hrp_pregnant_track" to "HRP_PREGNANT_TRACK", + "hrp_non_pregnant_track" to "HRP_NON_PREGNANT_TRACK", + // General OP Care + "general_opd" to "GENERAL_OPD_ACTIVITY" + ) + + /** Activity bands (LLD §5): tone without exposing numerical counts. */ + enum class Bucket { ZERO, ONE, TWO_PLUS } + + fun bucketFor(todayCount: Int): Bucket = when { + todayCount <= 0 -> Bucket.ZERO + todayCount == 1 -> Bucket.ONE + else -> Bucket.TWO_PLUS + } + + private val dateKeyFormat get() = SimpleDateFormat("yyyy-MM-dd", Locale.US) + + fun dateKey(time: Long): String = dateKeyFormat.format(Date(time)) +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/BundledTemplates.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/BundledTemplates.kt new file mode 100644 index 000000000..039086af1 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/BundledTemplates.kt @@ -0,0 +1,61 @@ +package org.piramalswasthya.sakhi.notifications + +import org.piramalswasthya.sakhi.model.NotifTemplateCache + +/** + * Bundled fallback template library (Notification LLD §7 offline resilience): + * works from day one, before any sync. Server templates (libraryVersion ≥ 1) + * replace these wholesale when they arrive. + * + * Buckets: ZERO = gratitude / quiet day, ONE = active day, TWO_PLUS = busy day. + * Languages: en, hi, as, bn. Translations pending native-speaker review. + */ +object BundledTemplates { + + private fun t(id: String, bucket: String, lang: String, body: String) = + NotifTemplateCache(id, bucket, lang, body, libraryVersion = 0) + + val ALL: List = listOf( + // ─── ZERO / quiet day — gratitude, never framed as failure ─── + t("zero_en_1", "ZERO", "en", "Good evening, {name}. Thank you for your continued service to your community. Wishing you a peaceful evening."), + t("zero_en_2", "ZERO", "en", "Good evening, {name}. Your village is stronger because of you. Rest well tonight."), + t("zero_en_3", "ZERO", "en", "Good evening, {name}. Every day of your service matters. Have a restful evening."), + t("zero_hi_1", "ZERO", "hi", "शुभ संध्या, {name} जी। समुदाय की निरंतर सेवा के लिए धन्यवाद। आपकी शाम मंगलमय हो।"), + t("zero_hi_2", "ZERO", "hi", "शुभ संध्या, {name} जी। आपके गाँव की ताक़त आप हैं। आज आराम कीजिए।"), + t("zero_hi_3", "ZERO", "hi", "शुभ संध्या, {name} जी। आपकी सेवा का हर दिन मायने रखता है। शुभ रात्रि।"), + t("zero_as_1", "ZERO", "as", "শুভ সন্ধিয়া, {name}। সমাজলৈ আপোনাৰ নিৰন্তৰ সেৱাৰ বাবে ধন্যবাদ। আপোনাৰ সন্ধিয়া শুভ হওক।"), + t("zero_as_2", "ZERO", "as", "শুভ সন্ধিয়া, {name}। আপোনাৰ বাবেই আপোনাৰ গাঁও শক্তিশালী। আজি জিৰণি লওক।"), + t("zero_as_3", "ZERO", "as", "শুভ সন্ধিয়া, {name}। আপোনাৰ সেৱাৰ প্ৰতিটো দিন মূল্যৱান। শুভ ৰাত্ৰি।"), + t("zero_bn_1", "ZERO", "bn", "শুভ সন্ধ্যা, {name}। সমাজের প্রতি আপনার নিরন্তর সেবার জন্য ধন্যবাদ। আপনার সন্ধ্যা শুভ হোক।"), + t("zero_bn_2", "ZERO", "bn", "শুভ সন্ধ্যা, {name}। আপনার জন্যই আপনার গ্রাম শক্তিশালী। আজ বিশ্রাম নিন।"), + t("zero_bn_3", "ZERO", "bn", "শুভ সন্ধ্যা, {name}। আপনার সেবার প্রতিটি দিন মূল্যবান। শুভ রাত্রি।"), + + // ─── ONE / active day — affirmation ─── + t("one_en_1", "ONE", "en", "Good evening, {name}. You made a positive difference in your community today. Thank you for your dedication."), + t("one_en_2", "ONE", "en", "Good evening, {name}. A family is safer tonight because of your visit today. Thank you."), + t("one_en_3", "ONE", "en", "Good evening, {name}. Your work today touched a life. Thank you for showing up."), + t("one_hi_1", "ONE", "hi", "शुभ संध्या, {name} जी। आज आपने अपने समुदाय में सकारात्मक बदलाव लाया। आपके समर्पण के लिए धन्यवाद।"), + t("one_hi_2", "ONE", "hi", "शुभ संध्या, {name} जी। आज आपकी सेवा से एक परिवार अधिक सुरक्षित है। धन्यवाद।"), + t("one_hi_3", "ONE", "hi", "शुभ संध्या, {name} जी। आज आपके काम ने किसी की ज़िंदगी को छुआ। धन्यवाद।"), + t("one_as_1", "ONE", "as", "শুভ সন্ধিয়া, {name}। আজি আপুনি আপোনাৰ সমাজত ইতিবাচক পৰিৱৰ্তন আনিলে। আপোনাৰ নিষ্ঠাৰ বাবে ধন্যবাদ।"), + t("one_as_2", "ONE", "as", "শুভ সন্ধিয়া, {name}। আপোনাৰ আজিৰ সেৱাৰ বাবে এটা পৰিয়াল আজি অধিক সুৰক্ষিত। ধন্যবাদ।"), + t("one_as_3", "ONE", "as", "শুভ সন্ধিয়া, {name}। আজি আপোনাৰ কামে এটা জীৱন স্পৰ্শ কৰিলে। ধন্যবাদ।"), + t("one_bn_1", "ONE", "bn", "শুভ সন্ধ্যা, {name}। আজ আপনি আপনার সমাজে ইতিবাচক পরিবর্তন এনেছেন। আপনার নিষ্ঠার জন্য ধন্যবাদ।"), + t("one_bn_2", "ONE", "bn", "শুভ সন্ধ্যা, {name}। আপনার আজকের সেবার জন্য একটি পরিবার আজ বেশি সুরক্ষিত। ধন্যবাদ।"), + t("one_bn_3", "ONE", "bn", "শুভ সন্ধ্যা, {name}। আজ আপনার কাজ একটি জীবন স্পর্শ করেছে। ধন্যবাদ।"), + + // ─── TWO_PLUS / busy day — celebration ─── + t("two_en_1", "TWO_PLUS", "en", "Good evening, {name}. You had a busy day supporting families today. Thank you for your commitment."), + t("two_en_2", "TWO_PLUS", "en", "Good evening, {name}. Many families felt your care today. Your community is lucky to have you."), + t("two_en_3", "TWO_PLUS", "en", "Good evening, {name}. What a day of service! Rest well — you have earned it."), + t("two_hi_1", "TWO_PLUS", "hi", "शुभ संध्या, {name} जी। आज आपने कई परिवारों का सहारा बनकर व्यस्त दिन बिताया। आपकी प्रतिबद्धता के लिए धन्यवाद।"), + t("two_hi_2", "TWO_PLUS", "hi", "शुभ संध्या, {name} जी। आज कई परिवारों ने आपकी देखभाल महसूस की। आपका समुदाय भाग्यशाली है।"), + t("two_hi_3", "TWO_PLUS", "hi", "शुभ संध्या, {name} जी। सेवा से भरा कैसा दिन! आराम कीजिए — आप इसके हक़दार हैं।"), + t("two_as_1", "TWO_PLUS", "as", "শুভ সন্ধিয়া, {name}। আজি আপুনি বহু পৰিয়ালক সহায় কৰি ব্যস্ত দিন কটালে। আপোনাৰ দায়বদ্ধতাৰ বাবে ধন্যবাদ।"), + t("two_as_2", "TWO_PLUS", "as", "শুভ সন্ধিয়া, {name}। আজি বহু পৰিয়ালে আপোনাৰ যত্ন অনুভৱ কৰিলে। আপোনাৰ সমাজ ভাগ্যৱান।"), + t("two_as_3", "TWO_PLUS", "as", "শুভ সন্ধিয়া, {name}। সেৱাৰে ভৰা এটা দিন! জিৰণি লওক — আপুনি ইয়াৰ যোগ্য।"), + t("two_bn_1", "TWO_PLUS", "bn", "শুভ সন্ধ্যা, {name}। আজ আপনি বহু পরিবারকে সাহায্য করে ব্যস্ত দিন কাটিয়েছেন। আপনার দায়বদ্ধতার জন্য ধন্যবাদ।"), + t("two_bn_2", "TWO_PLUS", "bn", "শুভ সন্ধ্যা, {name}। আজ বহু পরিবার আপনার যত্ন অনুভব করেছে। আপনার সমাজ ভাগ্যবান।"), + t("two_bn_3", "TWO_PLUS", "bn", "শুভ সন্ধ্যা, {name}। সেবায় ভরা একটি দিন! বিশ্রাম নিন — আপনি এর যোগ্য।") + ) +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/EveningNotificationReceiver.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/EveningNotificationReceiver.kt new file mode 100644 index 000000000..07e7484ea --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/EveningNotificationReceiver.kt @@ -0,0 +1,43 @@ +package org.piramalswasthya.sakhi.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Dependencies fetched via @EntryPoint instead of @AndroidEntryPoint: + * Hilt 2.48's receiver bytecode injection is incompatible with AGP 8.x + * javac output paths (fails the ASM transform). + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface EveningNotifEntryPoint { + fun engine(): NotificationEngine + fun scheduler(): NotificationScheduler +} + +/** Fired by AlarmManager at the district's evening time. Assembles offline. */ +class EveningNotificationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, EveningNotifEntryPoint::class.java + ) + val result = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + entryPoint.engine().deliverEveningNotification() + entryPoint.scheduler().schedule() // re-arm for tomorrow + } finally { + result.finish() + } + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/FormSaveInterceptor.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/FormSaveInterceptor.kt new file mode 100644 index 000000000..1e9e47225 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/FormSaveInterceptor.kt @@ -0,0 +1,106 @@ +package org.piramalswasthya.sakhi.notifications + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.database.room.InAppDb +import org.piramalswasthya.sakhi.database.room.dao.EveningNotifDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.model.FormSaveLogCache +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Produces the eligible activity stream (Notification LLD §5/§6). + * + * Rather than hooking all 40 repository save paths, it derives today's new + * records straight from the included health tables (id/date columns resolved + * at runtime, isDraft filtered) and INSERT-OR-IGNOREs them into + * form_save_log — whose unique constraint IS the edit-vs-new rule: a record + * created before today never appears, a second save of the same + * (beneficiary, form, day) is ignored. + */ +@Singleton +class FormSaveInterceptor @Inject constructor( + private val db: InAppDb, + private val dao: EveningNotifDao, + private val pref: PreferenceDao +) { + + private val sql get() = db.openHelper.readableDatabase + + private fun columns(table: String): Map = + try { + val map = mutableMapOf() + sql.query("PRAGMA table_info(`$table`)").use { c -> + val n = c.getColumnIndex("name") + val t = c.getColumnIndex("type") + while (c.moveToNext()) map[c.getString(n).lowercase()] = + (c.getString(t) ?: "").uppercase() + } + map + } catch (e: Exception) { + emptyMap() + } + + private fun resolve(cols: Map, candidates: List, intOnly: Boolean) = + candidates.firstOrNull { name -> + val type = cols[name] + type != null && (!intOnly || type.contains("INT")) + } + + /** Scans included tables and logs today's new records. Safe on any schema. */ + suspend fun captureToday() = withContext(Dispatchers.IO) { + val ashaId = try { + pref.getLoggedInUser()?.userId + } catch (e: Exception) { + null + } ?: return@withContext + val now = System.currentTimeMillis() + val dateKey = ActivityClassifier.dateKey(now) + val dayStart = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) + }.timeInMillis + + val saves = mutableListOf() + for ((formType, table) in ActivityClassifier.INCLUDED) { + try { + val cols = columns(table) + if (cols.isEmpty()) continue + val idCol = resolve(cols, ID_CANDIDATES, intOnly = false) ?: continue + val dateCol = resolve(cols, DATE_CANDIDATES, intOnly = true) ?: continue + val draft = if (cols.containsKey("isdraft")) " AND isDraft = 0" else "" + sql.query( + "SELECT DISTINCT `$idCol` FROM `$table` WHERE `$dateCol` >= $dayStart$draft" + ).use { c -> + while (c.moveToNext()) { + saves += FormSaveLogCache( + formType = formType, + beneficiaryId = c.getLong(0), + ashaWorkerId = ashaId, + savedAt = now, + dateKey = dateKey + ) + } + } + } catch (e: Exception) { + // one table failing must not block the day's count (LLD §6) + Timber.w(e, "EveningNotif: capture failed for $table") + } + } + if (saves.isNotEmpty()) dao.logSaves(saves) + } + + companion object { + private val ID_CANDIDATES = + listOf("benid", "beneficiaryid", "childbenid", "householdid", "hhid", "id") + // Creation and activity dates only. An "updated" column is when a row was last + // touched, so correcting a typo on a month-old form would land in today's bucket and + // tell the ASHA she had recorded a visit she did not. A table with no creation date + // is skipped instead, which undercounts rather than inventing work. + private val DATE_CANDIDATES = + listOf("createddate", "createdat", "visitdate", "date") + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotifBootReceiver.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotifBootReceiver.kt new file mode 100644 index 000000000..038cdd28f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotifBootReceiver.kt @@ -0,0 +1,17 @@ +package org.piramalswasthya.sakhi.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.EntryPointAccessors + +/** Alarms don't survive reboot — re-arm the evening delivery. */ +class NotifBootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + EntryPointAccessors.fromApplication( + context.applicationContext, EveningNotifEntryPoint::class.java + ).scheduler().schedule() + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotificationEngine.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotificationEngine.kt new file mode 100644 index 000000000..1e05966f8 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotificationEngine.kt @@ -0,0 +1,128 @@ +package org.piramalswasthya.sakhi.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.database.room.dao.EveningNotifDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.model.NotifHistoryCache +import org.piramalswasthya.sakhi.ui.home_activity.HomeActivity +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Evening notification assembly (Notification LLD §5): counts today's forms, + * classifies the day into a band, selects and fills a template, dispatches, + * and records history. Zero network calls — reads today's data only. + */ +@Singleton +class NotificationEngine @Inject constructor( + @ApplicationContext private val context: Context, + private val dao: EveningNotifDao, + private val interceptor: FormSaveInterceptor, + private val selector: TemplateSelector, + private val filler: TemplateFiller, + private val pref: PreferenceDao +) { + + suspend fun deliverEveningNotification() = withContext(Dispatchers.IO) { + try { + val today = ActivityClassifier.dateKey(System.currentTimeMillis()) + // dedupe: notification already delivered today → skip (LLD §6) + if (dao.shownCountForDay(today) > 0) return@withContext + + interceptor.captureToday() + val count = dao.countForDay(today) + val bucket = ActivityClassifier.bucketFor(count).name + val language = try { + pref.getCurrentLanguage().symbol + } catch (e: Exception) { + "en" + } + val template = selector.select(bucket, language) + val name = try { + pref.getLoggedInUser()?.name + } catch (e: Exception) { + null + } + val body = filler.fill(template.bodyTemplate, name) + + // Only record it if it actually went out. The history row is what makes delivery + // once-a-day, so writing it after a post that was refused (no POST_NOTIFICATIONS, + // or a throwing NotificationManager) burns the day: nothing appeared, and nothing + // will try again until tomorrow. + if (!notify(body)) { + Timber.w("EveningNotif: not delivered, leaving the day open to retry") + return@withContext + } + dao.logShown(NotifHistoryCache(templateId = template.templateId, shownDate = today, bucket = bucket)) + Timber.d("EveningNotif: delivered bucket=$bucket template=${template.templateId}") + } catch (e: Exception) { + Timber.e(e, "EveningNotif: delivery failed") + } + } + + /** @return true when the notification was handed to the system. */ + private fun notify(body: String): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + Timber.w("EveningNotif: POST_NOTIFICATIONS not granted — skipping") + return false + } + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.evening_notif_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + } + // tap opens the Journey screen (LLD §1) + val intent = Intent(context, HomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(EXTRA_OPEN_JOURNEY, true) + } + val pending = PendingIntent.getActivity( + context, REQUEST_CODE, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(context.getString(R.string.app_name)) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(pending) + .setAutoCancel(true) + .build() + return try { + manager.notify(NOTIFICATION_ID, notification) + true + } catch (e: Exception) { + // A refused post (quota, a disabled channel, an OEM restriction) throws here + // rather than returning, and swallowing it silently would mark the day delivered. + Timber.w(e, "EveningNotif: notification manager refused the post") + false + } + } + + companion object { + const val CHANNEL_ID = "evening_notification" + const val EXTRA_OPEN_JOURNEY = "open_journey" + private const val NOTIFICATION_ID = 20001 + private const val REQUEST_CODE = 20001 + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotificationScheduler.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotificationScheduler.kt new file mode 100644 index 000000000..dfb7f9616 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/NotificationScheduler.kt @@ -0,0 +1,90 @@ +package org.piramalswasthya.sakhi.notifications + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Schedules, reschedules and cancels daily evening delivery (Notification + * LLD §3/§7). District timing arrives via sync into TimingStore + * (SharedPreferences); default is 9 PM until configured. Alarms are re-armed + * on every app open, after each delivery, and on boot. + */ +@Singleton +class NotificationScheduler @Inject constructor( + @ApplicationContext private val context: Context +) { + + private val prefs get() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** + * "HH:mm", e.g. "21:00". Called by TemplateSyncHandler on timing change. + * + * The shape of the string is not enough: "99:99" matches it, and Calendar is lenient, so + * setting hour 99 rolls the alarm four days forward rather than failing. The evening + * notification then simply never arrives, with nothing on screen to say why - so the + * range is checked here, where a bad value can still be refused, not at the alarm. + */ + fun updateEveningTime(time: String) { + val parts = time.split(":") + val hour = parts.getOrNull(0)?.toIntOrNull() + val minute = parts.getOrNull(1)?.toIntOrNull() + if (parts.size != 2 || hour == null || minute == null || + hour !in 0..23 || minute !in 0..59 + ) { + Timber.w("EveningNotif: ignoring out-of-range evening time '%s'", time) + return + } + if (time != eveningTime()) { + prefs.edit().putString(KEY_TIME, time).apply() + schedule() // cancel pending schedule and re-schedule with latest timing (LLD §6) + } + } + + + fun eveningTime(): String = prefs.getString(KEY_TIME, DEFAULT_TIME) ?: DEFAULT_TIME + + /** Idempotent: always (re)sets the alarm for the next evening occurrence. */ + fun schedule() { + try { + val (hour, minute) = eveningTime().split(":").map { it.toInt() } + val at = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + if (timeInMillis <= System.currentTimeMillis()) add(Calendar.DAY_OF_YEAR, 1) + }.timeInMillis + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pending = PendingIntent.getBroadcast( + context, REQUEST_CODE, + Intent(context, EveningNotificationReceiver::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + // exact when permitted, inexact otherwise — a few minutes' drift is fine + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pending) + } else { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pending) + } + Timber.d("EveningNotif: scheduled for $at") + } catch (e: Exception) { + Timber.e(e, "EveningNotif: scheduling failed") + } + } + + companion object { + private const val PREFS = "evening_notif_timing" + private const val KEY_TIME = "evening_time" + private const val DEFAULT_TIME = "21:00" + private const val REQUEST_CODE = 20002 + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateFiller.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateFiller.kt new file mode 100644 index 000000000..b013331b4 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateFiller.kt @@ -0,0 +1,12 @@ +package org.piramalswasthya.sakhi.notifications + +import javax.inject.Inject +import javax.inject.Singleton + +/** Runtime personalization (Notification LLD §6): fills {slot} placeholders. */ +@Singleton +class TemplateFiller @Inject constructor() { + + fun fill(template: String, name: String?): String = + template.replace("{name}", name?.trim().takeUnless { it.isNullOrEmpty() } ?: "ASHA") +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateSelector.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateSelector.kt new file mode 100644 index 000000000..5754a584a --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateSelector.kt @@ -0,0 +1,33 @@ +package org.piramalswasthya.sakhi.notifications + +import org.piramalswasthya.sakhi.database.room.dao.EveningNotifDao +import org.piramalswasthya.sakhi.model.NotifTemplateCache +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Template selection policy (Notification LLD §5/§6): random pick from the + * activity-band pool in the worker's language, excluding recently shown + * templates; when all were recently used, reset the filter. Falls back to + * English, then to the bundled library. + */ +@Singleton +class TemplateSelector @Inject constructor( + private val dao: EveningNotifDao +) { + + suspend fun select(bucket: String, language: String): NotifTemplateCache { + val pool = dao.templatesFor(bucket, language) + .ifEmpty { dao.templatesFor(bucket, "en") } + .ifEmpty { BundledTemplates.ALL.filter { it.bucket == bucket && it.language == language } } + .ifEmpty { BundledTemplates.ALL.filter { it.bucket == bucket && it.language == "en" } } + + val recent = dao.recentTemplateIds(bucket, RECENT_WINDOW).toSet() + val fresh = pool.filterNot { it.templateId in recent } + return (fresh.ifEmpty { pool }).random() + } + + companion object { + private const val RECENT_WINDOW = 3 + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateSyncHandler.kt b/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateSyncHandler.kt new file mode 100644 index 000000000..725dc6db4 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/notifications/TemplateSyncHandler.kt @@ -0,0 +1,72 @@ +package org.piramalswasthya.sakhi.notifications + +import org.json.JSONArray +import org.piramalswasthya.sakhi.database.room.dao.EveningNotifDao +import org.piramalswasthya.sakhi.model.NotifTemplateCache +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Template/timing sync (Notification LLD §7). Piggybacks on the existing pull + * payload — no new endpoints, no new workers. Call [handlePullPayload] from + * the pull path once the server adds the fields; a missing/malformed payload + * is a graceful no-op and the module continues on bundled defaults. + * + * Expected fields: notif_library_version (Int), notif_library_data (JSON array + * of {template_id, bucket, language, body_template}), notif_evening_time ("HH:mm"). + */ +@Singleton +class TemplateSyncHandler @Inject constructor( + private val dao: EveningNotifDao, + private val scheduler: NotificationScheduler +) { + + suspend fun handlePullPayload( + libraryVersion: Int?, + libraryDataJson: String?, + eveningTime: String? + ) { + try { + eveningTime?.let { scheduler.updateEveningTime(it) } + + if (libraryVersion == null || libraryDataJson.isNullOrBlank()) return + val localVersion = dao.libraryVersion() ?: 0 + if (libraryVersion <= localVersion) return + + val array = JSONArray(libraryDataJson) + // All or nothing. Dropping the malformed entries and keeping the rest installs a + // partial library under a version number that claims to be complete - and since + // sync only applies a strictly higher version, whichever buckets went missing + // stay missing until the next bump, showing the ASHA nothing on those evenings. + val templates = ArrayList(array.length()) + for (i in 0 until array.length()) { + val o = array.optJSONObject(i) + val templateId = o?.optString("template_id").orEmpty() + val bucket = o?.optString("bucket").orEmpty() + val body = o?.optString("body_template").orEmpty() + if (templateId.isEmpty() || bucket.isEmpty() || body.isEmpty()) { + Timber.w( + "EveningNotif: template library v%d rejected - entry %d is incomplete", + libraryVersion, i + ) + return + } + templates += NotifTemplateCache( + templateId = templateId, + bucket = bucket, + language = o.optString("language", "en").ifEmpty { "en" }, + bodyTemplate = body, + libraryVersion = libraryVersion + ) + } + if (templates.isEmpty()) return + // server is the authoritative source of truth: complete overwrite, in one + // transaction so the alarm can never read a half-empty library + dao.replaceTemplates(templates) + Timber.d("EveningNotif: template library updated to v$libraryVersion (${templates.size} templates)") + } catch (e: Exception) { + Timber.w(e, "EveningNotif: template sync skipped") + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/BeneficiaryRecapDataSource.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/BeneficiaryRecapDataSource.kt new file mode 100644 index 000000000..de5c76d8f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/BeneficiaryRecapDataSource.kt @@ -0,0 +1,24 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.database.room.dao.BenDao +import javax.inject.Inject + +/** + * Monthly Recap (READ-ONLY) source for the Beneficiary category: the count of + * beneficiaries (family members) the current ASHA registered in the frozen recap + * window. Delegates to [BenDao.countCurrentAshaRegistrations] off the main thread. + * Returns an aggregate only — no beneficiary id/detail leaves the DAO. + */ +class BeneficiaryRecapDataSource @Inject constructor( + private val benDao: BenDao, +) { + suspend fun countRegistrations( + userId: Int, + startMillis: Long, + endMillisExclusive: Long, + ): Int = withContext(Dispatchers.IO) { + benDao.countCurrentAshaRegistrations(userId, startMillis, endMillisExclusive) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/CbacRecapDataSource.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/CbacRecapDataSource.kt new file mode 100644 index 000000000..e01060fc5 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/CbacRecapDataSource.kt @@ -0,0 +1,38 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.database.room.dao.CbacDao +import javax.inject.Inject + +/** + * Read-only recap view over CBAC (NCD screening) records for one ASHA. + * + * Delegates to an aggregate DAO query that counts DISTINCT beneficiaries with a + * completed CBAC screening in the recap window, owned by the current ASHA: + * - locally authored rows (ashaId == userId), OR + * - server-downloaded rows (ashaId == 0 AND createdBy == userName). + * Sync state is not filtered (offline-first). Only an aggregate Int is returned; + * no beneficiary id ever leaves the DAO. + */ +class CbacRecapDataSource @Inject constructor( + private val cbacDao: CbacDao, +) { + /** + * @param startMillis inclusive window start + * @param endMillisExclusive exclusive window end + */ + suspend fun countScreeningEvents( + userId: Int, + userName: String, + startMillis: Long, + endMillisExclusive: Long, + ): Int = withContext(Dispatchers.IO) { + cbacDao.countCurrentAshaScreenings( + userId = userId, + userName = userName, + startInclusive = startMillis, + endExclusive = endMillisExclusive, + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/EligibleCoupleRecapDataSource.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/EligibleCoupleRecapDataSource.kt new file mode 100644 index 000000000..82f6a8f29 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/EligibleCoupleRecapDataSource.kt @@ -0,0 +1,25 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.database.room.dao.EcrDao +import javax.inject.Inject + +/** + * Monthly Recap (READ-ONLY) source for the Eligible Couple category: the count of + * DISTINCT couples the current ASHA registered OR tracked for family planning in + * the frozen recap window. Delegates to [EcrDao.countCurrentAshaEligibleCouples] + * off the main thread. Returns an aggregate only — no beneficiary id/detail leaves + * the DAO. + */ +class EligibleCoupleRecapDataSource @Inject constructor( + private val ecrDao: EcrDao, +) { + suspend fun countCouples( + userName: String, + startMillis: Long, + endMillisExclusive: Long, + ): Int = withContext(Dispatchers.IO) { + ecrDao.countCurrentAshaEligibleCouples(userName, startMillis, endMillisExclusive) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/HouseholdRecapDataSource.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/HouseholdRecapDataSource.kt new file mode 100644 index 000000000..fa1a5124e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/HouseholdRecapDataSource.kt @@ -0,0 +1,24 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.database.room.dao.HouseholdDao +import javax.inject.Inject + +/** + * Monthly Recap (READ-ONLY) source for the Household category: the count of + * households the current ASHA registered in the frozen recap window. Delegates to + * [HouseholdDao.countCurrentAshaRegistrations] off the main thread. Returns an + * aggregate only — no household id/detail leaves the DAO. + */ +class HouseholdRecapDataSource @Inject constructor( + private val householdDao: HouseholdDao, +) { + suspend fun countRegistrations( + userId: Int, + startMillis: Long, + endMillisExclusive: Long, + ): Int = withContext(Dispatchers.IO) { + householdDao.countCurrentAshaRegistrations(userId, startMillis, endMillisExclusive) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/ImmunizationRecapDataSource.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/ImmunizationRecapDataSource.kt new file mode 100644 index 000000000..ba51d95da --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/ImmunizationRecapDataSource.kt @@ -0,0 +1,31 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.database.room.dao.ImmunizationDao +import javax.inject.Inject + +/** + * Read-only Monthly Recap data source for Immunization. Returns the count of vaccine + * DOSES the current ASHA administered in the window (child + mother alike). Ownership + * rests on the ASHA's own `createdBy` (the IMMUNIZATION table has no `ashaId`); the + * row semantics — the `createdBy` match and the `[start, end)` window over the + * user-entered vaccination `date` — live in the DAO SQL + * ([ImmunizationDao.countCurrentAshaDosesAdministered]). The composite PK makes the + * count re-sync-proof. + */ +class ImmunizationRecapDataSource @Inject constructor( + private val immunizationDao: ImmunizationDao, +) { + suspend fun countDosesAdministered( + userName: String, + startMillis: Long, + endMillisExclusive: Long, + ): Int = withContext(Dispatchers.IO) { + immunizationDao.countCurrentAshaDosesAdministered( + userName = userName, + startInclusive = startMillis, + endExclusive = endMillisExclusive, + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/MaternalHealthRecapDataSource.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/MaternalHealthRecapDataSource.kt new file mode 100644 index 000000000..10355a35f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/MaternalHealthRecapDataSource.kt @@ -0,0 +1,32 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.database.room.dao.MaternalHealthDao +import javax.inject.Inject + +/** + * Read-only Monthly Recap data source for Maternal Health. Returns the count of + * DISTINCT mothers / pregnant women the current ASHA supported in the window via + * ANY maternal activity (pregnancy registration, ANC, PMSMA, delivery outcome, PNC). + * + * Ownership rests on the ASHA's own `createdBy` (these tables carry no `ashaId`); + * the row semantics — the `createdBy` match, the `[start, end)` windows over each + * table's activity date, and the `UNION`/`DISTINCT benId` dedup — live in the DAO + * SQL ([MaternalHealthDao.countCurrentAshaMothersSupported]). + */ +class MaternalHealthRecapDataSource @Inject constructor( + private val maternalHealthDao: MaternalHealthDao, +) { + suspend fun countMothersSupported( + userName: String, + startMillis: Long, + endMillisExclusive: Long, + ): Int = withContext(Dispatchers.IO) { + maternalHealthDao.countCurrentAshaMothersSupported( + userName = userName, + startInclusive = startMillis, + endExclusive = endMillisExclusive, + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/MonthlyRecapMetricsCalculator.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/MonthlyRecapMetricsCalculator.kt new file mode 100644 index 000000000..6d2793cf2 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/MonthlyRecapMetricsCalculator.kt @@ -0,0 +1,168 @@ +package org.piramalswasthya.sakhi.repositories + +import org.piramalswasthya.sakhi.model.MonthlyRecapMetricsContract +import org.piramalswasthya.sakhi.model.MonthlyRecapMetricsPayload +import org.piramalswasthya.sakhi.model.RecapActivityMetric +import org.piramalswasthya.sakhi.model.RecapCategoryMetric +import org.piramalswasthya.sakhi.model.RecapCountingUnit +import org.piramalswasthya.sakhi.model.RecapMetricStatus +import javax.inject.Inject + +/** + * Builds the privacy-safe aggregate metrics payload for one recap month from + * verified, ASHA-owned local activity sources. + * + * Shape (Phase 6): produces a list of CATEGORIES. Each verified activity is a + * [RecapActivityMetric] rolled into its [RecapCategoryMetric] via + * [RecapCategoryMetric.from] (which computes the total + AVAILABLE/UNAVAILABLE + * status). Only categories with at least one verified activity are emitted; + * deferred categories (TB, HRP, HBNC, Child-reg, CUFY, Adolescent, NCD referral, + * General OP) are intentionally absent — never fabricated as 0. + * + * Verified so far: NCD → CBAC screenings; Household → new households registered; + * Beneficiary → new family members registered (drafts excluded); Eligible Couple → + * distinct couples registered OR tracked for FP; Maternal Health → distinct mothers + * supported via any maternal activity; Immunization → vaccine doses administered + * (child + mother). Ownership is `ashaId = :userId` (registrations) / CBAC's + * record-level predicate / `createdBy = :userName` (EC, Maternal Health, Immunization); + * see the DAO docs. More confirmed categories are added by counting them and + * appending their [RecapCategoryMetric]. + */ +class MonthlyRecapMetricsCalculator @Inject constructor( + private val cbacRecapDataSource: CbacRecapDataSource, + private val householdRecapDataSource: HouseholdRecapDataSource, + private val beneficiaryRecapDataSource: BeneficiaryRecapDataSource, + private val eligibleCoupleRecapDataSource: EligibleCoupleRecapDataSource, + private val maternalHealthRecapDataSource: MaternalHealthRecapDataSource, + private val immunizationRecapDataSource: ImmunizationRecapDataSource, +) { + suspend fun calculate( + userId: Int, + userName: String, + recapYearMonth: Int, + windowStartMillis: Long, + windowEndMillisExclusive: Long, + generatedAt: Long, + ): MonthlyRecapMetricsPayload { + val categories = mutableListOf() + + // ---- NCD: CBAC screening events (verified, ASHA-owned) ---- + val cbacScreenings = cbacRecapDataSource.countScreeningEvents( + userId = userId, + userName = userName, + startMillis = windowStartMillis, + endMillisExclusive = windowEndMillisExclusive, + ) + categories += RecapCategoryMetric.from( + categoryId = MonthlyRecapMetricsContract.CATEGORY_NCD, + activities = listOf( + RecapActivityMetric( + activityId = MonthlyRecapMetricsContract.ACTIVITY_CBAC_SCREENINGS, + unit = RecapCountingUnit.EVENT.name, + count = cbacScreenings, + status = RecapMetricStatus.AVAILABLE.name, + ), + ), + ) + + // ---- Household: new households registered ---- + val householdRegistrations = householdRecapDataSource.countRegistrations( + userId = userId, + startMillis = windowStartMillis, + endMillisExclusive = windowEndMillisExclusive, + ) + categories += RecapCategoryMetric.from( + categoryId = MonthlyRecapMetricsContract.CATEGORY_HOUSEHOLD, + activities = listOf( + RecapActivityMetric( + activityId = MonthlyRecapMetricsContract.ACTIVITY_HOUSEHOLD_REGISTRATIONS, + unit = RecapCountingUnit.REGISTRATION.name, + count = householdRegistrations, + status = RecapMetricStatus.AVAILABLE.name, + ), + ), + ) + + // ---- Beneficiary: new family members registered (drafts excluded) ---- + val beneficiaryRegistrations = beneficiaryRecapDataSource.countRegistrations( + userId = userId, + startMillis = windowStartMillis, + endMillisExclusive = windowEndMillisExclusive, + ) + categories += RecapCategoryMetric.from( + categoryId = MonthlyRecapMetricsContract.CATEGORY_BENEFICIARY, + activities = listOf( + RecapActivityMetric( + activityId = MonthlyRecapMetricsContract.ACTIVITY_BENEFICIARY_REGISTRATIONS, + unit = RecapCountingUnit.REGISTRATION.name, + count = beneficiaryRegistrations, + status = RecapMetricStatus.AVAILABLE.name, + ), + ), + ) + + // ---- Eligible Couple: distinct couples registered OR tracked for FP ---- + val eligibleCouples = eligibleCoupleRecapDataSource.countCouples( + userName = userName, + startMillis = windowStartMillis, + endMillisExclusive = windowEndMillisExclusive, + ) + categories += RecapCategoryMetric.from( + categoryId = MonthlyRecapMetricsContract.CATEGORY_ELIGIBLE_COUPLE, + activities = listOf( + RecapActivityMetric( + activityId = MonthlyRecapMetricsContract.ACTIVITY_ELIGIBLE_COUPLE_FP, + unit = RecapCountingUnit.UNIQUE_BENEFICIARY.name, + count = eligibleCouples, + status = RecapMetricStatus.AVAILABLE.name, + ), + ), + ) + + // ---- Maternal Health: distinct mothers supported via any maternal activity ---- + val mothersSupported = maternalHealthRecapDataSource.countMothersSupported( + userName = userName, + startMillis = windowStartMillis, + endMillisExclusive = windowEndMillisExclusive, + ) + categories += RecapCategoryMetric.from( + categoryId = MonthlyRecapMetricsContract.CATEGORY_MATERNAL_HEALTH, + activities = listOf( + RecapActivityMetric( + activityId = MonthlyRecapMetricsContract.ACTIVITY_MATERNAL_HEALTH_MOTHERS, + unit = RecapCountingUnit.UNIQUE_BENEFICIARY.name, + count = mothersSupported, + status = RecapMetricStatus.AVAILABLE.name, + ), + ), + ) + + // ---- Immunization: vaccine doses administered (child + mother) ---- + val dosesAdministered = immunizationRecapDataSource.countDosesAdministered( + userName = userName, + startMillis = windowStartMillis, + endMillisExclusive = windowEndMillisExclusive, + ) + categories += RecapCategoryMetric.from( + categoryId = MonthlyRecapMetricsContract.CATEGORY_IMMUNIZATION, + activities = listOf( + RecapActivityMetric( + activityId = MonthlyRecapMetricsContract.ACTIVITY_IMMUNIZATION_DOSES, + unit = RecapCountingUnit.DOSE.name, + count = dosesAdministered, + status = RecapMetricStatus.AVAILABLE.name, + ), + ), + ) + + return MonthlyRecapMetricsPayload( + payloadSchemaVersion = MonthlyRecapMetricsContract.PAYLOAD_SCHEMA_VERSION, + calculationVersion = MonthlyRecapMetricsContract.CALCULATION_VERSION, + recapYearMonth = recapYearMonth, + generatedAt = generatedAt, + windowStartMillis = windowStartMillis, + windowEndMillisExclusive = windowEndMillisExclusive, + categories = categories, + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/MonthlyRecapRepo.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/MonthlyRecapRepo.kt new file mode 100644 index 000000000..dfc767a01 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/MonthlyRecapRepo.kt @@ -0,0 +1,307 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.piramalswasthya.sakhi.BuildConfig +import org.piramalswasthya.sakhi.database.room.dao.MonthlyRecapDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.helpers.MonthlyRecapMetricsCodec +import org.piramalswasthya.sakhi.helpers.RecapClock +import org.piramalswasthya.sakhi.helpers.RecapDataReadiness +import org.piramalswasthya.sakhi.helpers.RecapScene +import org.piramalswasthya.sakhi.helpers.RecapSceneComposer +import org.piramalswasthya.sakhi.helpers.previousMonthWindow +import org.piramalswasthya.sakhi.helpers.recapFreezeBlocker +import org.piramalswasthya.sakhi.model.RecapContentLibrary +import org.piramalswasthya.sakhi.model.MonthlyRecapCache +import org.piramalswasthya.sakhi.model.MonthlyRecapMetricsContract +import org.piramalswasthya.sakhi.model.MonthlyRecapMetricsPayload +import org.piramalswasthya.sakhi.model.RecapDiagnostics +import org.piramalswasthya.sakhi.model.RecapFreezeBlocker +import org.piramalswasthya.sakhi.model.RecapMetricStatus +import org.piramalswasthya.sakhi.model.RecapStatus +import org.piramalswasthya.sakhi.model.recapStatus +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.random.Random + +/** + * Local, offline-first boundary for Monthly Recap snapshots. + * + * Identity: one snapshot per (logged-in ASHA, immediately previous month). + * getOrCreate is idempotent — the DAO's insert-IGNORE plus the unique DB index + * guarantee at most one row even under rapid taps, rotation, process recreation + * or concurrent coroutines; a lost insert race re-reads the winner. + * + * Phase 3 deliberately leaves metricsJson NULL (real on-device calculation is + * Phase 4) and never fabricates activity counts. + */ +@Singleton +class MonthlyRecapRepo @Inject constructor( + private val recapDao: MonthlyRecapDao, + private val preferenceDao: PreferenceDao, + private val clock: RecapClock, + private val metricsCalculator: MonthlyRecapMetricsCalculator, +) { + + /** Serializes metric generation so one payload is frozen per user+month. */ + private val metricsMutex = Mutex() + + private suspend fun loggedInUserId(): Int? = withContext(Dispatchers.IO) { + preferenceDao.getLoggedInUser()?.userId + } + + private suspend fun loggedInUserName(): String? = withContext(Dispatchers.IO) { + preferenceDao.getLoggedInUser()?.userName + } + + /** + * Returns the existing snapshot for (current user, previous month) or creates + * one stable foundation snapshot. Returns null only when no user is logged in. + */ + suspend fun getOrCreateCurrentRecap(): MonthlyRecapCache? { + val userId = loggedInUserId() ?: return null + val window = previousMonthWindow(clock.now()) + + recapDao.get(userId, window.yearMonth)?.let { return it } + + val now = clock.now().timeInMillis + recapDao.insert( + MonthlyRecapCache( + userId = userId, + recapYearMonth = window.yearMonth, + windowStartMillis = window.startMillis, + windowEndMillis = window.endMillisExclusive, + variantSeed = Random.nextLong(), // generated once; stable thereafter + createdAt = now, + updatedAt = now, + ) + ) // IGNORE on conflict: if another call won the race, read the winner below. + return recapDao.get(userId, window.yearMonth) + } + + /** Observes the current user's previous-month snapshot (null when absent). */ + fun observeCurrentRecap(): Flow = flow { + val userId = loggedInUserId() + if (userId == null) { + emit(null) + } else { + emitAll(recapDao.observe(userId, previousMonthWindow(clock.now()).yearMonth)) + } + }.flowOn(Dispatchers.IO) + + /** + * The language the recap is narrated in: whatever the ASHA already uses the app + * in. There is no separate recap language choice — she is never asked. + * + * The app supports more languages than the recap has content for (English is the + * app default, and Bangla exists too, while the content file carries only Hindi + * and Assamese). Unmatched languages fall back to Hindi inside + * [RecapSceneComposer], so this never returns a story-less recap on its own. + */ + private suspend fun appLanguageToken(): String = withContext(Dispatchers.IO) { + preferenceDao.getCurrentLanguage().symbol + } + + /** Playback foundation: NOT_STARTED → IN_PROGRESS (completed rows unaffected). */ + suspend fun markStarted() { + val recap = getOrCreateCurrentRecap() ?: return + recapDao.markStarted(recap.userId, recap.recapYearMonth, clock.now().timeInMillis) + } + + /** + * Phase 7: called when playback opens with a composed story — marks the + * snapshot started AND records the story length so resume clamping works + * against the real scene count. + */ + suspend fun onPlaybackOpened(totalScenes: Int) { + val recap = getOrCreateCurrentRecap() ?: return + val now = clock.now().timeInMillis + recapDao.markStarted(recap.userId, recap.recapYearMonth, now) + if (totalScenes > 0 && recap.totalScenes != totalScenes) { + recapDao.setTotalScenes(recap.userId, recap.recapYearMonth, totalScenes, now) + } + } + + /** + * Stores the CURRENT scene index (clamped) — the scene being shown, i.e. the + * exact resume target. Reopening an IN_PROGRESS recap starts AT this scene. + */ + suspend fun updateSafeProgress(sceneIndex: Int) { + val userId = loggedInUserId() ?: return + val window = previousMonthWindow(clock.now()) + val recap = recapDao.get(userId, window.yearMonth) ?: return + if (recap.recapStatus() != RecapStatus.IN_PROGRESS) return + // Scene INDICES run 0..totalScenes-1, so the last valid index is one less + // than the count. Clamping to totalScenes itself would let a stored progress + // point one past the end of the story, and reopening would resume nowhere. + val upper = recap.totalScenes?.takeIf { it > 0 }?.let { it - 1 } ?: Int.MAX_VALUE + val safe = sceneIndex.coerceIn(0, upper) + recapDao.updateProgress(userId, window.yearMonth, safe, clock.now().timeInMillis) + } + + /** Playback foundation: marks the snapshot completed (drives REPLAY). */ + suspend fun markCompleted() { + val userId = loggedInUserId() ?: return + val window = previousMonthWindow(clock.now()) + recapDao.markCompleted(userId, window.yearMonth, clock.now().timeInMillis) + } + + /** + * Ensures the current recap snapshot carries a frozen, privacy-safe metrics + * payload, calculating it once from verified local activity data (Phase 4.3: + * CBAC screening events) and reusing it thereafter. + * + * Freeze/stability guarantees: + * - the first valid, current-version payload wins and is never recalculated; + * - later local edits/sync do NOT change an already-frozen recap; + * - it uses the window frozen on the snapshot, so it is stable across the days + * 1–7 the recap is available; + * - concurrent callers are serialized by [metricsMutex] and a re-read inside + * the lock, so exactly one payload is stored and every caller returns it; + * - a corrupt or unsupported stored payload is treated as absent and + * regenerated deterministically (never crashes, never silently reinterpreted); + * - it freezes ONLY when this install's local data can be trusted for the month + * ([recapFreezeBlocker]) — otherwise the month is hidden and re-evaluated on the + * next open, so a re-installed or mid-month device can never permanently lock in + * a number lower than the ASHA's real work. An ALREADY-frozen payload is returned + * as-is and never re-gated: it passed readiness when it was written. + * + * Does NOT change status/progress (metric generation is not playback), the + * language, the variant seed or the snapshot identity. Intended to be called + * at recap generation/entry time (e.g. by Phase 6 playback) on a background + * dispatcher — the DAO suspend calls run off the main thread. Returns null + * only when no user is logged in. + */ + suspend fun ensureCurrentRecapMetrics(): MonthlyRecapMetricsPayload? { + val userName = loggedInUserName() ?: return null + val recap = getOrCreateCurrentRecap() ?: return null + // Fast path: a valid, current-version payload is already frozen. + decodeIfCurrent(recap.metricsJson, recap.recapYearMonth)?.let { return it } + + return metricsMutex.withLock { + // In-process optimisation: re-read inside the lock so a concurrent + // caller on this instance doesn't recompute. + val fresh = recapDao.get(recap.userId, recap.recapYearMonth) + decodeIfCurrent(fresh?.metricsJson, recap.recapYearMonth)?.let { return@withLock it } + + val now = clock.now().timeInMillis + val raw = metricsCalculator.calculate( + userId = recap.userId, + userName = userName, + recapYearMonth = recap.recapYearMonth, + windowStartMillis = recap.windowStartMillis, + windowEndMillisExclusive = recap.windowEndMillis, + generatedAt = now, + ) + val readiness = withContext(Dispatchers.IO) { + RecapDataReadiness( + isFullPullComplete = preferenceDao.isFullPullComplete, + localDataSince = preferenceDao.recapLocalDataSince(now), + ) + } + // ONE freeze decision, covering both the long-standing zero-month rule + // and local-data readiness. See [recapFreezeBlocker] for the reasoning. + val blocker = recapFreezeBlocker( + readiness = readiness, + windowStartMillis = recap.windowStartMillis, + hasCountableWork = raw.categories.any { + it.status == RecapMetricStatus.AVAILABLE.name && it.categoryTotal > 0 + }, + ) + val payload = raw.copy( + diagnostics = RecapDiagnostics( + freezeBlocker = blocker.name, + isFullPullComplete = readiness.isFullPullComplete, + localDataSince = readiness.localDataSince, + evaluatedAt = now, + appVersionCode = BuildConfig.VERSION_CODE, + ) + ) + when (blocker) { + // ZERO-MONTH GUARD (unchanged): an all-zero payload is returned but + // NOT frozen. A first dashboard visit can run before the server pull + // lands the previous month's records; freezing zeros then would hide + // the recap for the whole month. Zero payloads show nothing anyway + // (empty-month rule), so deferring the freeze until real work is + // visible is safe — and the story is still always frozen BEFORE the + // ASHA ever sees it. + RecapFreezeBlocker.NO_COUNTABLE_WORK -> return@withLock payload + + // NOT READY: counts exist but the local database cannot be trusted to + // be complete for this month. Freezing would permanently under-credit + // her work, so the month is hidden (empty categories => the recap does + // not open) and re-evaluated on her next visit inside the 1-7 window. + RecapFreezeBlocker.FULL_PULL_INCOMPLETE, + RecapFreezeBlocker.DEVICE_MISSED_PART_OF_MONTH -> { + Timber.i( + "Monthly Recap: not freezing %d — %s", + recap.recapYearMonth, + blocker.name, + ) + return@withLock payload.copy(categories = emptyList()) + } + + RecapFreezeBlocker.NONE -> Unit // fall through and freeze + } + // Durable freeze boundary: only writes when metricsJson IS NULL. + val updated = recapDao.setMetricsIfAbsent( + recap.userId, + recap.recapYearMonth, + MonthlyRecapMetricsCodec.encode(payload), + now, + ) + if (updated > 0) { + payload + } else { + // Another writer (e.g. a different process/instance) froze it first; + // return the stored winner rather than our unpersisted copy. + decodeIfCurrent( + recapDao.get(recap.userId, recap.recapYearMonth)?.metricsJson, + recap.recapYearMonth, + ) ?: payload + } + } + } + + /** Decodes stored metrics, accepting them only when current-version and same-month. */ + private fun decodeIfCurrent(json: String?, yearMonth: Int): MonthlyRecapMetricsPayload? { + val payload = MonthlyRecapMetricsCodec.decodeOrNull(json) ?: return null + return payload.takeIf { + it.calculationVersion == MonthlyRecapMetricsContract.CALCULATION_VERSION && + it.recapYearMonth == yearMonth + } + } + + /** + * Phase 7 — builds the personalised playback story for the current snapshot: + * ensures the frozen metrics exist, then composes scenes deterministically + * from (frozen payload, the ASHA's app language, frozen variantSeed) via + * [RecapSceneComposer]. Same snapshot → same story, replay-stable, offline. + * + * The language is read live from the app ([appLanguageToken]) rather than frozen, + * so switching the app language re-narrates the recap. That is safe: the variant + * seed is language-independent, so she keeps the same semantic sentences, and the + * frozen counts are untouched. + * + * Returns an EMPTY list when the month has no countable work (user decision: + * the recap does not open at all) and null when no user is logged in or the + * content library is unusable. Read-only: does not touch status/progress. + */ + suspend fun buildPersonalizedScenes(library: RecapContentLibrary): List? { + val payload = ensureCurrentRecapMetrics() ?: return null + val recap = getOrCreateCurrentRecap() ?: return null + return RecapSceneComposer(library).compose( + payload = payload, + languageToken = appLanguageToken(), + variantSeed = recap.variantSeed, + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/HomeActivity.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/HomeActivity.kt index af0163bd8..298feb022 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/HomeActivity.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/HomeActivity.kt @@ -14,8 +14,12 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.MotionEvent +import android.app.Dialog +import android.graphics.drawable.ColorDrawable import android.view.View +import android.view.Window import android.view.WindowManager +import android.view.animation.OvershootInterpolator import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebResourceError @@ -56,9 +60,16 @@ import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.piramalswasthya.sakhi.BuildConfig import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.badges.domain.BadgeCelebrations +import org.piramalswasthya.sakhi.badges.domain.BadgeDefinitions +import org.piramalswasthya.sakhi.badges.domain.TaskCompletionBus +import org.piramalswasthya.sakhi.databinding.DialogBadgeCelebrationBinding +import org.piramalswasthya.sakhi.notifications.NotificationEngine +import org.piramalswasthya.sakhi.notifications.NotificationScheduler import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao import org.piramalswasthya.sakhi.helpers.AccountDeactivationManager import org.piramalswasthya.sakhi.helpers.TokenExpiryManager @@ -89,6 +100,8 @@ import java.util.Locale import javax.inject.Inject +private const val CELEBRATION_MS = 3_500L + @AndroidEntryPoint class HomeActivity : AppCompatActivity(), MessageUpdate { @@ -125,6 +138,15 @@ class HomeActivity : AppCompatActivity(), MessageUpdate { @Inject lateinit var tokenExpiryManager: TokenExpiryManager + @Inject + lateinit var taskCompletionBus: TaskCompletionBus + + @Inject + lateinit var eveningNotificationScheduler: NotificationScheduler + + @Inject + lateinit var badgeCelebrations: BadgeCelebrations + private var _binding: ActivityHomeBinding? = null private val binding: ActivityHomeBinding @@ -316,6 +338,10 @@ class HomeActivity : AppCompatActivity(), MessageUpdate { askForPermissions() + eveningNotificationScheduler.schedule() + routeToJourneyIfRequested(intent) + observeBadgeCelebrations() + if (isChatSupportEnabled) { binding.addFab.visibility = View.VISIBLE @@ -656,8 +682,79 @@ class HomeActivity : AppCompatActivity(), MessageUpdate { invalidateOptionsMenu() } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + routeToJourneyIfRequested(intent) + } + + /** Game-style centre-screen celebration when a badge milestone is earned. */ + private fun observeBadgeCelebrations() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + badgeCelebrations.awards.collect { (badgeId, level) -> + showBadgeCelebration(badgeId, level) + } + } + } + } + + private suspend fun showBadgeCelebration(badgeId: String, level: Int) { + val def = BadgeDefinitions.byId(badgeId) ?: return + val cb = DialogBadgeCelebrationBinding.inflate(layoutInflater) + cb.ivCelebrationBadge.setImageResource( + def.tierIcons.getOrNull(level - 1) ?: def.iconRes + ) + cb.tvCelebrationBadgeName.text = getString(def.titleRes) + if (level > 1) { + cb.tvCelebrationLevel.visibility = View.VISIBLE + cb.tvCelebrationLevel.text = getString(R.string.badge_level, level) + } + val dialog = Dialog(this).apply { + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(cb.root) + window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) + window?.setDimAmount(0.6f) + setCancelable(true) + } + try { + dialog.show() + // pop-in with overshoot, like a game reward + cb.cvCelebration.scaleX = 0.5f + cb.cvCelebration.scaleY = 0.5f + cb.cvCelebration.alpha = 0f + cb.cvCelebration.animate().scaleX(1f).scaleY(1f).alpha(1f) + .setDuration(400) + .setInterpolator(OvershootInterpolator()) + .start() + cb.confettiCelebration.burst() + delay(CELEBRATION_MS) + } catch (e: Exception) { + Timber.w(e, "Badge celebration overlay failed") + } finally { + if (dialog.isShowing) dialog.dismiss() + } + } + + /** Evening notification tap → Journey screen (Notification LLD §1). */ + private fun routeToJourneyIfRequested(intent: Intent?) { + if (intent?.getBooleanExtra(NotificationEngine.EXTRA_OPEN_JOURNEY, false) == true) { + intent.removeExtra(NotificationEngine.EXTRA_OPEN_JOURNEY) + // post: nav back-stack restore runs after onCreate and would wipe + // an immediate navigate; after first layout the graph is settled + binding.root.post { + try { + navController.navigate(R.id.journeyFragment) + } catch (e: Exception) { + Timber.e(e, "Journey navigation from notification failed") + } + } + } + } + private fun setUpFirstTimePullWorker() { WorkerUtils.triggerPeriodicPncEcUpdateWorker(this) + WorkerUtils.triggerBadgeWorkers(this) + taskCompletionBus.start() // WorkerUtils.triggerMaaMeetingWorker(this) // WorkerUtils.triggerSaasBahuSammelanWorker(this) if (!pref.isFullPullComplete) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/badges/BadgeShelfFragment.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/badges/BadgeShelfFragment.kt new file mode 100644 index 000000000..cf71075c5 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/badges/BadgeShelfFragment.kt @@ -0,0 +1,57 @@ +package org.piramalswasthya.sakhi.ui.home_activity.badges + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.adapters.BadgeShelfAdapter +import org.piramalswasthya.sakhi.databinding.FragmentBadgeShelfBinding +import org.piramalswasthya.sakhi.ui.home_activity.HomeActivity + +/** + * Earned-badge shelf (LLD §1.2): live progress and earned levels per badge, + * computed entirely on-device from BADGE_STATE / BADGE_EARNED. + */ +@AndroidEntryPoint +class BadgeShelfFragment : Fragment() { + + private var _binding: FragmentBadgeShelfBinding? = null + private val binding: FragmentBadgeShelfBinding + get() = _binding!! + + private val viewModel: BadgeShelfViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentBadgeShelfBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val adapter = BadgeShelfAdapter() + binding.rvBadgeShelf.adapter = adapter + viewModel.cards.observe(viewLifecycleOwner) { adapter.submitList(it) } + } + + override fun onStart() { + super.onStart() + activity?.let { + (it as HomeActivity).updateActionBar( + R.drawable.badge_steady_syncer_t1, + getString(R.string.badge_shelf_title) + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/badges/BadgeShelfViewModel.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/badges/BadgeShelfViewModel.kt new file mode 100644 index 000000000..4648c082f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/badges/BadgeShelfViewModel.kt @@ -0,0 +1,17 @@ +package org.piramalswasthya.sakhi.ui.home_activity.badges + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import org.piramalswasthya.sakhi.badges.BadgeRepository +import javax.inject.Inject + +@HiltViewModel +class BadgeShelfViewModel @Inject constructor( + badgeRepository: BadgeRepository +) : ViewModel() { + + val cards: LiveData> = + badgeRepository.shelf.asLiveData() +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/HomeIconsFragment.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/HomeIconsFragment.kt index 48afc650a..fa7b28fdf 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/HomeIconsFragment.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/HomeIconsFragment.kt @@ -1,34 +1,69 @@ package org.piramalswasthya.sakhi.ui.home_activity.home +import android.content.res.Configuration +import android.content.res.Resources import android.os.Bundle +import android.os.SystemClock +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.piramalswasthya.sakhi.BuildConfig import org.piramalswasthya.sakhi.R import org.piramalswasthya.sakhi.adapters.IconGridAdapter +import org.piramalswasthya.sakhi.badges.BadgeRepository +import org.piramalswasthya.sakhi.badges.domain.BadgeDefinitions import org.piramalswasthya.sakhi.configuration.IconDataset +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao import org.piramalswasthya.sakhi.databinding.RvIconGridBinding import timber.log.Timber +import java.util.Locale import javax.inject.Inject +/** Small, generic playback position shown in RESUME. Not health/category data. */ +private const val GENERIC_RESUME_PROGRESS = 40 + +/** Debounce window so rapid taps on the strip produce a single callback. */ +private const val RECAP_CLICK_DEBOUNCE_MS = 600L + +/** Debug preview token meaning "derive the state from local recap data". */ +private const val MONTHLY_RECAP_PREVIEW_AUTO = "auto" + @AndroidEntryPoint class HomeIconsFragment : Fragment() { @Inject lateinit var iconDataset: IconDataset + @Inject + lateinit var badgeRepository: BadgeRepository + + @Inject + lateinit var pref: PreferenceDao + private var _binding: RvIconGridBinding? = null private val binding: RvIconGridBinding get() = _binding!! private val viewModel: HomeViewModel by viewModels({ requireActivity() }) + private val recapStripViewModel: MonthlyRecapStripViewModel by viewModels() + + /** Guards the recap entrance so it plays at most once per view lifecycle. */ + private var recapEntrancePlayed = false + + /** Timestamp of the last accepted strip click, for debouncing. */ + private var lastRecapClickMs = 0L override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -36,13 +71,267 @@ class HomeIconsFragment : Fragment() { ): View { // Inflate the layout for this fragment _binding = RvIconGridBinding.inflate(layoutInflater, container, false) + recapEntrancePlayed = false return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpHomeIconRvAdapter() + setUpBadgeWidget() + setUpMonthlyRecapStrip() + } + + /** + * Live badge progress widget (LLD: never reads zero). Shows the badge + * closest to its next milestone; hidden entirely by the remote kill switch. + */ + private fun setUpBadgeWidget() { + binding.cvBadgeWidget.setOnClickListener { + try { + findNavController().navigate(R.id.action_homeFragment_to_badgeShelfFragment) + } catch (e: Exception) { + Timber.e(e, "Badge shelf navigation failed") + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + badgeRepository.shelf.collect { cards -> + val b = _binding ?: return@collect + if (cards.isEmpty()) { // feature disabled remotely + b.cvBadgeWidget.visibility = View.GONE + return@collect + } + b.cvBadgeWidget.visibility = View.VISIBLE + val top = cards.maxByOrNull { card -> + val target = card.state?.nextTarget?.coerceAtLeast(1) ?: 1 + (card.state?.progress ?: 0).toDouble() / target + } + val target = top?.state?.nextTarget?.coerceAtLeast(1) ?: 1 + // maxed badges keep counting (streak weeks, cases) — never show "9 of 8" + val progress = (top?.state?.progress ?: 0).coerceAtMost(target) + if (top == null || progress <= 0) { + b.ivBadgeWidgetIcon.setImageResource(R.drawable.badge_steady_syncer_t1) + b.tvBadgeWidgetText.text = + getString(R.string.badge_widget_get_started) + b.pbBadgeWidget.max = 1 + b.pbBadgeWidget.progress = 0 + } else { + val (iconRes, earnedLook) = + BadgeDefinitions.displayIcon(top.definition, top.state) + b.ivBadgeWidgetIcon.setImageResource(iconRes) + b.ivBadgeWidgetIcon.alpha = if (earnedLook) 1f else 0.85f + b.tvBadgeWidgetText.text = getString( + R.string.badge_widget_progress, + getString(top.definition.titleRes), progress, target + ) + b.pbBadgeWidget.max = target.toInt() + b.pbBadgeWidget.progress = progress.coerceAtMost(target).toInt() + } + } + } + } + } + + + /** + * Strip state now derives from local recap persistence via + * [MonthlyRecapStripViewModel]: availability (fail-closed; release stays + * unavailable until real rollout integration) + snapshot status + * (none/NOT_STARTED -> READY, IN_PROGRESS -> RESUME, COMPLETED -> REPLAY). + * + * Debug builds keep a forced-preview override: any token other than "auto" + * in monthly_recap_debug.xml renders that state directly for visual testing. + */ + private fun setUpMonthlyRecapStrip() { + if (BuildConfig.DEBUG) { + val token = getString(R.string.monthly_recap_preview_state) + .trim().lowercase(Locale.ROOT) + if (token != MONTHLY_RECAP_PREVIEW_AUTO) { + renderMonthlyRecapStrip(MonthlyRecapStripState.fromToken(token)) + return + } + } + recapStripViewModel.stripState.observe(viewLifecycleOwner) { state -> + renderMonthlyRecapStrip(state) + } + } + + /** + * Re-evaluates recap visibility each time the dashboard becomes visible: a + * background sync may have landed the previous month's records after the + * first (hidden) check, and the strip must not stay stale until restart. + */ + override fun onResume() { + super.onResume() + recapStripViewModel.refresh() + } + + private fun renderMonthlyRecapStrip(state: MonthlyRecapStripState) { + val strip = binding.monthlyRecapStrip + val card = strip.root + val style = styleFor(state) + + if (!style.visible) { + // HIDDEN: no view, no click target, no accessibility focus, no animation. + card.animate().cancel() + card.setOnClickListener(null) + card.isClickable = false + card.isFocusable = false + card.visibility = View.GONE + return + } + + // Resolve strip text through the app's CURRENT language explicitly. The + // ambient context is not reliably localised on newer APIs, so — mirroring + // the app's own pattern (e.g. HRPPregnantTrackViewModel) — we build a + // Resources for the selected language. This makes the strip title, message, + // CTA, badges, content-description and month name follow the app language. + val locale = Locale(pref.getCurrentLanguage().symbol) + val res = localizedResources(locale) + + val monthLabel = MonthlyRecapMonth.previousMonthLabel(locale) + strip.recapMonthLabel.text = monthLabel + strip.recapMonthLabel.visibility = if (monthLabel.isBlank()) View.GONE else View.VISIBLE + strip.recapTitle.text = res.getString(R.string.monthly_recap_title) + strip.recapMessage.text = res.getString(style.messageRes) + strip.recapCta.text = res.getString(style.ctaRes) + strip.recapBadgeNew.text = res.getString(R.string.monthly_recap_badge_new) + strip.recapBadgeDone.text = res.getString(R.string.monthly_recap_badge_done) + + strip.recapBadgeNew.visibility = if (style.showNewBadge) View.VISIBLE else View.GONE + strip.recapBadgeDone.visibility = if (style.showCompletedBadge) View.VISIBLE else View.GONE + + if (style.showProgress) { + // Generic playback position only — never category/health information. + strip.recapProgress.progress = GENERIC_RESUME_PROGRESS + strip.recapProgress.visibility = View.VISIBLE + } else { + strip.recapProgress.visibility = View.GONE + } + + card.contentDescription = res.getString(style.contentDescriptionRes) + card.isClickable = true + card.isFocusable = true + card.visibility = View.VISIBLE + card.setOnClickListener { onRecapStripClicked(state) } + + setUpRecapCharacterAnimation() + playRecapEntrance(state, style) + } + + /** + * The card character is a looping Lottie of the ASHA-didi — the same character + * who narrates the recap, so the card and the story feel like one experience. + * Reduced-motion devices get a single static frame and never play. + */ + private fun setUpRecapCharacterAnimation() { + val character = _binding?.monthlyRecapStrip?.recapCharacter ?: return + if (recapAnimationsEnabled(requireContext())) { + character.playAnimation() + } else { + character.progress = 0f + } + } + + private fun onRecapStripClicked(state: MonthlyRecapStripState) { + val now = SystemClock.elapsedRealtime() + if (now - lastRecapClickMs < RECAP_CLICK_DEBOUNCE_MS) return + lastRecapClickMs = now + when (state) { + // Every visible state opens playback directly. The recap is narrated in + // the language the ASHA already uses the app in, so there is nothing to + // ask her first. The ViewModel resumes an IN_PROGRESS story at its stored + // scene; READY and REPLAY start at scene 0. + MonthlyRecapStripState.READY, + MonthlyRecapStripState.RESUME, + MonthlyRecapStripState.REPLAY -> navigateToRecapPlayback() + + MonthlyRecapStripState.HIDDEN -> Unit + } + } + + /** Opens the recap playback screen exactly once per accepted tap. */ + private fun navigateToRecapPlayback() { + navigateFromHome { it.navigate(HomeFragmentDirections.actionNavHomeToMonthlyRecapPlaybackFragment()) } + } + + private inline fun navigateFromHome(navigate: (androidx.navigation.NavController) -> Unit) { + val navController = findNavController() + try { + // Only navigate from home; prevents duplicate destinations on stale taps. + if (navController.currentDestination?.id == R.id.homeFragment) navigate(navController) + } catch (e: Exception) { + Timber.e(e, "Monthly Recap navigation failed") + } + } + + /** + * Warm one-shot entrance for the character, tied to this view's lifecycle and + * played at most once. READY gets the full slide + settle + sparkles; RESUME a + * gentler slide; REPLAY stays static. When animators are disabled the layout is + * already in its final resting state, so nothing needs to run. + */ + private fun playRecapEntrance(state: MonthlyRecapStripState, style: MonthlyRecapStripStyle) { + if (recapEntrancePlayed) return + recapEntrancePlayed = true + if (!style.animateEntrance || !recapAnimationsEnabled(requireContext())) return + + val strip = binding.monthlyRecapStrip + val character = strip.recapCharacter + val text = strip.recapTextColumn + val isReady = state == MonthlyRecapStripState.READY + + // She arrives first, walking in from the start edge... + character.translationX = -dp(if (isReady) 56f else 32f) + character.alpha = 0f + character.animate() + .translationX(0f) + .alpha(1f) + .setStartDelay(80L) + .setDuration(if (isReady) 480L else 360L) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { onRecapEntranceSettled(isReady) } + .start() + + // ...and the words settle in just behind her, so the card reads as one + // thing arriving rather than a character bolted onto a static banner. + text.translationY = dp(10f) + text.alpha = 0f + text.animate() + .translationY(0f) + .alpha(1f) + .setStartDelay(200L) + .setDuration(380L) + .setInterpolator(DecelerateInterpolator()) + .start() + } + + private fun onRecapEntranceSettled(isReady: Boolean) { + val strip = _binding?.monthlyRecapStrip ?: return + if (isReady) { + // Restrained celebratory settle. + strip.recapCharacter.animate() + .scaleX(1.05f) + .scaleY(1.05f) + .setDuration(130L) + .withEndAction { + val settled = _binding?.monthlyRecapStrip?.recapCharacter ?: return@withEndAction + settled.animate().scaleX(1f).scaleY(1f).setDuration(130L).start() + } + .start() + } + } + + private fun dp(value: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, resources.displayMetrics) + + /** Resources bound to [locale] so strip text follows the app's selected language. */ + private fun localizedResources(locale: Locale): Resources { + val config = Configuration(requireContext().resources.configuration) + config.setLocale(locale) + return requireContext().createConfigurationContext(config).resources } private fun setUpHomeIconRvAdapter() { @@ -78,4 +367,16 @@ class HomeIconsFragment : Fragment() { } } + + override fun onDestroyView() { + // Cancel any recap entrance animation + stop the Lottie so neither touches + // a destroyed view. + _binding?.monthlyRecapStrip?.recapCharacter?.apply { + animate().cancel() + cancelAnimation() + } + _binding?.monthlyRecapStrip?.recapTextColumn?.animate()?.cancel() + super.onDestroyView() + _binding = null + } } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/MonthlyRecapStrip.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/MonthlyRecapStrip.kt new file mode 100644 index 000000000..ee9d6f024 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/MonthlyRecapStrip.kt @@ -0,0 +1,144 @@ +package org.piramalswasthya.sakhi.ui.home_activity.home + +import android.content.Context +import android.provider.Settings +import androidx.annotation.StringRes +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.model.RecapStatus +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +/** + * Pure mapping from (availability, local snapshot status) to the dashboard strip + * state. Fail-closed: unavailable always wins, regardless of snapshot state. + * + * - unavailable/unknown -> HIDDEN + * - no snapshot / NOT_STARTED -> READY + * - IN_PROGRESS -> RESUME + * - COMPLETED -> REPLAY + */ +fun stripStateFor(available: Boolean, status: RecapStatus?): MonthlyRecapStripState = + when { + !available -> MonthlyRecapStripState.HIDDEN + status == RecapStatus.IN_PROGRESS -> MonthlyRecapStripState.RESUME + status == RecapStatus.COMPLETED -> MonthlyRecapStripState.REPLAY + else -> MonthlyRecapStripState.READY + } + +/** + * True when system animator-duration scale is non-zero. Shared by the strip and + * the recap screens so reduced-motion devices get the static final layout. + */ +fun recapAnimationsEnabled(context: Context): Boolean = + Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f + ) != 0f + +/** + * UI-only, privacy-safe representation of the Monthly Recap dashboard strip. + * + * This deliberately contains NO beneficiary data, medical information, category + * counts, names or database entities — only which visible state the strip is in. + * Business/eligibility data (Room, APIs, snapshots) is a later Phase and must map + * into one of these states before reaching the UI. + */ +enum class MonthlyRecapStripState { + HIDDEN, + READY, + RESUME, + REPLAY; + + companion object { + /** Maps a debug/config token to a state; anything unknown is safely [HIDDEN]. */ + fun fromToken(token: String?): MonthlyRecapStripState = + when (token?.trim()?.lowercase(Locale.ROOT)) { + "ready" -> READY + "resume" -> RESUME + "replay" -> REPLAY + else -> HIDDEN + } + } +} + +/** + * Pure, privacy-safe description of how a [MonthlyRecapStripState] should render. + * + * Holds only string resource ids and presentation flags, so the mapping in + * [styleFor] can be unit-tested without an Android [android.content.Context]. + */ +data class MonthlyRecapStripStyle( + val visible: Boolean, + @StringRes val messageRes: Int, + @StringRes val ctaRes: Int, + @StringRes val contentDescriptionRes: Int, + val showNewBadge: Boolean, + val showCompletedBadge: Boolean, + val showProgress: Boolean, + val animateEntrance: Boolean, +) + +/** Deterministic, privacy-safe mapping from state to presentation. Pure and testable. */ +fun styleFor(state: MonthlyRecapStripState): MonthlyRecapStripStyle = when (state) { + MonthlyRecapStripState.HIDDEN -> MonthlyRecapStripStyle( + visible = false, + messageRes = 0, + ctaRes = 0, + contentDescriptionRes = 0, + showNewBadge = false, + showCompletedBadge = false, + showProgress = false, + animateEntrance = false, + ) + + MonthlyRecapStripState.READY -> MonthlyRecapStripStyle( + visible = true, + messageRes = R.string.monthly_recap_ready_message, + ctaRes = R.string.monthly_recap_view_recap, + contentDescriptionRes = R.string.monthly_recap_cd_ready, + showNewBadge = true, + showCompletedBadge = false, + showProgress = false, + animateEntrance = true, + ) + + MonthlyRecapStripState.RESUME -> MonthlyRecapStripStyle( + visible = true, + messageRes = R.string.monthly_recap_resume_message, + ctaRes = R.string.monthly_recap_continue_recap, + contentDescriptionRes = R.string.monthly_recap_cd_resume, + showNewBadge = false, + showCompletedBadge = false, + showProgress = true, + animateEntrance = true, + ) + + MonthlyRecapStripState.REPLAY -> MonthlyRecapStripStyle( + visible = true, + messageRes = R.string.monthly_recap_replay_message, + ctaRes = R.string.monthly_recap_watch_again, + contentDescriptionRes = R.string.monthly_recap_cd_replay, + showNewBadge = false, + showCompletedBadge = true, + showProgress = false, + animateEntrance = false, + ) +} + +/** + * Derives the localised name of the immediately previous completed calendar month. + * + * [now] is injectable so year rollover (January -> previous December) is unit-testable + * without changing the device clock. This is intentionally a tiny utility, not the + * full RecapClock architecture, which arrives with real recap calculation later. + */ +object MonthlyRecapMonth { + fun previousMonthLabel(locale: Locale, now: Calendar = Calendar.getInstance()): String { + val cal = now.clone() as Calendar + cal.add(Calendar.MONTH, -1) + val label = SimpleDateFormat("MMMM", locale).format(cal.time) + return label.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/MonthlyRecapStripViewModel.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/MonthlyRecapStripViewModel.kt new file mode 100644 index 000000000..7c8093b64 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/MonthlyRecapStripViewModel.kt @@ -0,0 +1,66 @@ +package org.piramalswasthya.sakhi.ui.home_activity.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.piramalswasthya.sakhi.helpers.MonthlyRecapAvailabilityProvider +import org.piramalswasthya.sakhi.helpers.RecapStoryGate +import org.piramalswasthya.sakhi.model.recapStatus +import org.piramalswasthya.sakhi.repositories.MonthlyRecapRepo +import javax.inject.Inject + +/** + * Privacy-safe strip state for the dashboard: HIDDEN / READY / RESUME / REPLAY. + * + * Gate order (all fail-closed): availability (unknown → HIDDEN) → the + * EMPTY-MONTH rule (no countable work → HIDDEN, the recap never opens) → the + * local snapshot status (none/NOT_STARTED → READY, IN_PROGRESS → RESUME, + * COMPLETED → REPLAY). No counts or beneficiary data reach the UI. + * + * The story check is RE-EVALUATED whenever the recap snapshot row changes and + * on every [refresh] (called when Home becomes visible). That matters because a + * background sync can land the previous month's records after the first check: + * without re-evaluation the strip would stay hidden until the process restarted. + * Re-running is cheap — the content library is cached and, once metrics are + * frozen, the check is a single-row read. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class MonthlyRecapStripViewModel @Inject constructor( + private val recapRepo: MonthlyRecapRepo, + private val availability: MonthlyRecapAvailabilityProvider, + private val storyGate: RecapStoryGate, +) : ViewModel() { + + /** Bumped to force a re-check (e.g. Home resumed, sync may have landed rows). */ + private val refreshTrigger = MutableStateFlow(0) + + val stripState: LiveData = + combine(refreshTrigger, recapRepo.observeCurrentRecap()) { _, recap -> recap } + .flatMapLatest { recap -> + flow { + val visible = availability.isAvailable() && storyGate.hasStory() + emit( + stripStateFor( + available = visible, + status = recap?.recapStatus(), + ) + ) + } + } + .asLiveData() + + /** Call when the dashboard becomes visible so a post-sync month appears. */ + fun refresh() { + viewModelScope.launch { refreshTrigger.value = refreshTrigger.value + 1 } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/SchedulerFragment.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/SchedulerFragment.kt index 177ddc3e0..0012eab45 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/SchedulerFragment.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/home/SchedulerFragment.kt @@ -1,6 +1,9 @@ package org.piramalswasthya.sakhi.ui.home_activity.home import android.os.Bundle +import android.widget.Toast +import org.piramalswasthya.sakhi.BuildConfig +import org.piramalswasthya.sakhi.badges.BadgeDemoSeeder import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -9,9 +12,23 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint +import android.view.HapticFeedbackConstants +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.CompositePageTransformer +import androidx.viewpager2.widget.MarginPageTransformer +import androidx.viewpager2.widget.ViewPager2 +import kotlin.math.abs +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.adapters.BadgeCarouselAdapter +import org.piramalswasthya.sakhi.badges.BadgeRepository import org.piramalswasthya.sakhi.databinding.FragmentSchedulerBinding +import timber.log.Timber +import javax.inject.Inject import org.piramalswasthya.sakhi.ui.home_activity.home.SchedulerViewModel.State.LOADED import org.piramalswasthya.sakhi.ui.home_activity.home.SchedulerViewModel.State.LOADING import java.util.Calendar @@ -32,6 +49,12 @@ class SchedulerFragment : Fragment() { private val viewModel: SchedulerViewModel by viewModels({ requireActivity() }) + @Inject + lateinit var badgeRepository: BadgeRepository + + @Inject + lateinit var demoSeeder: BadgeDemoSeeder + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -42,6 +65,7 @@ class SchedulerFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setUpImpactDashboard() viewModel.state.observe(viewLifecycleOwner) { when (it) { LOADING -> { @@ -178,6 +202,119 @@ class SchedulerFragment : Fragment() { } } + /** Set by a drag or an arrow tap, consumed by the next page change — see [setUpImpactDashboard]. */ + private var carouselMovedByUser = false + + /** Impact Dashboard: auto-moving carousel of badge progress. */ + private fun setUpImpactDashboard() { + val carouselAdapter = BadgeCarouselAdapter() + binding.vpImpactBadges.adapter = carouselAdapter + + // The carousel also advances on its own every few seconds. A tick on those is the + // phone buzzing in her pocket unprompted, which reads as a notification rather than + // as feedback, so haptics are armed only by a drag or a tap on the arrows. + fun step(direction: Int) { + val count = carouselAdapter.itemCount + if (count == 0) return + carouselMovedByUser = true + binding.vpImpactBadges.setCurrentItem( + (binding.vpImpactBadges.currentItem + direction + count) % count, true + ) + } + binding.btnCarouselPrev.setOnClickListener { step(-1) } + binding.btnCarouselNext.setOnClickListener { step(+1) } + + // card-deck feel: neighbours peek in, scale and fade with the swipe + binding.vpImpactBadges.apply { + offscreenPageLimit = 1 // one neighbour each side — enough for peek, lighter on low-RAM devices + (getChildAt(0) as? RecyclerView)?.apply { + val peek = (36 * resources.displayMetrics.density).toInt() + setPadding(peek, 0, peek, 0) + clipToPadding = false + } + setPageTransformer(CompositePageTransformer().apply { + addTransformer(MarginPageTransformer((12 * resources.displayMetrics.density).toInt())) + addTransformer { page, position -> + val scale = 1f - 0.1f * abs(position) + page.scaleX = scale + page.scaleY = scale + page.alpha = 1f - 0.3f * abs(position) + } + }) + // gentle tactile tick as pages settle + "3 / 8" position counter + registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + if (state == ViewPager2.SCROLL_STATE_DRAGGING) carouselMovedByUser = true + } + + override fun onPageSelected(position: Int) { + if (carouselMovedByUser) { + performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + carouselMovedByUser = false + } + _binding?.tvCarouselCounter?.text = + "${position + 1} / ${carouselAdapter.itemCount}" + } + }) + } + // debug-only presentation helper: long-press the title to stage demo progress + if (BuildConfig.DEBUG) binding.tvImpactTitle.setOnLongClickListener { + viewLifecycleOwner.lifecycleScope.launch { + val msg = try { + demoSeeder.stage() + } catch (e: Exception) { + "Demo staging failed: ${e.message}" + } + Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() + } + true + } + binding.cvImpactDashboard.setOnClickListener { + try { + findNavController().navigate(R.id.badgeShelfFragment) + } catch (e: Exception) { + Timber.e(e, "Badge shelf navigation from dashboard failed") + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + badgeRepository.shelf.collect { cards -> + val b = _binding ?: return@collect + // empty ⇔ badge feature killed remotely — hide cleanly + b.cvImpactDashboard.visibility = + if (cards.isEmpty()) View.GONE else View.VISIBLE + carouselAdapter.submitList(cards.sortedByDescending { card -> + val target = (card.state?.nextTarget ?: 1L).coerceAtLeast(1L) + (card.state?.progress ?: 0L).toDouble() / target + }) { + val vp = _binding?.vpImpactBadges ?: return@submitList + _binding?.tvCarouselCounter?.text = + "${vp.currentItem + 1} / ${carouselAdapter.itemCount}" + } + } + } + } + // gentle auto-advance; pauses while the worker is swiping or tab hidden + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (true) { + delay(CAROUSEL_INTERVAL_MS) + val b = _binding ?: continue + val count = carouselAdapter.itemCount + if (count > 1 && b.vpImpactBadges.scrollState == ViewPager2.SCROLL_STATE_IDLE) { + b.vpImpactBadges.setCurrentItem( + (b.vpImpactBadges.currentItem + 1) % count, true + ) + } + } + } + } + } + + companion object { + private const val CAROUSEL_INTERVAL_MS = 4_000L + } + override fun onDestroy() { super.onDestroy() _binding = null diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/journey/JourneyFragment.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/journey/JourneyFragment.kt new file mode 100644 index 000000000..a4e0f187f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/journey/JourneyFragment.kt @@ -0,0 +1,86 @@ +package org.piramalswasthya.sakhi.ui.home_activity.journey + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.adapters.BadgeShelfAdapter +import org.piramalswasthya.sakhi.databinding.FragmentJourneyBinding +import org.piramalswasthya.sakhi.ui.home_activity.HomeActivity +import timber.log.Timber + +/** + * Journey screen (Notification LLD §1): opened from the evening notification; + * shows today's acknowledged work and badge progress. + */ +@AndroidEntryPoint +class JourneyFragment : Fragment() { + + private var _binding: FragmentJourneyBinding? = null + private val binding: FragmentJourneyBinding + get() = _binding!! + + private val viewModel: JourneyViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentJourneyBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.tvJourneyGreeting.text = + getString(R.string.journey_greeting, viewModel.userName) + + viewModel.todayCount.observe(viewLifecycleOwner) { count -> + binding.tvJourneyNarrative.text = getString( + when { + count <= 0 -> R.string.journey_narrative_zero + count == 1 -> R.string.journey_narrative_one + else -> R.string.journey_narrative_busy + } + ) + binding.tvJourneyToday.text = + getString(R.string.journey_today_count, count) + } + viewModel.lifetimeTotal.observe(viewLifecycleOwner) { total -> + binding.tvJourneyLifetime.text = + getString(R.string.journey_lifetime_count, total) + } + + val adapter = BadgeShelfAdapter() + binding.rvJourneyBadges.adapter = adapter + viewModel.topBadges.observe(viewLifecycleOwner) { adapter.submitList(it) } + + binding.btnJourneyAllBadges.setOnClickListener { + try { + findNavController().navigate(R.id.badgeShelfFragment) + } catch (e: Exception) { + Timber.e(e, "Journey → badge shelf navigation failed") + } + } + } + + override fun onStart() { + super.onStart() + activity?.let { + (it as HomeActivity).updateActionBar( + R.drawable.badge_steady_syncer_t1, + getString(R.string.journey_title) + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/journey/JourneyViewModel.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/journey/JourneyViewModel.kt new file mode 100644 index 000000000..8891a64ea --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/journey/JourneyViewModel.kt @@ -0,0 +1,58 @@ +package org.piramalswasthya.sakhi.ui.home_activity.journey + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.piramalswasthya.sakhi.badges.BadgeRepository +import org.piramalswasthya.sakhi.database.room.dao.EveningNotifDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.notifications.ActivityClassifier +import org.piramalswasthya.sakhi.notifications.FormSaveInterceptor +import javax.inject.Inject + +/** + * Aggregates notification and badge data for the Journey screen + * (Notification LLD §6): today's count, lifetime total, badge progress. + */ +@HiltViewModel +class JourneyViewModel @Inject constructor( + private val dao: EveningNotifDao, + private val interceptor: FormSaveInterceptor, + badgeRepository: BadgeRepository, + pref: PreferenceDao +) : ViewModel() { + + val userName: String = try { + pref.getLoggedInUser()?.name ?: "" + } catch (e: Exception) { + "" + } + + private val _todayCount = MutableLiveData() + val todayCount: LiveData = _todayCount + + private val _lifetimeTotal = MutableLiveData() + val lifetimeTotal: LiveData = _lifetimeTotal + + /** Top badges by progress toward their next milestone. */ + val topBadges = badgeRepository.shelf.map { cards -> + cards.sortedByDescending { card -> + val target = (card.state?.nextTarget ?: 1L).coerceAtLeast(1L) + (card.state?.progress ?: 0L).toDouble() / target + }.take(3) + }.asLiveData() + + init { + viewModelScope.launch { + interceptor.captureToday() + val today = ActivityClassifier.dateKey(System.currentTimeMillis()) + _todayCount.postValue(dao.countForDay(today)) + _lifetimeTotal.postValue(dao.lifetimeCount()) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/MonthlyRecapPlaybackFragment.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/MonthlyRecapPlaybackFragment.kt new file mode 100644 index 000000000..0bb06c434 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/MonthlyRecapPlaybackFragment.kt @@ -0,0 +1,471 @@ +package org.piramalswasthya.sakhi.ui.home_activity.monthly_recap + +import android.annotation.SuppressLint +import android.graphics.Typeface +import android.os.Bundle +import android.os.CountDownTimer +import android.os.SystemClock +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import com.google.android.material.progressindicator.LinearProgressIndicator +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.databinding.FragmentMonthlyRecapPlaybackBinding +import org.piramalswasthya.sakhi.helpers.RecapScene +import org.piramalswasthya.sakhi.ui.home_activity.home.recapAnimationsEnabled + +/** + * Phase 7 — the personalised Monthly Recap playback. + * + * Story-style auto-play: a slim segmented progress bar drives each scene + * (welcome 3.5s, category scenes 6s). Every message scene pairs one sentence + * with a DIFFERENT didi animation (the composer guarantees no consecutive + * repeat); the speech bubble is visually attached to her, so the words feel + * spoken. The goodbye scene never auto-advances — it offers Replay instead. + * + * Controls are BOTH gestural and visible: tap-left/tap-right/hold on the + * overlay for people who know the story idiom, plus real Previous / Pause-Play / + * Next buttons that TalkBack can find and describe (the overlay itself is + * hidden from accessibility so the buttons are the single a11y path). + * + * Everything renders from the frozen snapshot: fully offline and identical on + * every replay. Playback resumes at the scene supplied by the ViewModel + * (SavedStateHandle for rotation, Room for process death / "Continue Recap"). + * Reduced-motion devices get static frames; the CountDownTimer is immune to + * animator scale so the story still advances. + */ +@AndroidEntryPoint +class MonthlyRecapPlaybackFragment : Fragment() { + + private var _binding: FragmentMonthlyRecapPlaybackBinding? = null + private val binding get() = _binding!! + + private val viewModel: MonthlyRecapPlaybackViewModel by viewModels() + + private var scenes: List = emptyList() + private var sceneIndex = 0 + private var segments: List = emptyList() + + private var sceneTimer: CountDownTimer? = null + private var remainingMs = 0L + private var currentSceneDurationMs = 0L + private var touchDownAtMs = 0L + private var motionEnabled = true + + /** True while the ASHA has explicitly paused via the Pause button. */ + private var userPaused = false + + /** Soft looping background music (pilot); null-safe if it fails to load. */ + private var music: RecapMusicController? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = FragmentMonthlyRecapPlaybackBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + motionEnabled = recapAnimationsEnabled(requireContext()) + + binding.recapPlaybackClose.setOnClickListener { findNavController().popBackStack() } + binding.recapReplayButton.setOnClickListener { restartStory() } + binding.recapPrevButton.setOnClickListener { onControlNavigate(-1) } + binding.recapNextButton.setOnClickListener { onControlNavigate(+1) } + binding.recapPauseButton.setOnClickListener { togglePause() } + music = RecapMusicController(requireContext()) + binding.recapMusicToggle.setOnClickListener { toggleMusic() } + updateMusicButton() + setUpGestures() + showLoading(true) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { state -> + when (state) { + is MonthlyRecapPlaybackViewModel.UiState.Loading -> showLoading(true) + + is MonthlyRecapPlaybackViewModel.UiState.Closed -> + findNavController().popBackStack() + + is MonthlyRecapPlaybackViewModel.UiState.Playing -> { + if (scenes.isEmpty()) { + scenes = state.scenes + showLoading(false) + buildProgressSegments(scenes.size) + // Resume target: rotation → same scene; process death + // or "Continue Recap" → the scene stored in Room. + showScene(state.startSceneIndex, animate = motionEnabled) + // Prepare the loop once (plays continuously across all + // scenes, never restarts per message), then fade it in + // if it should currently be audible. + music?.prepare() + refreshMusic(fade = true) + } + } + } + } + } + } + } + + /** Blank-screen guard: a small spinner until the story is composed. */ + private fun showLoading(loading: Boolean) { + val b = _binding ?: return + b.recapLoading.visibility = if (loading) View.VISIBLE else View.GONE + val storyVisibility = if (loading) View.INVISIBLE else View.VISIBLE + b.recapProgressRow.visibility = storyVisibility + b.recapPlaybackClose.visibility = storyVisibility + b.recapControlsRow.visibility = storyVisibility // includes the music toggle + // Keep the empty girl/bubble hidden until the first scene renders. + b.recapPlaybackGirl.visibility = storyVisibility + b.recapBubbleTail.visibility = storyVisibility + b.recapBubbleCard.visibility = storyVisibility + } + + // ------------------------------------------------------------ scene render + + private fun showScene(index: Int, animate: Boolean) { + val b = _binding ?: return + if (index !in scenes.indices) return + sceneIndex = index + val scene = scenes[index] + viewModel.onSceneShown(index, scene) + syncSegments(index) + + val isGoodbye = scene.type == RecapScene.Type.GOODBYE + + // Every scene uses the same girl + attached-bubble layout (user's "50/50" + // balance): welcome carries the greeting animation with no count, category + // scenes the achievement + count, goodbye the closing line + Replay. + b.recapReplayButton.visibility = if (isGoodbye) View.VISIBLE else View.GONE + // The finale has nothing left to auto-play: pause loses its meaning there. + b.recapPauseButton.visibility = if (isGoodbye) View.INVISIBLE else View.VISIBLE + b.recapNextButton.isEnabled = !isGoodbye + b.recapNextButton.alpha = if (isGoodbye) 0.35f else 1f + + b.recapSceneText.text = emphasizedCount(scene) // count == null -> plain text + scene.lottieRawRes?.let { res -> + b.recapPlaybackGirl.setAnimation(res) + if (motionEnabled) b.recapPlaybackGirl.playAnimation() + else b.recapPlaybackGirl.progress = 0f // static friendly frame + } + if (animate) { + fadeSlideIn(b.recapPlaybackGirl, translation = 0f) + fadeSlideIn(b.recapBubbleTail) + fadeSlideIn(b.recapBubbleCard) + if (isGoodbye) fadeSlideIn(b.recapReplayButton) + } + b.root.announceForAccessibility(scene.text) + startSceneTimer(scene) + } + + /** Makes the achievement number pop inside the sentence (bold, accent, larger). */ + private fun emphasizedCount(scene: RecapScene): CharSequence { + val count = scene.count?.toString() ?: return scene.text + val start = scene.text.indexOf(count) + if (start < 0) return scene.text + val end = start + count.length + return SpannableString(scene.text).apply { + setSpan(StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(RelativeSizeSpan(1.2f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + ForegroundColorSpan( + ContextCompat.getColor(requireContext(), R.color.recap_playback_count_accent) + ), + start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + private fun fadeSlideIn(view: View, translation: Float = 24f) { + view.alpha = 0f + view.translationY = translation + view.animate().alpha(1f).translationY(0f) + .setDuration(320L).setInterpolator(DecelerateInterpolator()).start() + } + + // ------------------------------------------------------------ progress bar + + private fun buildProgressSegments(count: Int) { + val b = _binding ?: return + b.recapProgressRow.removeAllViews() + segments = List(count) { i -> + LinearProgressIndicator(requireContext()).apply { + max = SEGMENT_MAX + trackThickness = dpToPx(3f) + trackCornerRadius = dpToPx(2f) + trackColor = ContextCompat.getColor(context, R.color.recap_playback_progress_track) + setIndicatorColor( + ContextCompat.getColor(context, R.color.recap_playback_progress_active) + ) + layoutParams = + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginEnd = if (i == count - 1) 0 else dpToPx(6f) + } + }.also { b.recapProgressRow.addView(it) } + } + } + + /** Past segments full, current + future reset (the timer fills the current). */ + private fun syncSegments(current: Int) { + segments.forEachIndexed { i, segment -> + segment.progress = if (i < current) SEGMENT_MAX else 0 + } + } + + // ------------------------------------------------------------ auto-advance + + private fun startSceneTimer(scene: RecapScene) { + cancelTimer() + if (scene.type == RecapScene.Type.GOODBYE) { + // The finale rests: full segment, no auto-advance — she chooses replay/close. + segments.getOrNull(sceneIndex)?.progress = SEGMENT_MAX + return + } + currentSceneDurationMs = readingDurationMs(scene.text) + remainingMs = currentSceneDurationMs + if (!userPaused) launchTimer(currentSceneDurationMs) + } + + /** + * Auto-advance time ADAPTS to sentence length so a short line isn't rushed + * and a long Assamese line gets room to be read: a base plus per-word reading + * time, clamped to a comfortable floor and ceiling. Word-splitting is done + * without a regex (deliberately — an ICU regex crash bit us once). Pause + + * manual Prev/Next remain the override, so a generous default is risk-free. + */ + private fun readingDurationMs(text: String): Long { + val words = text.split(' ', '\n', '\t', '\r', ' ').count { it.isNotBlank() } + return (BASE_READ_MS + words * PER_WORD_MS).coerceIn(MIN_SCENE_MS, MAX_SCENE_MS) + } + + private fun launchTimer(durationMs: Long) { + remainingMs = durationMs + sceneTimer = object : CountDownTimer(durationMs, TIMER_TICK_MS) { + override fun onTick(msLeft: Long) { + remainingMs = msLeft + val done = currentSceneDurationMs - msLeft + segments.getOrNull(sceneIndex)?.progress = + ((done.toFloat() / currentSceneDurationMs) * SEGMENT_MAX).toInt() + } + + override fun onFinish() { + segments.getOrNull(sceneIndex)?.progress = SEGMENT_MAX + advance(1) + } + }.start() + } + + private fun pauseTimer() { + sceneTimer?.cancel() + sceneTimer = null + } + + private fun resumeTimer() { + if (sceneTimer != null || scenes.isEmpty() || userPaused) return + val scene = scenes.getOrNull(sceneIndex) ?: return + if (scene.type == RecapScene.Type.GOODBYE) return + if (remainingMs in 1..currentSceneDurationMs) launchTimer(remainingMs) + } + + private fun cancelTimer() { + sceneTimer?.cancel() + sceneTimer = null + remainingMs = 0L + } + + private fun advance(delta: Int) { + val next = sceneIndex + delta + when { + next < 0 -> showScene(0, animate = false) // back on first scene restarts it + next in scenes.indices -> showScene(next, animate = motionEnabled) + // past the goodbye scene: stay (she uses Replay or Close) + } + } + + private fun restartStory() { + viewModel.onReplay() + userPaused = false + updatePauseButton() + showScene(0, animate = motionEnabled) + } + + // ------------------------------------------------------------ controls + + /** Explicit button navigation always resumes playing (never leaves it paused). */ + private fun onControlNavigate(delta: Int) { + if (scenes.isEmpty()) return + userPaused = false + updatePauseButton() + advance(delta) + } + + private fun togglePause() { + if (scenes.isEmpty()) return + userPaused = !userPaused + if (userPaused) { + pauseTimer() + if (motionEnabled) _binding?.recapPlaybackGirl?.pauseAnimation() + } else { + if (motionEnabled) _binding?.recapPlaybackGirl?.resumeAnimation() + resumeTimer() + } + // Pause pauses the whole experience — music follows the story here. + refreshMusic(fade = true) + updatePauseButton() + } + + private fun updatePauseButton() { + val b = _binding ?: return + b.recapPauseButton.setImageResource( + if (userPaused) R.drawable.ic_recap_play else R.drawable.ic_recap_pause + ) + b.recapPauseButton.contentDescription = getString( + if (userPaused) R.string.monthly_recap_playback_play_cd + else R.string.monthly_recap_playback_pause_cd + ) + } + + /** Music on/off — silences ONLY the music; the story keeps auto-playing. */ + private fun toggleMusic() { + viewModel.musicMuted = !viewModel.musicMuted + refreshMusic(fade = true) + updateMusicButton() + } + + /** Music is audible only while the story is playing AND not muted. */ + private fun shouldMusicPlay(): Boolean = !userPaused && !viewModel.musicMuted + + private fun refreshMusic(fade: Boolean) { + music?.setAudible(shouldMusicPlay(), fade) + } + + private fun updateMusicButton() { + val b = _binding ?: return + val muted = viewModel.musicMuted + b.recapMusicToggle.setImageResource( + if (muted) R.drawable.ic_recap_music_off else R.drawable.ic_recap_music_on + ) + b.recapMusicToggle.contentDescription = getString( + if (muted) R.string.monthly_recap_music_off_cd + else R.string.monthly_recap_music_on_cd + ) + } + + // ------------------------------------------------------------ gestures + + /** + * Convenience layer for people who know the story idiom: hold anywhere = + * pause, quick tap right 2/3 = next, left 1/3 = back. Not the accessible + * path — the visible buttons above are (this overlay is marked + * importantForAccessibility="no"). + */ + @SuppressLint("ClickableViewAccessibility") + private fun setUpGestures() { + binding.recapTouchOverlay.setOnTouchListener { overlay, event -> + if (scenes.isEmpty()) return@setOnTouchListener false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + touchDownAtMs = SystemClock.elapsedRealtime() + pauseTimer() + if (motionEnabled) binding.recapPlaybackGirl.pauseAnimation() + true + } + + MotionEvent.ACTION_UP -> { + val heldMs = SystemClock.elapsedRealtime() - touchDownAtMs + if (motionEnabled && !userPaused) binding.recapPlaybackGirl.resumeAnimation() + if (heldMs < TAP_THRESHOLD_MS) { + if (event.x < overlay.width / 3f) advance(-1) else advance(1) + } else { + resumeTimer() + } + true + } + + MotionEvent.ACTION_CANCEL -> { + if (motionEnabled && !userPaused) binding.recapPlaybackGirl.resumeAnimation() + resumeTimer() + true + } + + else -> false + } + } + } + + // ------------------------------------------------------------ lifecycle + + override fun onPause() { + super.onPause() + pauseTimer() + _binding?.recapPlaybackGirl?.pauseAnimation() + music?.setAudible(false, fade = false) // immediate silence on background + } + + override fun onResume() { + super.onResume() + if (scenes.isEmpty()) return + if (!userPaused) { + if (motionEnabled) _binding?.recapPlaybackGirl?.resumeAnimation() + resumeTimer() + } + // Restore music to whatever the current state dictates (silent if the + // story is paused or the user muted it). + refreshMusic(fade = true) + } + + override fun onDestroyView() { + cancelTimer() + music?.release() + music = null + _binding?.let { b -> + b.recapPlaybackGirl.animate().cancel() + b.recapBubbleCard.animate().cancel() + b.recapBubbleTail.animate().cancel() + b.recapReplayButton.animate().cancel() + } + super.onDestroyView() + _binding = null + } + + private fun dpToPx(dp: Float): Int = (dp * resources.displayMetrics.density).toInt() + + private companion object { + const val SEGMENT_MAX = 1000 + + // Adaptive per-scene reading time: base + per-word, clamped. ~8s for a + // short welcome, up to ~13s for a long Assamese achievement line. + const val BASE_READ_MS = 3_500L + const val PER_WORD_MS = 650L + const val MIN_SCENE_MS = 7_000L + const val MAX_SCENE_MS = 13_000L + + /** + * 60ms ≈ 16 progress updates/second — visually smooth for a slim strip + * while costing ~6x fewer callbacks than per-frame ticking on the + * low-end handsets many ASHAs use. + */ + const val TIMER_TICK_MS = 60L + const val TAP_THRESHOLD_MS = 250L + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/MonthlyRecapPlaybackViewModel.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/MonthlyRecapPlaybackViewModel.kt new file mode 100644 index 000000000..08207a71c --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/MonthlyRecapPlaybackViewModel.kt @@ -0,0 +1,110 @@ +package org.piramalswasthya.sakhi.ui.home_activity.monthly_recap + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.piramalswasthya.sakhi.helpers.RecapScene +import org.piramalswasthya.sakhi.helpers.RecapStoryGate +import org.piramalswasthya.sakhi.model.RecapStatus +import org.piramalswasthya.sakhi.model.recapStatus +import org.piramalswasthya.sakhi.model.safeProgressScene +import org.piramalswasthya.sakhi.repositories.MonthlyRecapRepo +import javax.inject.Inject + +/** + * Phase 7 — drives the personalised recap playback. + * + * Resume model (currentScene semantics): the snapshot's progressScene stores + * the scene BEING SHOWN, and reopening starts AT that scene. Restoration + * sources, in priority order: + * 1. [SavedStateHandle] — same-session recreation (rotation, UI restart); + * 2. Room (progressScene) — true process death or a fresh open of an + * IN_PROGRESS recap ("Continue Recap" resumes where she left off); + * 3. 0 — first open, or a COMPLETED recap being replayed. + * + * Write ordering: scene-progress writes are serialized through a fair mutex + * acquired in main-thread launch order, so rapid next/back taps can never let + * an older write land last (the stored index always matches the last scene + * shown). On open, the story length is recorded (totalScenes) so clamping and + * resume validate against the real story. + * + * EMPTY story → [UiState.Closed]: the fragment exits immediately — the recap + * "does not open at all" for a month with nothing to celebrate (user decision; + * the strip is normally already hidden by then, this is the second lock). + */ +@HiltViewModel +class MonthlyRecapPlaybackViewModel @Inject constructor( + private val storyGate: RecapStoryGate, + private val recapRepo: MonthlyRecapRepo, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + sealed class UiState { + object Loading : UiState() + /** [startSceneIndex] is the validated resume target (0 for fresh/replay). */ + data class Playing(val scenes: List, val startSceneIndex: Int) : UiState() + object Closed : UiState() + } + + private val _state = MutableStateFlow(UiState.Loading) + val state: StateFlow = _state + + /** Fair FIFO lock: progress writes commit in the exact order scenes were shown. */ + private val progressWriteMutex = Mutex() + + /** + * Whether the ASHA muted the background music. Held here so it survives + * rotation/recreation; the music itself lives in the fragment (view-tied). + * Muting affects ONLY the music — the story keeps playing. + */ + var musicMuted: Boolean = false + + init { + viewModelScope.launch { + val scenes = storyGate.composeStory() + if (scenes.isEmpty()) { + _state.value = UiState.Closed + return@launch + } + val snapshot = recapRepo.getOrCreateCurrentRecap() + val startIndex = when { + // 1. Same-session recreation (rotation): exact scene, pre-validation. + savedStateHandle.contains(KEY_SCENE_INDEX) -> + savedStateHandle.get(KEY_SCENE_INDEX) ?: 0 + // 2. Process death / fresh open of an in-progress story: the Room + // index, already coerced against the stored totalScenes. + snapshot?.recapStatus() == RecapStatus.IN_PROGRESS -> snapshot.safeProgressScene() + // 3. Fresh start or replay of a completed story. + else -> 0 + }.coerceIn(0, scenes.lastIndex) + recapRepo.onPlaybackOpened(totalScenes = scenes.size) + _state.value = UiState.Playing(scenes, startIndex) + } + } + + /** Called by the fragment whenever a scene becomes current. */ + fun onSceneShown(index: Int, scene: RecapScene) { + savedStateHandle[KEY_SCENE_INDEX] = index + viewModelScope.launch { + progressWriteMutex.withLock { + recapRepo.updateSafeProgress(index) + if (scene.type == RecapScene.Type.GOODBYE) recapRepo.markCompleted() + } + } + } + + /** Replay restarts the story UI-side; the COMPLETED status is preserved. */ + fun onReplay() { + savedStateHandle[KEY_SCENE_INDEX] = 0 + } + + private companion object { + const val KEY_SCENE_INDEX = "recap_playback_scene_index" + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/RecapMusicController.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/RecapMusicController.kt new file mode 100644 index 000000000..0f8339560 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/monthly_recap/RecapMusicController.kt @@ -0,0 +1,110 @@ +package org.piramalswasthya.sakhi.ui.home_activity.monthly_recap + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.media.MediaPlayer +import org.piramalswasthya.sakhi.R +import timber.log.Timber + +/** + * Soft looping background music for the recap playback (pilot). + * + * Design: the controller is "dumb" — it just makes the music audible or silent + * on request. The fragment decides WHEN via one rule: music is audible only when + * the story is playing (not story-paused) AND not muted AND the app is + * foregrounded. That single rule keeps every case consistent: + * - Pause button → pauses story AND music together; + * - Music button → silences ONLY the music, story keeps playing; + * - background → music silences immediately, resumes on return (if it should). + * + * The track is prepared once and loops CONTINUOUSLY across scenes (it is never + * restarted per message). Low volume with a gentle 1s fade. Fully fail-safe: + * every audio call is guarded, so a missing/corrupt file can never crash or + * block the recap — the story just plays without music. + * + * Self-contained (this class + a few fragment wiring lines) so it can be + * reverted cleanly if not wanted. + */ +class RecapMusicController(private val context: Context) { + + private var player: MediaPlayer? = null + private var fade: ValueAnimator? = null + private var currentVolume = 0f + + /** Create + loop the track, held silent until [setAudible] turns it up. */ + fun prepare() { + if (player != null) return + try { + player = MediaPlayer.create(context.applicationContext, R.raw.recap_bg_music)?.apply { + isLooping = true + setVolume(0f, 0f) + start() + } + } catch (e: Exception) { + Timber.w(e, "Recap music failed to prepare") + player = null + } + } + + /** + * Make the music audible or silent. [fade] = smooth 1s ramp (buttons); pass + * false for an immediate cut (going to background, where the screen is gone). + * When turning silent, the player is paused after reaching zero. + */ + fun setAudible(audible: Boolean, fade: Boolean) { + val p = player ?: return + val target = if (audible) TARGET_VOLUME else 0f + try { + if (audible && !p.isPlaying) p.start() + if (fade) { + fadeTo(target) { if (!audible) runCatching { p.pause() } } + } else { + cancelFade() + currentVolume = target + p.setVolume(target, target) + if (!audible) p.pause() + } + } catch (e: Exception) { + Timber.w(e, "Recap music setAudible failed") + } + } + + fun release() { + cancelFade() + runCatching { player?.release() } + player = null + currentVolume = 0f + } + + private fun fadeTo(target: Float, onEnd: (() -> Unit)? = null) { + val p = player ?: return + cancelFade() + fade = ValueAnimator.ofFloat(currentVolume, target).apply { + duration = FADE_MS + addUpdateListener { anim -> + val v = anim.animatedValue as Float + currentVolume = v + runCatching { p.setVolume(v, v) } + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + onEnd?.invoke() + } + }) + start() + } + } + + private fun cancelFade() { + fade?.cancel() + fade = null + } + + private companion object { + /** Background level. 25% for pilot audibility (esp. on emulators). */ + const val TARGET_VOLUME = 0.25f + const val FADE_MS = 1_000L + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/work/BadgeEvaluatorWorker.kt b/app/src/main/java/org/piramalswasthya/sakhi/work/BadgeEvaluatorWorker.kt new file mode 100644 index 000000000..dcf353090 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/work/BadgeEvaluatorWorker.kt @@ -0,0 +1,37 @@ +package org.piramalswasthya.sakhi.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.piramalswasthya.sakhi.badges.domain.BadgeEvaluator +import timber.log.Timber + +/** + * Nightly time-based re-evaluation (LLD §2.3): streak and quarter boundaries + * move with the calendar even when no new records are saved. + */ +@HiltWorker +class BadgeEvaluatorWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val evaluator: BadgeEvaluator +) : CoroutineWorker(appContext, params) { + + companion object { + const val periodicName = "BadgeEvaluatorWorker-periodic" + const val oneShotName = "BadgeEvaluatorWorker-oneShot" + } + + override suspend fun doWork(): Result { + return try { + evaluator.evaluateAll() + Result.success() + } catch (e: Exception) { + Timber.e(e, "Badges: nightly evaluation failed") + Result.success() // next trigger recomputes from scratch anyway + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/work/BadgeSyncWorker.kt b/app/src/main/java/org/piramalswasthya/sakhi/work/BadgeSyncWorker.kt new file mode 100644 index 000000000..24bf383d9 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/work/BadgeSyncWorker.kt @@ -0,0 +1,116 @@ +package org.piramalswasthya.sakhi.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.model.BadgeConfigCache +import org.piramalswasthya.sakhi.model.BadgeEarnedCache +import org.piramalswasthya.sakhi.model.BadgeStreakFreezeCache +import org.piramalswasthya.sakhi.network.BadgeApiService +import org.piramalswasthya.sakhi.network.BadgeEarnedDTO +import org.piramalswasthya.sakhi.network.BadgeEarnedPush +import timber.log.Timber + +/** + * Badge synchronization (LLD §2.2 / §4.2): pulls central config, freeze + * windows and previously earned milestones; pushes newly earned milestones + * (badgeId, level, earnedAt only — no beneficiary data). + * + * Every step degrades independently: with the server down or endpoints not + * yet deployed, the module keeps functioning on last-known or compiled + * defaults (LLD §5.2), so this worker always succeeds. + */ +@HiltWorker +class BadgeSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val api: BadgeApiService, + private val badgeDao: BadgeDao, + private val pref: PreferenceDao +) : CoroutineWorker(appContext, params) { + + companion object { + const val name = "BadgeSyncWorker" + } + + override suspend fun doWork(): Result { + val now = System.currentTimeMillis() + + try { + api.getConfig().body()?.config?.takeIf { it.isNotEmpty() }?.let { config -> + badgeDao.upsertConfig(config.map { (k, v) -> BadgeConfigCache(k, v, now) }) + } + } catch (e: Exception) { + Timber.d("Badges: config pull skipped (${e.message})") + } + + try { + api.getFreezes().body()?.freezes?.let { freezes -> + badgeDao.replaceFreezes(freezes.map { + BadgeStreakFreezeCache( + badgeId = it.badgeId ?: "", + startDate = it.startDate, + endDate = it.endDate + ) + }) + } + } catch (e: Exception) { + Timber.d("Badges: freeze pull skipped (${e.message})") + } + + val userId = try { + pref.getLoggedInUser()?.userId + } catch (e: Exception) { + null + } ?: return Result.success() + + // Restore milestones on reinstall (insert-IGNORE keeps local awards intact). + // + // awardKey comes back into caseRef, which is what makes a restored row identical to + // the one an evaluation would derive: both sides of the local uniqueness key + // (userId, badgeId, level, caseRef) agree, so re-deriving after the restore is a + // no-op rather than a duplicate. Restoring with an empty caseRef instead would + // collapse two quarters of a re-earnable badge into one row on the way out and + // duplicate it against the derived rows on the way back in. + // + // The key is a quarter key or a digest, never a beneficiary id (AwardKeys). + try { + api.getEarned().body()?.earned?.let { restored -> + badgeDao.insertEarned(restored.map { + BadgeEarnedCache( + userId = userId, badgeId = it.badgeId, level = it.level, + caseRef = it.awardKey, earnedAt = it.earnedAt, synced = true + ) + }) + } + } catch (e: Exception) { + Timber.d("Badges: earned restore skipped (${e.message})") + } + + try { + val unsynced = badgeDao.getUnsyncedEarned(userId) + if (unsynced.isNotEmpty()) { + val response = api.postEarned( + BadgeEarnedPush( + userId = userId, + badges = unsynced.map { + // caseRef travels as awardKey. It is a quarter key or a + // digest by this point, never a beneficiary id (LLD §4). + BadgeEarnedDTO(it.badgeId, it.level, it.earnedAt, it.caseRef) + } + ) + ) + if (response.isSuccessful) badgeDao.markEarnedSynced(unsynced.map { it.id }) + } + } catch (e: Exception) { + Timber.d("Badges: earned push skipped (${e.message})") + } + + return Result.success() + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/work/MonthlyRecapReminderWorker.kt b/app/src/main/java/org/piramalswasthya/sakhi/work/MonthlyRecapReminderWorker.kt new file mode 100644 index 000000000..29786d2ed --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/work/MonthlyRecapReminderWorker.kt @@ -0,0 +1,142 @@ +package org.piramalswasthya.sakhi.work + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.helpers.GamificationConfigProvider +import org.piramalswasthya.sakhi.helpers.RecapNotificationState +import org.piramalswasthya.sakhi.helpers.isMonthlyRecapWindowOpen +import org.piramalswasthya.sakhi.repositories.MonthlyRecapRepo +import org.piramalswasthya.sakhi.ui.home_activity.HomeActivity +import timber.log.Timber +import java.util.Calendar +import java.util.concurrent.TimeUnit + +/** + * The trigger the audit found missing: nothing previously scheduled the recap + * snapshot or told her it was ready. This worker is that trigger. + * + * Runs once a day (cheap: at most one small DB read/write when nothing is due). + * On each run, only inside the active window (days 1..ACTIVE_WINDOW_DAYS of the + * month — the same rule the dashboard strip itself uses): + * 1. Freeze/fetch this month's snapshot via [MonthlyRecapRepo.getOrCreateCurrentRecap] + * (idempotent — repeated calls never re-freeze an existing snapshot). + * 2. If she has not already been notified for THIS recap month, post one local + * notification inviting her to watch it, then remember that she was told. + * + * Entirely on-device, no server call, no clinical writes. Silently no-ops when + * the mechanic is gated off, nobody is logged in, or outside the window — + * failures here must never surface to her as an error. + */ +@HiltWorker +class MonthlyRecapReminderWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted params: WorkerParameters, + private val recapRepo: MonthlyRecapRepo, + private val configProvider: GamificationConfigProvider, + private val pref: PreferenceDao, +) : CoroutineWorker(appContext, params) { + + companion object { + const val UNIQUE_WORK_NAME = "MONTHLY_RECAP_REMINDER" + private const val NOTIFICATION_ID = 9001 + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder( + 1, TimeUnit.DAYS + ).build() + // KEEP: an already-scheduled periodic worker survives app restarts and + // device reboots on its own; re-enqueuing with KEEP is a harmless no-op + // rather than resetting its 24h cycle every time the app launches. + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo = + createForegroundInfo("Checking your monthly recap...") + + override suspend fun doWork(): Result = try { + val user = pref.getLoggedInUser() + if (user == null) { + Result.success() + } else if (!configProvider.isMechanicEnabled( + GamificationConfigProvider.Mechanic.MONTHLY_RECAP, user.userId + ) + ) { + Result.success() // gated off: not an error, just nothing to do yet + } else if (!isMonthlyRecapWindowOpen(Calendar.getInstance())) { + Result.success() // outside days 1..7: nothing to freeze or announce + } else { + val recap = recapRepo.getOrCreateCurrentRecap() + if (recap != null && + !RecapNotificationState.hasNotified(appContext, user.userId, recap.recapYearMonth) + ) { + postReminder() + RecapNotificationState.markNotified(appContext, user.userId, recap.recapYearMonth) + } + Result.success() + } + } catch (e: Exception) { + Timber.e(e, "MonthlyRecapReminderWorker failed") + Result.success() // gamification must never retry-storm or alarm the user + } + + private fun postReminder() { + val channelId = appContext.getString(R.string.notification_recap_channel_id) + val openHome = PendingIntent.getActivity( + appContext, + NOTIFICATION_ID, + Intent(appContext, HomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(appContext, channelId) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(appContext.getString(R.string.notification_recap_title)) + .setContentText(appContext.getString(R.string.notification_recap_body)) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(appContext.getString(R.string.notification_recap_body)) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setContentIntent(openHome) + .build() + NotificationManagerCompat.from(appContext).notify(NOTIFICATION_ID, notification) + } + + private fun createForegroundInfo(progress: String): ForegroundInfo { + val notification = NotificationCompat.Builder( + appContext, appContext.getString(R.string.notification_sync_channel_id) + ) + .setContentTitle(progress) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ForegroundInfo(1004, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(1004, notification) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/work/PullIncentiveWorker.kt b/app/src/main/java/org/piramalswasthya/sakhi/work/PullIncentiveWorker.kt index 5367f66fe..183f09391 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/work/PullIncentiveWorker.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/work/PullIncentiveWorker.kt @@ -12,10 +12,14 @@ import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.badges.domain.BadgeDates +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao import org.piramalswasthya.sakhi.helpers.setToStartOfTheDay +import org.piramalswasthya.sakhi.model.BadgeSyncLogCache import org.piramalswasthya.sakhi.repositories.IncentiveRepo import java.util.Calendar +import timber.log.Timber @HiltWorker class PullIncentiveWorker @AssistedInject constructor( @@ -23,6 +27,7 @@ class PullIncentiveWorker @AssistedInject constructor( @Assisted params: WorkerParameters, private val incentiveRepo: IncentiveRepo, private val preferenceDao: PreferenceDao, + private val badgeDao: BadgeDao, ) : CoroutineWorker(appContext, params) { override suspend fun getForegroundInfo(): ForegroundInfo = createForegroundInfo() @@ -43,6 +48,15 @@ class PullIncentiveWorker @AssistedInject constructor( ) preferenceDao.lastIncentivePullTimestamp = Calendar.getInstance().setToStartOfTheDay().timeInMillis + // Badges (LLD §3.1 Steady Syncer): this worker only runs after every + // push group succeeded — log the successful sync week. + try { + val now = System.currentTimeMillis() + badgeDao.insertSyncLog(BadgeSyncLogCache(BadgeDates.weekKey(now), now)) + WorkerUtils.triggerAdHocBadgeEvaluation(appContext) + } catch (e: Exception) { + Timber.w(e, "Badges: sync-week log after push failed") + } return Result.success() } diff --git a/app/src/main/java/org/piramalswasthya/sakhi/work/UpdatePrefForPullCompleteWorker.kt b/app/src/main/java/org/piramalswasthya/sakhi/work/UpdatePrefForPullCompleteWorker.kt index f64d23373..b050ca2b9 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/work/UpdatePrefForPullCompleteWorker.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/work/UpdatePrefForPullCompleteWorker.kt @@ -11,13 +11,18 @@ import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.badges.domain.BadgeDates +import org.piramalswasthya.sakhi.database.room.dao.BadgeDao import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.model.BadgeSyncLogCache +import timber.log.Timber @HiltWorker class UpdatePrefForPullCompleteWorker @AssistedInject constructor( @Assisted private val appContext: Context, @Assisted params: WorkerParameters, private val preferenceDao: PreferenceDao, + private val badgeDao: BadgeDao, ) : CoroutineWorker(appContext, params) { companion object { @@ -29,6 +34,14 @@ class UpdatePrefForPullCompleteWorker @AssistedInject constructor( override suspend fun doWork(): Result { preferenceDao.isFullPullComplete = true + // Badges (LLD §3.1 Steady Syncer): log the successful sync week + try { + val now = System.currentTimeMillis() + badgeDao.insertSyncLog(BadgeSyncLogCache(BadgeDates.weekKey(now), now)) + WorkerUtils.triggerAdHocBadgeEvaluation(appContext) + } catch (e: Exception) { + Timber.w(e, "Badges: sync-week log after full pull failed") + } return Result.success() } diff --git a/app/src/main/java/org/piramalswasthya/sakhi/work/WorkerUtils.kt b/app/src/main/java/org/piramalswasthya/sakhi/work/WorkerUtils.kt index eb065f9c5..c40ce4a69 100644 --- a/app/src/main/java/org/piramalswasthya/sakhi/work/WorkerUtils.kt +++ b/app/src/main/java/org/piramalswasthya/sakhi/work/WorkerUtils.kt @@ -739,6 +739,34 @@ object WorkerUtils { ) } + /** + * Badges module (LLD §2): nightly time-based re-evaluation + config/earned + * synchronization. Idempotent — safe to call on every home entry. + */ + fun triggerBadgeWorkers(context: Context) { + val workManager = WorkManager.getInstance(context) + workManager.enqueueUniquePeriodicWork( + BadgeEvaluatorWorker.periodicName, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequest.Builder(BadgeEvaluatorWorker::class.java, 1, TimeUnit.DAYS) + .build() + ) + workManager.enqueueUniqueWork( + BadgeSyncWorker.name, + ExistingWorkPolicy.KEEP, + syncRequestBuilder().build() + ) + } + + /** One-shot badge recompute, used after sync milestones. */ + fun triggerAdHocBadgeEvaluation(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork( + BadgeEvaluatorWorker.oneShotName, + ExistingWorkPolicy.KEEP, + OneTimeWorkRequest.Builder(BadgeEvaluatorWorker::class.java).build() + ) + } + fun triggerAdHocPncEcUpdateWorker(context: Context) { val workRequest = OneTimeWorkRequest.Builder(UpdatePNCToECWorker::class.java).build() WorkManager.getInstance(context) diff --git a/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t1.png b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t1.png new file mode 100644 index 000000000..8fabdadac Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t1.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t2.png b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t2.png new file mode 100644 index 000000000..dd02ee725 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t2.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t3.png b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t3.png new file mode 100644 index 000000000..a81eb4385 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t3.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t4.png b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t4.png new file mode 100644 index 000000000..a3efc8c37 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_child_fully_protected_t4.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_community_voice_t1.png b/app/src/main/res/drawable-nodpi/badge_community_voice_t1.png new file mode 100644 index 000000000..245f4a26a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_community_voice_t1.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_community_voice_t2.png b/app/src/main/res/drawable-nodpi/badge_community_voice_t2.png new file mode 100644 index 000000000..cb1862943 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_community_voice_t2.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_community_voice_t3.png b/app/src/main/res/drawable-nodpi/badge_community_voice_t3.png new file mode 100644 index 000000000..47ce281f7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_community_voice_t3.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_community_voice_t4.png b/app/src/main/res/drawable-nodpi/badge_community_voice_t4.png new file mode 100644 index 000000000..58a30f071 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_community_voice_t4.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_complete_worker_t1.png b/app/src/main/res/drawable-nodpi/badge_complete_worker_t1.png new file mode 100644 index 000000000..c4d19ab49 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_complete_worker_t1.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_complete_worker_t2.png b/app/src/main/res/drawable-nodpi/badge_complete_worker_t2.png new file mode 100644 index 000000000..44ea7e6c3 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_complete_worker_t2.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_complete_worker_t3.png b/app/src/main/res/drawable-nodpi/badge_complete_worker_t3.png new file mode 100644 index 000000000..c1ffcbbd7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_complete_worker_t3.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_complete_worker_t4.png b/app/src/main/res/drawable-nodpi/badge_complete_worker_t4.png new file mode 100644 index 000000000..2b8ba1ae4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_complete_worker_t4.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_digital_identity_t1.png b/app/src/main/res/drawable-nodpi/badge_digital_identity_t1.png new file mode 100644 index 000000000..31ca3bd09 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_digital_identity_t1.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_digital_identity_t2.png b/app/src/main/res/drawable-nodpi/badge_digital_identity_t2.png new file mode 100644 index 000000000..28a353334 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_digital_identity_t2.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_digital_identity_t3.png b/app/src/main/res/drawable-nodpi/badge_digital_identity_t3.png new file mode 100644 index 000000000..406dbab14 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_digital_identity_t3.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_digital_identity_t4.png b/app/src/main/res/drawable-nodpi/badge_digital_identity_t4.png new file mode 100644 index 000000000..0d0d773f2 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_digital_identity_t4.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_maternal_journey_t1.png b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t1.png new file mode 100644 index 000000000..71cefdf15 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t1.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_maternal_journey_t2.png b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t2.png new file mode 100644 index 000000000..065b7c81d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t2.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_maternal_journey_t3.png b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t3.png new file mode 100644 index 000000000..e4a84488e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t3.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_maternal_journey_t4.png b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t4.png new file mode 100644 index 000000000..c86a065c3 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_maternal_journey_t4.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_steady_syncer_t1.png b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t1.png new file mode 100644 index 000000000..31bd601e8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t1.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_steady_syncer_t2.png b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t2.png new file mode 100644 index 000000000..e4bb25ede Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t2.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_steady_syncer_t3.png b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t3.png new file mode 100644 index 000000000..0d77807fb Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t3.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_steady_syncer_t4.png b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t4.png new file mode 100644 index 000000000..78f65f44e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_steady_syncer_t4.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_timely_reporter_t1.png b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t1.png new file mode 100644 index 000000000..5d691e262 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t1.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_timely_reporter_t2.png b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t2.png new file mode 100644 index 000000000..d472a50f8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t2.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_timely_reporter_t3.png b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t3.png new file mode 100644 index 000000000..a503f0423 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t3.png differ diff --git a/app/src/main/res/drawable-nodpi/badge_timely_reporter_t4.png b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t4.png new file mode 100644 index 000000000..415abcea1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/badge_timely_reporter_t4.png differ diff --git a/app/src/main/res/drawable-nodpi/img_recap_character.png b/app/src/main/res/drawable-nodpi/img_recap_character.png new file mode 100644 index 000000000..0e2809ee4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/img_recap_character.png differ diff --git a/app/src/main/res/drawable/badge_critical_referral.xml b/app/src/main/res/drawable/badge_critical_referral.xml new file mode 100644 index 000000000..74271197b --- /dev/null +++ b/app/src/main/res/drawable/badge_critical_referral.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/badge_vulnerable_baby.xml b/app/src/main/res/drawable/badge_vulnerable_baby.xml new file mode 100644 index 000000000..e34feb2ca --- /dev/null +++ b/app/src/main/res/drawable/badge_vulnerable_baby.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_monthly_recap_strip.xml b/app/src/main/res/drawable/bg_monthly_recap_strip.xml new file mode 100644 index 000000000..29e7d3ad1 --- /dev/null +++ b/app/src/main/res/drawable/bg_monthly_recap_strip.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_recap_badge.xml b/app/src/main/res/drawable/bg_recap_badge.xml new file mode 100644 index 000000000..81c190a83 --- /dev/null +++ b/app/src/main/res/drawable/bg_recap_badge.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_recap_badge_new.xml b/app/src/main/res/drawable/bg_recap_badge_new.xml new file mode 100644 index 000000000..e91d145e5 --- /dev/null +++ b/app/src/main/res/drawable/bg_recap_badge_new.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_recap_playback.xml b/app/src/main/res/drawable/bg_recap_playback.xml new file mode 100644 index 000000000..498d2f693 --- /dev/null +++ b/app/src/main/res/drawable/bg_recap_playback.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_recap_close.xml b/app/src/main/res/drawable/ic_recap_close.xml new file mode 100644 index 000000000..b4d0ba98c --- /dev/null +++ b/app/src/main/res/drawable/ic_recap_close.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recap_music_off.xml b/app/src/main/res/drawable/ic_recap_music_off.xml new file mode 100644 index 000000000..5df21d010 --- /dev/null +++ b/app/src/main/res/drawable/ic_recap_music_off.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recap_music_on.xml b/app/src/main/res/drawable/ic_recap_music_on.xml new file mode 100644 index 000000000..74b649e8a --- /dev/null +++ b/app/src/main/res/drawable/ic_recap_music_on.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recap_next.xml b/app/src/main/res/drawable/ic_recap_next.xml new file mode 100644 index 000000000..4905c6bc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_recap_next.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recap_pause.xml b/app/src/main/res/drawable/ic_recap_pause.xml new file mode 100644 index 000000000..c6901c46d --- /dev/null +++ b/app/src/main/res/drawable/ic_recap_pause.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recap_play.xml b/app/src/main/res/drawable/ic_recap_play.xml new file mode 100644 index 000000000..0838e508f --- /dev/null +++ b/app/src/main/res/drawable/ic_recap_play.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recap_prev.xml b/app/src/main/res/drawable/ic_recap_prev.xml new file mode 100644 index 000000000..28566515b --- /dev/null +++ b/app/src/main/res/drawable/ic_recap_prev.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/recap_bubble_tail.xml b/app/src/main/res/drawable/recap_bubble_tail.xml new file mode 100644 index 000000000..0aa3d3dbd --- /dev/null +++ b/app/src/main/res/drawable/recap_bubble_tail.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/dialog_badge_celebration.xml b/app/src/main/res/layout/dialog_badge_celebration.xml new file mode 100644 index 000000000..9ccac9041 --- /dev/null +++ b/app/src/main/res/layout/dialog_badge_celebration.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_badge_shelf.xml b/app/src/main/res/layout/fragment_badge_shelf.xml new file mode 100644 index 000000000..167113324 --- /dev/null +++ b/app/src/main/res/layout/fragment_badge_shelf.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index b7fed9f63..0e3f172ef 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -12,33 +12,12 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_journey.xml b/app/src/main/res/layout/fragment_journey.xml new file mode 100644 index 000000000..3028b0b6b --- /dev/null +++ b/app/src/main/res/layout/fragment_journey.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + +