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 06fbf67c6..2ee3641bb 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 @@ -130,7 +130,10 @@ import org.piramalswasthya.sakhi.model.dynamicEntity.ben_ifa.BenIfaFormResponseJ import org.piramalswasthya.sakhi.model.dynamicEntity.eye_surgery.EyeSurgeryFormResponseJsonEntity import org.piramalswasthya.sakhi.model.dynamicEntity.filariaaMdaCampaign.FilariaMDACampaignFormResponseJsonEntity import org.piramalswasthya.sakhi.model.dynamicEntity.mosquitonetEntity.MosquitoNetFormResponseJsonEntity - +import org.piramalswasthya.sakhi.database.room.dao.GamificationDao +import org.piramalswasthya.sakhi.model.GamificationBadge +import org.piramalswasthya.sakhi.model.GamificationProfile +import org.piramalswasthya.sakhi.model.PointsTransaction @Database( entities = [ HouseholdCache::class, @@ -200,10 +203,13 @@ import org.piramalswasthya.sakhi.model.dynamicEntity.mosquitonetEntity.MosquitoN FilariaMDAFormResponseJsonEntity::class, ANCFormResponseJsonEntity::class, FilariaMDACampaignFormResponseJsonEntity::class, - TBConfirmedTreatmentCache::class + TBConfirmedTreatmentCache::class, + GamificationProfile::class, + GamificationBadge::class, + PointsTransaction::class, ], views = [BenBasicCache::class], - version = 57, exportSchema = false + version = 58, exportSchema = false ) @TypeConverters( @@ -268,6 +274,7 @@ abstract class InAppDb : RoomDatabase() { abstract fun formResponseFilariaMDACampaignJsonDao(): FilariaMdaCampaignJsonDao abstract val syncDao: SyncDao + abstract val gamificationDao: GamificationDao companion object { @Volatile @@ -311,6 +318,70 @@ abstract class InAppDb : RoomDatabase() { }) + val MIGRATION_57_58 = object : Migration(57, 58) { + override fun migrate(database: SupportSQLiteDatabase) { + + if (!tableExists(database, "GAMIFICATION_PROFILE")) { + database.execSQL(""" + CREATE TABLE `GAMIFICATION_PROFILE` ( + `userId` INTEGER NOT NULL, + `totalPoints` INTEGER NOT NULL DEFAULT 0, + `currentStreakDays` INTEGER NOT NULL DEFAULT 0, + `longestStreakDays` INTEGER NOT NULL DEFAULT 0, + `lastActivityDate` TEXT, + `lastLoginDate` TEXT, + `level` INTEGER NOT NULL DEFAULT 1, + `createdAt` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL, + `syncState` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`userId`) + ) + """.trimIndent()) + } + + if (!tableExists(database, "GAMIFICATION_BADGE")) { + database.execSQL(""" + CREATE TABLE `GAMIFICATION_BADGE` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `userId` INTEGER NOT NULL, + `badgeType` TEXT NOT NULL, + `badgeNameEn` TEXT NOT NULL, + `badgeNameHi` TEXT NOT NULL, + `badgeNameAs` TEXT NOT NULL, + `earnedAt` INTEGER NOT NULL, + `syncState` INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(`userId`) REFERENCES `GAMIFICATION_PROFILE`(`userId`) + ON UPDATE CASCADE ON DELETE CASCADE + ) + """.trimIndent()) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `idx_gamification_badge_userId` ON `GAMIFICATION_BADGE` (`userId`)" + ) + } + + if (!tableExists(database, "GAMIFICATION_POINTS_TX")) { + database.execSQL(""" + CREATE TABLE `GAMIFICATION_POINTS_TX` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `userId` INTEGER NOT NULL, + `points` INTEGER NOT NULL, + `eventType` TEXT NOT NULL, + `eventRefId` TEXT, + `reason` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `syncState` INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(`userId`) REFERENCES `GAMIFICATION_PROFILE`(`userId`) + ON UPDATE CASCADE ON DELETE CASCADE + ) + """.trimIndent()) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `idx_gamification_tx_userId` ON `GAMIFICATION_POINTS_TX` (`userId`)" + ) + } + } + } + + val MIGRATION_56_57 = object : Migration(56, 57) { override fun migrate(database: SupportSQLiteDatabase) { val householdLocColumns = listOf( @@ -3214,7 +3285,8 @@ abstract class InAppDb : RoomDatabase() { MIGRATION_53_54, MIGRATION_54_55, MIGRATION_55_56, - MIGRATION_56_57 + MIGRATION_56_57, + MIGRATION_57_58 ).build() diff --git a/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/GamificationDao.kt b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/GamificationDao.kt new file mode 100644 index 000000000..f52fc5b5e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/database/room/dao/GamificationDao.kt @@ -0,0 +1,62 @@ +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.GamificationBadge +import org.piramalswasthya.sakhi.model.GamificationProfile +import org.piramalswasthya.sakhi.model.PointsTransaction + +@Dao +interface GamificationDao { + + // ── Profile ─────────────────────────────────────────────────────────────── + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertProfile(profile: GamificationProfile) + + @Query("SELECT * FROM GAMIFICATION_PROFILE WHERE userId = :userId") + suspend fun getProfile(userId: Int): GamificationProfile? + + @Query("SELECT * FROM GAMIFICATION_PROFILE WHERE userId = :userId") + fun observeProfile(userId: Int): Flow + + @Query("SELECT * FROM GAMIFICATION_PROFILE WHERE syncState = 0") + suspend fun getUnsyncedProfiles(): List + + @Query("UPDATE GAMIFICATION_PROFILE SET syncState = 2 WHERE userId = :userId") + suspend fun markProfileSynced(userId: Int) + + // ── Badges ──────────────────────────────────────────────────────────────── + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertBadge(badge: GamificationBadge): Long + + @Query("SELECT COUNT(*) FROM GAMIFICATION_BADGE WHERE userId = :userId AND badgeType = :badgeType") + suspend fun hasBadge(userId: Int, badgeType: String): Int + + @Query("SELECT * FROM GAMIFICATION_BADGE WHERE userId = :userId ORDER BY earnedAt DESC") + fun observeBadges(userId: Int): Flow> + + @Query("SELECT * FROM GAMIFICATION_BADGE WHERE syncState = 0") + suspend fun getUnsyncedBadges(): List + + @Query("UPDATE GAMIFICATION_BADGE SET syncState = 2 WHERE id IN (:ids)") + suspend fun markBadgesSynced(ids: List) + + // ── Points Transactions ─────────────────────────────────────────────────── + + @Insert + suspend fun insertTransaction(tx: PointsTransaction): Long + + @Query("SELECT * FROM GAMIFICATION_POINTS_TX WHERE userId = :userId ORDER BY createdAt DESC LIMIT 20") + fun observeRecentTransactions(userId: Int): Flow> + + @Query("SELECT * FROM GAMIFICATION_POINTS_TX WHERE syncState = 0") + suspend fun getUnsyncedTransactions(): List + + @Query("UPDATE GAMIFICATION_POINTS_TX SET syncState = 2 WHERE id IN (:ids)") + suspend fun markTransactionsSynced(ids: List) +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/di/GamificationModule.kt b/app/src/main/java/org/piramalswasthya/sakhi/di/GamificationModule.kt new file mode 100644 index 000000000..2bfd0a664 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/di/GamificationModule.kt @@ -0,0 +1,19 @@ +package org.piramalswasthya.sakhi.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.piramalswasthya.sakhi.database.room.InAppDb +import org.piramalswasthya.sakhi.database.room.dao.GamificationDao +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object GamificationModule { + + @Provides + @Singleton + fun provideGamificationDao(db: InAppDb): GamificationDao = + db.gamificationDao +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/gamification/BadgeCatalog.kt b/app/src/main/java/org/piramalswasthya/sakhi/gamification/BadgeCatalog.kt new file mode 100644 index 000000000..e9a00e72e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/gamification/BadgeCatalog.kt @@ -0,0 +1,117 @@ +package org.piramalswasthya.sakhi.gamification + +/** + * Centralised catalogue of every badge the engine can award. + * + * Adding a new badge = one entry here + one rule in GamificationEngine. + * Nothing else changes. + * + * All three language names are stored here so the UI can render + * badges completely offline without any string resource lookup at runtime. + */ +object BadgeCatalog { + + data class BadgeDef( + val type: String, + val nameEn: String, + val nameHi: String, + val nameAs: String, + val descEn: String + ) + + // ── First-time badges ───────────────────────────────────────────────────── + + val FIRST_HOUSEHOLD = BadgeDef( + type = "FIRST_HOUSEHOLD", + nameEn = "First Home", + nameHi = "पहला घर", + nameAs = "প্ৰথম ঘৰ", + descEn = "Registered your first household" + ) + + val FIRST_BENEFICIARY = BadgeDef( + type = "FIRST_BENEFICIARY", + nameEn = "First Step", + nameHi = "पहला कदम", + nameAs = "প্ৰথম পদক্ষেপ", + descEn = "Registered your first beneficiary" + ) + + // ── Streak badges ───────────────────────────────────────────────────────── + + val STREAK_3 = BadgeDef( + type = "STREAK_3", + nameEn = "3-Day Streak", + nameHi = "3 दिन की लय", + nameAs = "3 দিনৰ ধাৰা", + descEn = "Active for 3 days in a row" + ) + + val STREAK_7 = BadgeDef( + type = "STREAK_7", + nameEn = "Week Warrior", + nameHi = "सप्ताह योद्धा", + nameAs = "সপ্তাহৰ যোদ্ধা", + descEn = "Active for 7 days in a row" + ) + + val STREAK_30 = BadgeDef( + type = "STREAK_30", + nameEn = "Monthly Champion", + nameHi = "मासिक चैंपियन", + nameAs = "মাহিলী চেম্পিয়ন", + descEn = "Active for 30 days in a row" + ) + + // ── Health activity badges ──────────────────────────────────────────────── + + val ANC_HERO = BadgeDef( + type = "ANC_HERO", + nameEn = "ANC Hero", + nameHi = "ANC नायक", + nameAs = "ANC বীৰ", + descEn = "Completed your first ANC visit" + ) + + val IMMUNIZATION_GUARDIAN = BadgeDef( + type = "IMMUNIZATION_GUARDIAN", + nameEn = "Immunization Guardian", + nameHi = "टीकाकरण संरक्षक", + nameAs = "টিকাকৰণৰ ৰক্ষক", + descEn = "Recorded your first immunization" + ) + + val HRP_IDENTIFIER = BadgeDef( + type = "HRP_IDENTIFIER", + nameEn = "Life Saver", + nameHi = "जीवन रक्षक", + nameAs = "জীৱন ৰক্ষক", + descEn = "Identified your first high-risk pregnancy" + ) + + val NCD_CHAMPION = BadgeDef( + type = "NCD_CHAMPION", + nameEn = "NCD Champion", + nameHi = "NCD चैंपियन", + nameAs = "NCD চেম্পিয়ন", + descEn = "Completed your first NCD screening" + ) + + // ── Level badges ────────────────────────────────────────────────────────── + + val LEVEL_2 = BadgeDef( + type = "LEVEL_2", + nameEn = "Rising Star", + nameHi = "उभरता सितारा", + nameAs = "উদীয়মান তৰা", + descEn = "Reached Level 2" + ) + + val LEVEL_5 = BadgeDef( + type = "LEVEL_5", + nameEn = "Health Champion", + nameHi = "स्वास्थ्य चैंपियन", + nameAs = "স্বাস্থ্য চেম্পিয়ন", + descEn = "Reached Level 5" + ) +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/gamification/GamificationEngine.kt b/app/src/main/java/org/piramalswasthya/sakhi/gamification/GamificationEngine.kt new file mode 100644 index 000000000..dd684f8dd --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/gamification/GamificationEngine.kt @@ -0,0 +1,253 @@ +package org.piramalswasthya.sakhi.gamification + +import org.piramalswasthya.sakhi.model.GamificationBadge +import org.piramalswasthya.sakhi.model.GamificationProfile +import org.piramalswasthya.sakhi.model.PointsTransaction +import org.piramalswasthya.sakhi.repositories.GamificationRepo +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Core gamification engine. + * + * Pure business logic — no Android framework dependencies except the + * injected repository. Fully unit-testable with a fake repository. + * + * ── Design decisions ────────────────────────────────────────────────────── + * + * WHY streaks over leaderboards: + * Leaderboards create pressure to record duplicate or false health data — + * a documented problem in community health worker digitisation programs. + * Streaks are personal and internally motivating; they don't require + * comparing against peers and work fully offline. + * + * WHY points are health-outcome weighted (not volume-weighted): + * HRP identification (35 pts) > Delivery outcome (30 pts) > ANC (20 pts) + * > Registration (10–15 pts) > Login (5 pts). + * This aligns the reward signal with actual health impact, not just + * form-filling speed. + * + * WHY java.util.Calendar instead of java.time.LocalDate: + * Min SDK is 25. java.time requires API 26+. Calendar works on all + * supported devices without desugaring configuration changes. + * ────────────────────────────────────────────────────────────────────────── + */ +@Singleton +class GamificationEngine @Inject constructor( + private val repo: GamificationRepo +) { + + companion object { + private val DATE_FMT = object : ThreadLocal() { + override fun initialValue() = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + } + + /** Points table. Weights reflect health-outcome importance. */ + private val POINTS_MAP = mapOf( + "HOUSEHOLD_REGISTERED" to 10, + "BENEFICIARY_REGISTERED" to 15, + "ANC_VISIT_COMPLETED" to 20, + "DELIVERY_OUTCOME_RECORDED" to 30, + "PNC_VISIT_COMPLETED" to 20, + "IMMUNIZATION_RECORDED" to 25, + "HRP_CASE_IDENTIFIED" to 35, // highest — saves maternal lives + "NCD_SCREENING_COMPLETED" to 20, + "CBAC_FORM_FILLED" to 15, + "DAILY_LOGIN" to 5 + ) + + /** + * XP thresholds for each level (index = level number). + * Level 1 starts at 0 XP. + */ + val LEVEL_THRESHOLDS = listOf( + 0, // Level 1 + 100, // Level 2 + 300, // Level 3 + 600, // Level 4 + 1000, // Level 5 + 1500, // Level 6 + 2200, // Level 7 + 3000 // Level 8 + ) + + private fun today(): String = DATE_FMT.get()!!.format(Date()) + + /** + * Returns the difference in calendar days between two ISO date strings. + * Uses Calendar to stay compatible with min SDK 25. + */ + fun daysBetween(from: String, to: String): Long { + return try { + val fromMs = Calendar.getInstance().apply { + time = DATE_FMT.get()!!.parse(from) ?: return 0L + }.timeInMillis + val toMs = Calendar.getInstance().apply { + time = DATE_FMT.get()!!.parse(to) ?: return 0L + }.timeInMillis + (toMs - fromMs) / (1000 * 60 * 60 * 24) + } catch (e: Exception) { + 0L + } + } + } + + /** + * Main entry point. Call after any successful health-worker action save. + * + * @param userId The ASHA worker's userId (matches User.userId: Int) + * @param event The gamification event that occurred + */ + suspend fun process(userId: Int, event: GamificationEvent) { + val points = POINTS_MAP[event.eventType] ?: return + val today = today() + + // 1. Load or create profile + val existing = repo.getProfile(userId) + ?: GamificationProfile(userId = userId, createdAt = System.currentTimeMillis()) + + // 2. Deduplicate daily login — only award once per calendar day + if (event is GamificationEvent.DailyLogin && + existing.lastLoginDate == today) return + + // 3. Update streak + val streakUpdated = updateStreak(existing, today) + + // 4. Award points + compute new level + val newTotal = streakUpdated.totalPoints + points + val newLevel = computeLevel(newTotal) + val leveledUp = newLevel > streakUpdated.level + + val finalProfile = streakUpdated.copy( + totalPoints = newTotal, + level = newLevel, + lastActivityDate = today, + lastLoginDate = if (event is GamificationEvent.DailyLogin) today else streakUpdated.lastLoginDate, + updatedAt = System.currentTimeMillis(), + syncState = 0 // mark dirty for sync + ) + + // 5. Persist — profile first, then transaction + repo.upsertProfile(finalProfile) + repo.insertTransaction( + PointsTransaction( + userId = userId, + points = points, + eventType = event.eventType, + eventRefId = event.refId, + reason = friendlyReason(event.eventType), + syncState = 0 + ) + ) + + // 6. Evaluate badges + evaluateBadges(userId, event, finalProfile, leveledUp) + } + + // ── Streak logic ────────────────────────────────────────────────────────── + + /** + * Updates streak counters based on [today] relative to [profile.lastActivityDate]. + * + * - Same day → no change (idempotent) + * - Next day → streak + 1 + * - Any gap → streak resets to 1 + */ + fun updateStreak(profile: GamificationProfile, today: String): GamificationProfile { + val last = profile.lastActivityDate + ?: return profile.copy(currentStreakDays = 1, longestStreakDays = 1) + + return when (daysBetween(last, today)) { + 0L -> profile // same day, no streak change + 1L -> { // consecutive day + val newStreak = profile.currentStreakDays + 1 + profile.copy( + currentStreakDays = newStreak, + longestStreakDays = maxOf(profile.longestStreakDays, newStreak) + ) + } + else -> profile.copy(currentStreakDays = 1) // streak broken + } + } + + // ── Level computation ───────────────────────────────────────────────────── + + fun computeLevel(totalPoints: Int): Int = + LEVEL_THRESHOLDS.indexOfLast { totalPoints >= it }.coerceAtLeast(0) + 1 + + // ── Badge evaluation ────────────────────────────────────────────────────── + + private suspend fun evaluateBadges( + userId: Int, + event: GamificationEvent, + profile: GamificationProfile, + leveledUp: Boolean + ) { + // First-time activity badges + if (event is GamificationEvent.HouseholdRegistered) + awardBadgeIfNew(userId, BadgeCatalog.FIRST_HOUSEHOLD) + + if (event is GamificationEvent.BeneficiaryRegistered) + awardBadgeIfNew(userId, BadgeCatalog.FIRST_BENEFICIARY) + + if (event is GamificationEvent.AncVisitCompleted) + awardBadgeIfNew(userId, BadgeCatalog.ANC_HERO) + + if (event is GamificationEvent.ImmunizationRecorded) + awardBadgeIfNew(userId, BadgeCatalog.IMMUNIZATION_GUARDIAN) + + if (event is GamificationEvent.HrpCaseIdentified) + awardBadgeIfNew(userId, BadgeCatalog.HRP_IDENTIFIER) + + if (event is GamificationEvent.NcdScreeningCompleted) + awardBadgeIfNew(userId, BadgeCatalog.NCD_CHAMPION) + + // Streak milestone badges + when (profile.currentStreakDays) { + 3 -> awardBadgeIfNew(userId, BadgeCatalog.STREAK_3) + 7 -> awardBadgeIfNew(userId, BadgeCatalog.STREAK_7) + 30 -> awardBadgeIfNew(userId, BadgeCatalog.STREAK_30) + } + + // Level-up badges + if (leveledUp) { + when (profile.level) { + 2 -> awardBadgeIfNew(userId, BadgeCatalog.LEVEL_2) + 5 -> awardBadgeIfNew(userId, BadgeCatalog.LEVEL_5) + } + } + } + + private suspend fun awardBadgeIfNew(userId: Int, def: BadgeCatalog.BadgeDef) { + if (!repo.hasBadge(userId, def.type)) { + repo.insertBadge( + GamificationBadge( + userId = userId, + badgeType = def.type, + badgeNameEn = def.nameEn, + badgeNameHi = def.nameHi, + badgeNameAs = def.nameAs, + syncState = 0 + ) + ) + } + } + + private fun friendlyReason(eventType: String) = when (eventType) { + "HOUSEHOLD_REGISTERED" -> "Household registration" + "BENEFICIARY_REGISTERED" -> "Beneficiary registration" + "ANC_VISIT_COMPLETED" -> "ANC visit completed" + "DELIVERY_OUTCOME_RECORDED" -> "Delivery outcome recorded" + "PNC_VISIT_COMPLETED" -> "PNC visit completed" + "IMMUNIZATION_RECORDED" -> "Immunization recorded" + "HRP_CASE_IDENTIFIED" -> "High-risk pregnancy identified" + "NCD_SCREENING_COMPLETED" -> "NCD screening completed" + "CBAC_FORM_FILLED" -> "CBAC form filled" + "DAILY_LOGIN" -> "Daily login bonus" + else -> eventType + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/gamification/GamificationEvent.kt b/app/src/main/java/org/piramalswasthya/sakhi/gamification/GamificationEvent.kt new file mode 100644 index 000000000..a17e1824c --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/gamification/GamificationEvent.kt @@ -0,0 +1,58 @@ +package org.piramalswasthya.sakhi.gamification + +/** + * Sealed hierarchy of every ASHA worker action that can trigger a reward. + * + * Health form code calls: + * gamificationEngine.process(userId, GamificationEvent.AncVisitCompleted(benId.toString())) + * + * The engine is the ONLY place that knows point values and badge rules. + * Health forms stay completely decoupled — they just fire an event. + * + * [refId] is optional — pass the beneficiary ID / visit ID / household ID + * for audit trail and backend deduplication. + */ +sealed class GamificationEvent( + val eventType: String, + val refId: String? = null +) { + // ── Household ───────────────────────────────────────────────────────────── + class HouseholdRegistered(refId: String) : + GamificationEvent("HOUSEHOLD_REGISTERED", refId) + + // ── Beneficiary ─────────────────────────────────────────────────────────── + class BeneficiaryRegistered(refId: String) : + GamificationEvent("BENEFICIARY_REGISTERED", refId) + + // ── Maternal Health ─────────────────────────────────────────────────────── + class AncVisitCompleted(refId: String) : + GamificationEvent("ANC_VISIT_COMPLETED", refId) + + class DeliveryOutcomeRecorded(refId: String) : + GamificationEvent("DELIVERY_OUTCOME_RECORDED", refId) + + class PncVisitCompleted(refId: String) : + GamificationEvent("PNC_VISIT_COMPLETED", refId) + + // ── Child Care ──────────────────────────────────────────────────────────── + class ImmunizationRecorded(refId: String) : + GamificationEvent("IMMUNIZATION_RECORDED", refId) + + // ── HRP ─────────────────────────────────────────────────────────────────── + /** High-risk pregnancy identification — highest point value by design. + * Early identification of HRP directly reduces maternal mortality. */ + class HrpCaseIdentified(refId: String) : + GamificationEvent("HRP_CASE_IDENTIFIED", refId) + + // ── NCD ─────────────────────────────────────────────────────────────────── + class NcdScreeningCompleted(refId: String) : + GamificationEvent("NCD_SCREENING_COMPLETED", refId) + + // ── CBAC ────────────────────────────────────────────────────────────────── + class CbacFormFilled(refId: String) : + GamificationEvent("CBAC_FORM_FILLED", refId) + + // ── Daily login ─────────────────────────────────────────────────────────── + /** Awarded once per calendar day. Engine deduplicates automatically. */ + object DailyLogin : GamificationEvent("DAILY_LOGIN") +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/GamificationBadge.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/GamificationBadge.kt new file mode 100644 index 000000000..0031bcc13 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/GamificationBadge.kt @@ -0,0 +1,61 @@ +package org.piramalswasthya.sakhi.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One row per badge earned. New rows inserted on unlock — never updated. + * Trilingual badge names stored directly so UI renders offline + * without any resource lookup. + * + * Foreign key to GAMIFICATION_PROFILE ensures badges are always + * tied to a valid worker profile. + */ +@Entity( + tableName = "GAMIFICATION_BADGE", + foreignKeys = [ + ForeignKey( + entity = GamificationProfile::class, + parentColumns = ["userId"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["userId"])] +) +data class GamificationBadge( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Long = 0, + + @ColumnInfo(name = "userId") + val userId: Int, + + /** + * Stable identifier for the badge type. + * e.g. "FIRST_REGISTRATION", "STREAK_7", "ANC_HERO" + * Used to prevent duplicate awards. + */ + @ColumnInfo(name = "badgeType") + val badgeType: String, + + @ColumnInfo(name = "badgeNameEn") + val badgeNameEn: String, + + @ColumnInfo(name = "badgeNameHi") + val badgeNameHi: String, + + @ColumnInfo(name = "badgeNameAs") + val badgeNameAs: String, + + @ColumnInfo(name = "earnedAt") + val earnedAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "syncState") + val syncState: Int = 0 +) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/GamificationProfile.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/GamificationProfile.kt new file mode 100644 index 000000000..03d431ece --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/GamificationProfile.kt @@ -0,0 +1,56 @@ +package org.piramalswasthya.sakhi.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Stores the ASHA worker's overall gamification state. + * One row per worker, keyed by userId (Int — matches User.userId). + * + * syncState matches the app-wide convention: + * 0 = UNSYNCED, 1 = SYNCING, 2 = SYNCED + */ +@Entity(tableName = "GAMIFICATION_PROFILE") +data class GamificationProfile( + + @PrimaryKey + @ColumnInfo(name = "userId") + val userId: Int, + + @ColumnInfo(name = "totalPoints") + val totalPoints: Int = 0, + + @ColumnInfo(name = "currentStreakDays") + val currentStreakDays: Int = 0, + + @ColumnInfo(name = "longestStreakDays") + val longestStreakDays: Int = 0, + + /** + * ISO date string "yyyy-MM-dd" of the last day the worker earned points. + * Null means the worker has never earned points yet. + */ + @ColumnInfo(name = "lastActivityDate") + val lastActivityDate: String? = null, + + /** + * ISO date string "yyyy-MM-dd" of the last day the worker claimed a daily login bonus. + * Used to deduplicate login bonuses to once per calendar day. + * Null means the worker has not claimed a login bonus yet. + */ + @ColumnInfo(name = "lastLoginDate") + val lastLoginDate: String? = null, + + @ColumnInfo(name = "level") + val level: Int = 1, + + @ColumnInfo(name = "createdAt") + val createdAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "updatedAt") + val updatedAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "syncState") + val syncState: Int = 0 +) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/model/PointsTransaction.kt b/app/src/main/java/org/piramalswasthya/sakhi/model/PointsTransaction.kt new file mode 100644 index 000000000..036fb5218 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/model/PointsTransaction.kt @@ -0,0 +1,64 @@ +package org.piramalswasthya.sakhi.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Immutable audit log — one row per points award event. + * Never updated after insert. + * + * Allows the backend to reconstruct gamification state + * from raw events if needed (event-sourcing friendly). + */ +@Entity( + tableName = "GAMIFICATION_POINTS_TX", + foreignKeys = [ + ForeignKey( + entity = GamificationProfile::class, + parentColumns = ["userId"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["userId"])] +) +data class PointsTransaction( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Long = 0, + + @ColumnInfo(name = "userId") + val userId: Int, + + @ColumnInfo(name = "points") + val points: Int, + + /** + * Mirrors GamificationEvent.eventType string. + * e.g. "ANC_VISIT_COMPLETED", "STREAK_BONUS" + */ + @ColumnInfo(name = "eventType") + val eventType: String, + + /** + * Optional reference ID — beneficiary ID, visit ID, household ID etc. + * Used for deduplication and audit on backend. + */ + @ColumnInfo(name = "eventRefId") + val eventRefId: String? = null, + + /** Human-readable English reason shown in the activity feed. */ + @ColumnInfo(name = "reason") + val reason: String, + + @ColumnInfo(name = "createdAt") + val createdAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "syncState") + val syncState: Int = 0 +) diff --git a/app/src/main/java/org/piramalswasthya/sakhi/repositories/GamificationRepo.kt b/app/src/main/java/org/piramalswasthya/sakhi/repositories/GamificationRepo.kt new file mode 100644 index 000000000..785d1265e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/repositories/GamificationRepo.kt @@ -0,0 +1,75 @@ +package org.piramalswasthya.sakhi.repositories + +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.sakhi.database.room.dao.GamificationDao +import org.piramalswasthya.sakhi.model.GamificationBadge +import org.piramalswasthya.sakhi.model.GamificationProfile +import org.piramalswasthya.sakhi.model.PointsTransaction +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single data-access point for all gamification state. + * + * Follows the same @Inject constructor + @Singleton pattern + * used by every other repository in this project. + * + * Network sync stubs are intentionally left as TODOs here — the + * GamificationSyncWorker calls this repo and will call the API + * once the backend endpoint is available from FLW-API service. + */ +@Singleton +class GamificationRepo @Inject constructor( + private val dao: GamificationDao +) { + + // ── Profile ─────────────────────────────────────────────────────────────── + + suspend fun getProfile(userId: Int): GamificationProfile? = + dao.getProfile(userId) + + suspend fun upsertProfile(profile: GamificationProfile) = + dao.upsertProfile(profile) + + fun observeProfile(userId: Int): Flow = + dao.observeProfile(userId) + + // ── Badges ──────────────────────────────────────────────────────────────── + + suspend fun insertBadge(badge: GamificationBadge): Long = + dao.insertBadge(badge) + + suspend fun hasBadge(userId: Int, badgeType: String): Boolean = + dao.hasBadge(userId, badgeType) > 0 + + fun observeBadges(userId: Int): Flow> = + dao.observeBadges(userId) + + // ── Transactions ────────────────────────────────────────────────────────── + + suspend fun insertTransaction(tx: PointsTransaction): Long = + dao.insertTransaction(tx) + + fun observeRecentTransactions(userId: Int): Flow> = + dao.observeRecentTransactions(userId) + + // ── Sync helpers ────────────────────────────────────────────────────────── + + suspend fun getUnsyncedProfiles(): List = + dao.getUnsyncedProfiles() + + suspend fun getUnsyncedBadges(): List = + dao.getUnsyncedBadges() + + suspend fun getUnsyncedTransactions(): List = + dao.getUnsyncedTransactions() + + suspend fun markProfileSynced(userId: Int) = + dao.markProfileSynced(userId) + + suspend fun markBadgesSynced(ids: List) = + dao.markBadgesSynced(ids) + + suspend fun markTransactionsSynced(ids: List) = + dao.markTransactionsSynced(ids) +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/BadgeAdapter.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/BadgeAdapter.kt new file mode 100644 index 000000000..4fbab6254 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/BadgeAdapter.kt @@ -0,0 +1,61 @@ +package org.piramalswasthya.sakhi.ui.home_activity.gamification + +import android.content.Context +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.databinding.ItemBadgeBinding +import org.piramalswasthya.sakhi.helpers.Languages +import org.piramalswasthya.sakhi.model.GamificationBadge +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao + +class BadgeAdapter : ListAdapter(DiffCallback) { + + var language: String = "en" + + inner class BadgeViewHolder(private val binding: ItemBadgeBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(badge: GamificationBadge) { + binding.tvBadgeName.text = when (language) { + "hi" -> badge.badgeNameHi + "as" -> badge.badgeNameAs + else -> badge.badgeNameEn + } + binding.tvBadgeEmoji.text = emojiForBadge(badge.badgeType) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BadgeViewHolder { + val binding = ItemBadgeBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return BadgeViewHolder(binding) + } + + override fun onBindViewHolder(holder: BadgeViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + private fun emojiForBadge(badgeType: String): String = when (badgeType) { + "FIRST_HOUSEHOLD" -> "🏠" + "FIRST_BENEFICIARY" -> "👤" + "STREAK_3" -> "🔥" + "STREAK_7" -> "⚡" + "STREAK_30" -> "🏆" + "ANC_HERO" -> "🤰" + "IMMUNIZATION_GUARDIAN" -> "💉" + "HRP_IDENTIFIER" -> "❤️" + "NCD_CHAMPION" -> "🩺" + "LEVEL_2" -> "⭐" + "LEVEL_5" -> "🌟" + else -> "🏅" + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(a: GamificationBadge, b: GamificationBadge) = a.id == b.id + override fun areContentsTheSame(a: GamificationBadge, b: GamificationBadge) = a == b + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/GamificationDashboardFragment.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/GamificationDashboardFragment.kt new file mode 100644 index 000000000..a9a4b50e8 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/GamificationDashboardFragment.kt @@ -0,0 +1,128 @@ +package org.piramalswasthya.sakhi.ui.home_activity.gamification + +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.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.piramalswasthya.sakhi.R +import org.piramalswasthya.sakhi.databinding.FragmentGamificationDashboardBinding +import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao +import org.piramalswasthya.sakhi.ui.home_activity.HomeActivity +import javax.inject.Inject + +@AndroidEntryPoint +class GamificationDashboardFragment : Fragment() { + + @Inject + lateinit var preferenceDao: PreferenceDao + + private val viewModel: GamificationViewModel by viewModels() + + private var _binding: FragmentGamificationDashboardBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamificationDashboardBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val userId = preferenceDao.getLoggedInUser()?.userId ?: return + viewModel.init(userId) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + renderProfile(state) + renderBadges(state) + renderActivity(state) + } + } + } + } + + private fun renderProfile(state: GamificationUiState) { + val profile = state.profile + binding.tvTotalPoints.text = (profile?.totalPoints ?: 0).toString() + binding.tvLevel.text = getString(R.string.gamification_level, profile?.level ?: 1) + + val streak = profile?.currentStreakDays ?: 0 + binding.tvStreak.text = resources.getQuantityString( + R.plurals.gamification_streak_days, streak, streak + ) + + // XP progress bar within the current level + val currentLevelThreshold = levelThreshold(profile?.level ?: 1) + val nextLevelThreshold = levelThreshold((profile?.level ?: 1) + 1) + val progress = if (nextLevelThreshold > currentLevelThreshold) { + val pointsIntoLevel = (profile?.totalPoints ?: 0) - currentLevelThreshold + val pointsForLevel = nextLevelThreshold - currentLevelThreshold + ((pointsIntoLevel.toFloat() / pointsForLevel) * 100).toInt().coerceIn(0, 100) + } else 100 + binding.progressXp.progress = progress + } + + private fun renderBadges(state: GamificationUiState) { + if (state.badges.isEmpty()) { + binding.tvNoBadges.visibility = View.VISIBLE + binding.rvBadges.visibility = View.GONE + } else { + binding.tvNoBadges.visibility = View.GONE + binding.rvBadges.visibility = View.VISIBLE + + val lang = preferenceDao.getCurrentLanguage().symbol + val adapter = binding.rvBadges.adapter as? BadgeAdapter + ?: BadgeAdapter().also { + it.language = lang + binding.rvBadges.adapter = it + } + adapter.submitList(state.badges) + } + } + + private fun renderActivity(state: GamificationUiState) { + if (state.recentTransactions.isEmpty()) { + binding.tvNoActivity.visibility = View.VISIBLE + binding.rvActivity.visibility = View.GONE + } else { + binding.tvNoActivity.visibility = View.GONE + binding.rvActivity.visibility = View.VISIBLE + + val adapter = binding.rvActivity.adapter as? PointsTransactionAdapter + ?: PointsTransactionAdapter().also { binding.rvActivity.adapter = it } + adapter.submitList(state.recentTransactions) + } + } + + private fun levelThreshold(level: Int): Int = + GamificationEngine.LEVEL_THRESHOLDS.getOrElse(level - 1) { + GamificationEngine.LEVEL_THRESHOLDS.last() + } + + override fun onStart() { + super.onStart() + activity?.let { + (it as HomeActivity).updateActionBar( + R.drawable.ic_gamification, + getString(R.string.gamification_dashboard_title) + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/GamificationViewModel.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/GamificationViewModel.kt new file mode 100644 index 000000000..8072ec83d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/GamificationViewModel.kt @@ -0,0 +1,71 @@ +package org.piramalswasthya.sakhi.ui.home_activity.gamification + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.piramalswasthya.sakhi.gamification.GamificationEngine +import org.piramalswasthya.sakhi.gamification.GamificationEvent +import org.piramalswasthya.sakhi.model.GamificationBadge +import org.piramalswasthya.sakhi.model.GamificationProfile +import org.piramalswasthya.sakhi.model.PointsTransaction +import org.piramalswasthya.sakhi.repositories.GamificationRepo +import javax.inject.Inject + +data class GamificationUiState( + val profile: GamificationProfile? = null, + val badges: List = emptyList(), + val recentTransactions: List = emptyList() +) + +@HiltViewModel +class GamificationViewModel @Inject constructor( + private val repo: GamificationRepo, + private val engine: GamificationEngine +) : ViewModel() { + + private val _userId = MutableStateFlow(null) + + val uiState: StateFlow = _userId + .filterNotNull() + .flatMapLatest { uid -> + combine( + repo.observeProfile(uid), + repo.observeBadges(uid), + repo.observeRecentTransactions(uid) + ) { profile, badges, transactions -> + GamificationUiState( + profile = profile, + badges = badges, + recentTransactions = transactions + ) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = GamificationUiState() + ) + + fun init(userId: Int) { + _userId.value = userId + } + + /** + * Called by health-form screens after a successful save. + * Example usage from an ANC form: + * gamificationViewModel.onHealthEvent(userId, GamificationEvent.AncVisitCompleted(benId.toString())) + */ + fun onHealthEvent(userId: Int, event: GamificationEvent) { + viewModelScope.launch { + engine.process(userId, event) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/PointsTransactionAdapter.kt b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/PointsTransactionAdapter.kt new file mode 100644 index 000000000..3d1ef6a87 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/ui/home_activity/gamification/PointsTransactionAdapter.kt @@ -0,0 +1,43 @@ +package org.piramalswasthya.sakhi.ui.home_activity.gamification + +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.databinding.ItemPointsTransactionBinding +import org.piramalswasthya.sakhi.model.PointsTransaction +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class PointsTransactionAdapter : + ListAdapter(DiffCallback) { + + inner class TxViewHolder(private val binding: ItemPointsTransactionBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(tx: PointsTransaction) { + binding.tvReason.text = tx.reason + binding.tvPoints.text = "+${tx.points} XP" + binding.tvDate.text = SimpleDateFormat("dd MMM", Locale.ENGLISH) + .format(Date(tx.createdAt)) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TxViewHolder { + val binding = ItemPointsTransactionBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return TxViewHolder(binding) + } + + override fun onBindViewHolder(holder: TxViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(a: PointsTransaction, b: PointsTransaction) = a.id == b.id + override fun areContentsTheSame(a: PointsTransaction, b: PointsTransaction) = a == b + } +} diff --git a/app/src/main/java/org/piramalswasthya/sakhi/work/GamificationSyncWorker.kt b/app/src/main/java/org/piramalswasthya/sakhi/work/GamificationSyncWorker.kt new file mode 100644 index 000000000..40d35fb36 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/sakhi/work/GamificationSyncWorker.kt @@ -0,0 +1,107 @@ +package org.piramalswasthya.sakhi.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.piramalswasthya.sakhi.repositories.GamificationRepo +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Syncs unsynced gamification state (profiles, badges, transactions) + * to the AMRIT backend when connectivity is restored. + * + * Runs every 6 hours, network-constrained, with exponential backoff. + * Matches the app's existing offline-first WorkManager sync architecture. + * + * TODO: Replace stub sync calls with actual API calls once the + * gamification endpoints are available in FLW-API service. + * Endpoint contract is documented in docs/gamification/API_CONTRACT.md + */ +@HiltWorker +class GamificationSyncWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val repo: GamificationRepo +) : CoroutineWorker(context, params) { + + companion object { + const val WORK_NAME = "GamificationSyncWorker" + + fun buildRequest(): PeriodicWorkRequest = + PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15, + TimeUnit.MINUTES + ) + .build() + + /** + * Safe to call multiple times (e.g. on every login). + * KEEP_EXISTING prevents duplicate workers. + */ + fun schedule(workManager: WorkManager) { + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + buildRequest() + ) + } + } + + override suspend fun doWork(): Result { + Timber.d("GamificationSyncWorker: starting sync") + return try { + syncProfiles() + syncBadges() + syncTransactions() + Timber.d("GamificationSyncWorker: sync complete") + Result.success() + } catch (e: Exception) { + Timber.e(e, "GamificationSyncWorker: sync failed") + if (runAttemptCount < 3) Result.retry() else Result.failure() + } + } + + private suspend fun syncProfiles() { + val unsynced = repo.getUnsyncedProfiles() + if (unsynced.isEmpty()) return + Timber.d("GamificationSyncWorker: ${unsynced.size} profiles pending sync") + // NOTE: Records are NOT marked synced here. + // They will be marked synced only after confirmed API success. + // TODO: apiService.syncGamificationProfiles(unsynced) + // repo.markProfileSynced(it.userId) — call AFTER API success + } + + private suspend fun syncBadges() { + val unsynced = repo.getUnsyncedBadges() + if (unsynced.isEmpty()) return + Timber.d("GamificationSyncWorker: ${unsynced.size} badges pending sync") + // TODO: apiService.syncGamificationBadges(unsynced) + // repo.markBadgesSynced(unsynced.map { it.id }) — call AFTER API success + } + + private suspend fun syncTransactions() { + val unsynced = repo.getUnsyncedTransactions() + if (unsynced.isEmpty()) return + Timber.d("GamificationSyncWorker: ${unsynced.size} transactions pending sync") + // TODO: apiService.syncGamificationTransactions(unsynced) + // repo.markTransactionsSynced(unsynced.map { it.id }) — call AFTER API success + } +} diff --git a/app/src/main/res/drawable/bg_level_chip.xml b/app/src/main/res/drawable/bg_level_chip.xml new file mode 100644 index 000000000..7fe5021a2 --- /dev/null +++ b/app/src/main/res/drawable/bg_level_chip.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_gamification_dashboard.xml b/app/src/main/res/layout/fragment_gamification_dashboard.xml new file mode 100644 index 000000000..016098239 --- /dev/null +++ b/app/src/main/res/layout/fragment_gamification_dashboard.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_badge.xml b/app/src/main/res/layout/item_badge.xml new file mode 100644 index 000000000..939e1c7df --- /dev/null +++ b/app/src/main/res/layout/item_badge.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_points_transaction.xml b/app/src/main/res/layout/item_points_transaction.xml new file mode 100644 index 000000000..ef89ba8bd --- /dev/null +++ b/app/src/main/res/layout/item_points_transaction.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-as/strings_gamification.xml b/app/src/main/res/values-as/strings_gamification.xml new file mode 100644 index 000000000..164ef3773 --- /dev/null +++ b/app/src/main/res/values-as/strings_gamification.xml @@ -0,0 +1,18 @@ + + + + মোৰ অগ্ৰগতি + স্তৰ %1$d + মুঠ XP + সক্ৰিয় ধাৰা + অৰ্জিত বেজ + শেহতীয়া কাৰ্যকলাপ + 🏅 বেজ অৰ্জিত: %1$s + +%1$d XP + প্ৰথম বেজ অৰ্জন কৰিবলৈ কাৰ্য সম্পূৰ্ণ কৰক! + + + %d দিনৰ ধাৰা 🔥 + %d দিনৰ ধাৰা 🔥 + + diff --git a/app/src/main/res/values-hi/strings_gamification.xml b/app/src/main/res/values-hi/strings_gamification.xml new file mode 100644 index 000000000..ebe8f85e2 --- /dev/null +++ b/app/src/main/res/values-hi/strings_gamification.xml @@ -0,0 +1,18 @@ + + + + मेरी प्रगति + स्तर %1$d + कुल XP + सक्रिय लय + अर्जित बैज + हाल की गतिविधि + 🏅 बैज अर्जित: %1$s + +%1$d XP + पहला बैज अर्जित करने के लिए कार्य पूरे करें! + + + %d दिन की लय 🔥 + %d दिन की लय 🔥 + + diff --git a/app/src/main/res/values/strings_gamification.xml b/app/src/main/res/values/strings_gamification.xml new file mode 100644 index 000000000..a4f8c4e32 --- /dev/null +++ b/app/src/main/res/values/strings_gamification.xml @@ -0,0 +1,18 @@ + + + + My Progress + Level %1$d + Total XP + Active Streak + Badges Earned + Recent Activity + 🏅 Badge earned: %1$s + +%1$d XP + Complete tasks to earn your first badge! + + + %d day streak 🔥 + %d day streak 🔥 + + diff --git a/app/src/test/java/org/piramalswasthya/sakhi/gamification/GamificationEngineTest.kt b/app/src/test/java/org/piramalswasthya/sakhi/gamification/GamificationEngineTest.kt new file mode 100644 index 000000000..b6a11e0c7 --- /dev/null +++ b/app/src/test/java/org/piramalswasthya/sakhi/gamification/GamificationEngineTest.kt @@ -0,0 +1,33 @@ +package org.piramalswasthya.sakhi.gamification + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GamificationEngineStreakTest { + + @Test + fun `daysBetween same date returns 0`() = + assertEquals(0L, GamificationEngine.daysBetween("2026-05-09", "2026-05-09")) + + @Test + fun `daysBetween consecutive days returns 1`() = + assertEquals(1L, GamificationEngine.daysBetween("2026-05-08", "2026-05-09")) + + @Test + fun `daysBetween month boundary works`() = + assertEquals(1L, GamificationEngine.daysBetween("2026-04-30", "2026-05-01")) + + @Test + fun `computeLevel at 0 points is level 1`() { + val thresholds = GamificationEngine.LEVEL_THRESHOLDS + val level = thresholds.indexOfLast { 0 >= it }.coerceAtLeast(0) + 1 + assertEquals(1, level) + } + + @Test + fun `computeLevel at 100 points is level 2`() { + val thresholds = GamificationEngine.LEVEL_THRESHOLDS + val level = thresholds.indexOfLast { 100 >= it }.coerceAtLeast(0) + 1 + assertEquals(2, level) + } +}