diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c59942be7..6c25c182f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -78,6 +78,10 @@
android:name="com.jnj.vaccinetracker.reportsoverview.vaccinesoverview.activity.VaccinesOverviewFlowActivity"
android:windowSoftInputMode="adjustResize" />
+
+
(DiffCallback) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = ItemHmis105ReportRowBinding.inflate(inflater, parent, false)
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.bind(item)
+ }
+
+ inner class ViewHolder(private val binding: ItemHmis105ReportRowBinding) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(report: Hmis105ReportDTO) {
+ binding.textViewDoses.text = report.doses
+ if (isSectionHeader(report)) {
+ setNumericViewsVisibility(View.GONE)
+ binding.textViewUnder1Static.text = ""
+ binding.textViewUnder1Outreach.text = ""
+ binding.textView1to4Static.text = ""
+ binding.textView1to4Outreach.text = ""
+ binding.textView5to14Static.text = ""
+ binding.textView5to14Outreach.text = ""
+ binding.textViewTotal.text = ""
+ } else {
+ setNumericViewsVisibility(View.VISIBLE)
+ binding.textViewUnder1Static.text = report.under1Static.toString()
+ binding.textViewUnder1Outreach.text = report.under1Outreach.toString()
+ binding.textView1to4Static.text = report.age1to4Static.toString()
+ binding.textView1to4Outreach.text = report.age1to4Outreach.toString()
+ binding.textView5to14Static.text = report.age5to14Static.toString()
+ binding.textView5to14Outreach.text = report.age5to14Outreach.toString()
+ binding.textViewTotal.text = report.total.toString()
+ }
+ }
+
+ private fun setNumericViewsVisibility(visibility: Int) {
+ binding.textViewUnder1Static.visibility = visibility
+ binding.textViewUnder1Outreach.visibility = visibility
+ binding.textView1to4Static.visibility = visibility
+ binding.textView1to4Outreach.visibility = visibility
+ binding.textView5to14Static.visibility = visibility
+ binding.textView5to14Outreach.visibility = visibility
+ binding.textViewTotal.visibility = visibility
+ }
+
+ private fun isSectionHeader(report: Hmis105ReportDTO): Boolean {
+ return report.under1Static == 0 &&
+ report.under1Outreach == 0 &&
+ report.age1to4Static == 0 &&
+ report.age1to4Outreach == 0 &&
+ report.age5to14Static == 0 &&
+ report.age5to14Outreach == 0 &&
+ report.total == 0 &&
+ report.doses == report.doses.uppercase()
+ }
+ }
+
+ companion object DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: Hmis105ReportDTO,
+ newItem: Hmis105ReportDTO
+ ): Boolean = oldItem.doses == newItem.doses
+
+ override fun areContentsTheSame(
+ oldItem: Hmis105ReportDTO,
+ newItem: Hmis105ReportDTO
+ ): Boolean = oldItem == newItem
+ }
+}
+
diff --git a/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/dto/Hmis105ReportDTO.kt b/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/dto/Hmis105ReportDTO.kt
new file mode 100644
index 000000000..5b00c2537
--- /dev/null
+++ b/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/dto/Hmis105ReportDTO.kt
@@ -0,0 +1,13 @@
+package com.jnj.vaccinetracker.reportsoverview.hmis105.dto
+
+data class Hmis105ReportDTO(
+ val doses: String,
+ val under1Static: Int = 0,
+ val under1Outreach: Int = 0,
+ val age1to4Static: Int = 0,
+ val age1to4Outreach: Int = 0,
+ val age5to14Static: Int = 0,
+ val age5to14Outreach: Int = 0,
+ val total: Int = 0
+)
+
diff --git a/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/model/Hmis105ViewModel.kt b/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/model/Hmis105ViewModel.kt
new file mode 100644
index 000000000..e1b7d8633
--- /dev/null
+++ b/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/model/Hmis105ViewModel.kt
@@ -0,0 +1,485 @@
+package com.jnj.vaccinetracker.reportsoverview.hmis105.model
+
+import android.os.Bundle
+import android.util.Log
+import androidx.annotation.StringRes
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import com.jnj.vaccinetracker.R
+import com.jnj.vaccinetracker.common.data.database.repositories.VisitRepository
+import com.jnj.vaccinetracker.common.data.database.typealiases.addDaysToDate
+import com.jnj.vaccinetracker.common.data.database.typealiases.getTodayMidnight
+import com.jnj.vaccinetracker.common.data.models.Constants
+import com.jnj.vaccinetracker.common.data.models.NavigationDirection
+import com.jnj.vaccinetracker.common.data.repositories.UserRepository
+import com.jnj.vaccinetracker.common.domain.entities.BirthDate
+import com.jnj.vaccinetracker.common.domain.entities.ParticipantBase
+import com.jnj.vaccinetracker.common.domain.entities.Visit
+import com.jnj.vaccinetracker.common.domain.usecases.FindParticipantByParticipantUuidUseCase
+import com.jnj.vaccinetracker.common.helpers.AppCoroutineDispatchers
+import com.jnj.vaccinetracker.common.viewmodel.ViewModelWithState
+import com.jnj.vaccinetracker.reportsoverview.hmis105.dto.Hmis105ReportDTO
+import com.soywiz.klock.DateTime
+import com.soywiz.klock.jvm.toDate
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Date
+import javax.inject.Inject
+
+class Hmis105ViewModel @Inject constructor(
+ userRepository: UserRepository,
+ private val visitRepository: VisitRepository,
+ private val findParticipantByParticipantUuidUseCase: FindParticipantByParticipantUuidUseCase,
+ override val dispatchers: AppCoroutineDispatchers
+) : ViewModelWithState() {
+
+ val reportDTOs = mutableLiveData>(emptyList())
+ val isLoading = mutableLiveData(false)
+ val currentScreen = mutableLiveData()
+ val selectedStartDate = MutableLiveData(null)
+ val selectedEndDate = MutableLiveData(null)
+ var navigationDirection = NavigationDirection.NONE
+
+ private var screens = listOf()
+ private val currentLocationUuid = userRepository.getDeviceNameSiteUuid()
+
+ companion object {
+ private val HMIS105_VACCINES = mapOf(
+ "BCG Vxnaid Date" to "CL01. BCG",
+ "Hep B BD Vxnaid Date" to "CL02. Hep B BD",
+ "PAB for Td Vxnaid Date" to "CL03. PAB for Td",
+ "Polio 0 Vxnaid Date" to "CL04. Polio 0",
+ "Polio 1 Vxnaid Date" to "CL05. Polio 1",
+ "Polio 2 Vxnaid Date" to "CL06. Polio 2",
+ "Polio 3 Vxnaid Date" to "CL07. Polio 3",
+ "IPV 1 Vxnaid Date" to "CL08. IPV 1",
+ "IPV 2 Vxnaid Date" to "CL09. IPV 2",
+ "DPT-HepB-Hib 1 Vxnaid Date" to "CL10. DPT-HepB-Hib 1",
+ "DPT-HepB-Hib 2 Vxnaid Date" to "CL11. DPT-HepB-Hib 2",
+ "DPT-HepB-Hib 3 Vxnaid Date" to "CL12. DPT-HepB-Hib 3",
+ "PCV 1 Vxnaid Date" to "CL13. PCV 1",
+ "PCV 2 Vxnaid Date" to "CL14. PCV 2",
+ "PCV 3 Vxnaid Date" to "CL15. PCV 3",
+ "Rota 1 Vxnaid Date" to "CL16. Rota 1",
+ "Rota 2 Vxnaid Date" to "CL17. Rota 2",
+ "Rota 3 Vxnaid Date" to "CL18. Rota 3",
+ "Yellow Fever Vxnaid Date" to "CL22. Yellow Fever",
+ "Measles Rubella 1 (MR1) Vxnaid Date" to "CL23. Measles Rubella 1 (MR1)"
+ )
+
+ private const val KEY_MR1 = "Measles Rubella 1 (MR1) Vxnaid Date"
+ private const val KEY_MR2 = "Measles Rubella 2 (MR2) Vxnaid Date"
+ private const val KEY_YELLOW_FEVER = "Yellow Fever Vxnaid Date"
+ private const val UUID_LLINS = "6de53ec6-bf3f-41fe-bf2e-e61447a6557a"
+ }
+
+ init {
+ initScreens()
+ }
+
+ fun getHMIS105Data(startDate: DateTime?, endDate: DateTime?) {
+ Log.d("Hmis105ViewModel", "getHMIS105Data called")
+ isLoading.value = true
+ viewModelScope.launch {
+ try {
+ val start = startDate?.toDate() ?: getTodayMidnight()
+ val end = endDate?.toDate() ?: addDaysToDate(getTodayMidnight(), 1)
+
+ Log.d("Hmis105ViewModel", "Loading report data from $start to $end")
+
+ val data = withContext(dispatchers.io) {
+ try {
+ val occurredVisits = visitRepository
+ .findAllVisitsByAttributeTypeAndValue(
+ Constants.ATTRIBUTE_VISIT_STATUS,
+ Constants.VISIT_STATUS_OCCURRED
+ )
+ val participantsMap = buildParticipantsMap(occurredVisits)
+ val candidateVisits = occurredVisits.filter { visit ->
+ participantsMap[visit.participantUuid]?.locationUuid == currentLocationUuid
+ }
+ val allVisits = candidateVisits.filter { visit ->
+ visit.observations.values.any { obsValue ->
+ obsValue.dateTime.time in start.time until end.time
+ }
+ }
+
+ Log.d("Hmis105ViewModel", "Total visits found: ${allVisits.size}")
+
+ val allKeys = allVisits.flatMap { it.observations.keys }.toSet()
+ Log.d("Hmis105ViewModel", "All obs keys in filtered visits: $allKeys")
+ val reportData = mutableListOf()
+
+ reportData.addAll(createHmis105ReportDTOList(allVisits, participantsMap, start, end))
+ reportData.add(createFullyImmunized1Year(allVisits, participantsMap, start, end))
+ reportData.add(createLLINSReport(allVisits, participantsMap, start, end))
+ reportData.add(Hmis105ReportDTO(doses = "SECOND YEAR OF LIFE"))
+ reportData.add(createMR2Report(allVisits, participantsMap, start, end))
+ reportData.add(createFullyImmunized2Years(allVisits, participantsMap, start, end))
+ reportData
+ } catch (ex: Exception) {
+ Log.e("Hmis105ViewModel", "Repository error: ${ex.message}", ex)
+ emptyList()
+ }
+ }
+
+ Log.d("Hmis105ViewModel", "Report data loaded: ${data.size} rows")
+ reportDTOs.value = data
+ isLoading.value = false
+ } catch (ex: Exception) {
+ Log.e("Hmis105ViewModel", "Unexpected error loading report data", ex)
+ reportDTOs.value = emptyList()
+ isLoading.value = false
+ }
+ }
+ }
+
+ private suspend fun buildParticipantsMap(visits: List): Map {
+ val map = mutableMapOf()
+ for (visit in visits) {
+ if (!map.containsKey(visit.participantUuid)) {
+ map[visit.participantUuid] =
+ findParticipantByParticipantUuidUseCase.findByParticipantUuid(visit.participantUuid)
+ }
+ }
+ return map
+ }
+
+ private fun calculateAgeInMonthsAt(birthDate: BirthDate, referenceDate: DateTime): Int {
+ val birth = birthDate.toDateTime()
+ return (referenceDate.yearInt - birth.yearInt) * 12 +
+ (referenceDate.month0 - birth.month0)
+ }
+
+ private fun calculateAgeInYearsAt(birthDate: BirthDate, referenceDate: DateTime): Int {
+ return calculateAgeInMonthsAt(birthDate, referenceDate) / 12
+ }
+
+ private fun calculateChildAgeGroupAt(birthDate: BirthDate, visitDate: DateTime): String {
+ val ageInMonths = calculateAgeInMonthsAt(birthDate, visitDate)
+ val ageInYears = calculateAgeInYearsAt(birthDate, visitDate)
+ return when {
+ ageInMonths in 0..11 -> Constants.GROUP_AGE_FIRST
+ ageInMonths in 12..59 -> Constants.GROUP_AGE_SECOND
+ ageInYears in 5..14 -> Constants.GROUP_AGE_THIRD
+ else -> Constants.GROUP_AGE_FOURTH
+ }
+ }
+
+ private fun createHmis105ReportDTOList(
+ visits: List,
+ participantsMap: Map,
+ startDate: Date,
+ endDate: Date
+ ): List {
+
+ val reportRowsMap = mutableMapOf()
+ val nowDateTime = DateTime.now() // Use current time for age, matching SQL
+
+ for (visit in visits) {
+ val participant = participantsMap[visit.participantUuid] ?: continue
+
+ for ((key, obsValue) in visit.observations) {
+ if (obsValue.dateTime.time !in startDate.time until endDate.time) {
+ continue
+ }
+
+ val hmisLabel = HMIS105_VACCINES[key] ?: continue
+ val ageGroup = calculateChildAgeGroupAt(participant.birthDate, nowDateTime)
+ val visitLocation = visit.visitLocation
+
+ updateReportRow(reportRowsMap, hmisLabel, ageGroup, visitLocation)
+ Log.d("Hmis105ViewModel", "Mapped [$key] → [$hmisLabel] | age: $ageGroup | loc: $visitLocation")
+ }
+ }
+ return reportRowsMap.values.toList().sortedBy { it.doses }
+ }
+ private fun createFullyImmunized1Year(
+ visits: List,
+ participantsMap: Map,
+ startDate: Date,
+ endDate: Date
+ ): Hmis105ReportDTO {
+
+ val yellowFeverRecipients = visits
+ .filter { visit ->
+ visit.observations.any { (key, obsValue) ->
+ key == KEY_YELLOW_FEVER && obsValue.dateTime.time in startDate.time until endDate.time
+ }
+ }
+ .map { it.participantUuid }
+ .toSet()
+
+ val mr1Recipients = visits
+ .filter { visit ->
+ visit.observations.any { (key, obsValue) ->
+ key == KEY_MR1 && obsValue.dateTime.time in startDate.time until endDate.time
+ }
+ }
+ .map { it.participantUuid }
+ .toSet()
+
+ Log.d("Hmis105ViewModel", "CL24 Yellow Fever recipients: ${yellowFeverRecipients.size}")
+ Log.d("Hmis105ViewModel", "CL24 MR1 recipients: ${mr1Recipients.size}")
+
+ // Must have received BOTH vaccines (intersection)
+ val bothVaccinesUuids = yellowFeverRecipients.intersect(mr1Recipients)
+ Log.d("Hmis105ViewModel", "CL24 received both YF + MR1: ${bothVaccinesUuids.size}")
+
+ var under1Static = 0
+ var under1Outreach = 0
+
+ for (participantUuid in bothVaccinesUuids) {
+ val participant = participantsMap[participantUuid] ?: continue
+
+ val mr1Visit = visits.firstOrNull { visit ->
+ visit.participantUuid == participantUuid &&
+ visit.observations.any { (key, obsValue) ->
+ key == KEY_MR1 && obsValue.dateTime.time in startDate.time until endDate.time
+ }
+ } ?: continue
+
+ val mr1ObsValue = mr1Visit.observations[KEY_MR1] ?: continue
+ val visitDateTime = DateTime(mr1ObsValue.dateTime.time)
+ val ageAtMR1 = calculateAgeInMonthsAt(participant.birthDate, visitDateTime)
+
+ if (ageAtMR1 !in 8..12) {
+ Log.d("Hmis105ViewModel", "CL24 skip $participantUuid — age at MR1 was $ageAtMR1 months")
+ continue
+ }
+
+ when (mr1Visit.visitLocation) {
+ Constants.VISIT_PLACE_STATIC -> under1Static++
+ Constants.VISIT_PLACE_OUTREACH,
+ Constants.VISIT_PLACE_SCHOOL -> under1Outreach++
+ else -> under1Static++
+ }
+ }
+
+ Log.d("Hmis105ViewModel", "CL24 Final — Static: $under1Static, Outreach: $under1Outreach")
+ return Hmis105ReportDTO(
+ doses = "CL24. Fully immunized by 1 year",
+ under1Static = under1Static,
+ under1Outreach = under1Outreach,
+ total = under1Static + under1Outreach
+ )
+ }
+
+ private fun createLLINSReport(
+ visits: List,
+ participantsMap: Map,
+ startDate: Date,
+ endDate: Date
+ ): Hmis105ReportDTO {
+
+ var under1Static = 0
+ var under1Outreach = 0
+
+ for (visit in visits) {
+ val participant = participantsMap[visit.participantUuid] ?: continue
+ val llinsEntry = visit.observations.entries.firstOrNull { (key, obs) ->
+ (key.contains(UUID_LLINS, ignoreCase = true) ||
+ key.contains("LLIN", ignoreCase = true)) &&
+ obs.value.trim().equals("yes", ignoreCase = true) &&
+ obs.dateTime.time in startDate.time until endDate.time
+ } ?: continue
+
+ val obsDateTime = DateTime(llinsEntry.value.dateTime.time)
+ val ageInMonths = calculateAgeInMonthsAt(participant.birthDate, obsDateTime)
+ if (ageInMonths !in 0..11) continue
+
+ when (visit.visitLocation) {
+ Constants.VISIT_PLACE_STATIC -> under1Static++
+ Constants.VISIT_PLACE_OUTREACH,
+ Constants.VISIT_PLACE_SCHOOL -> under1Outreach++
+ }
+ }
+
+ Log.d("Hmis105ViewModel", "CL25 LLINs — Static: $under1Static, Outreach: $under1Outreach")
+ return Hmis105ReportDTO(
+ doses = "CL25. No. received LLINs",
+ under1Static = under1Static,
+ under1Outreach = under1Outreach,
+ total = under1Static + under1Outreach
+ )
+ }
+
+ private fun createMR2Report(
+ visits: List,
+ participantsMap: Map,
+ startDate: Date,
+ endDate: Date
+ ): Hmis105ReportDTO {
+
+ var age1to4Static = 0
+ var age1to4Outreach = 0
+ var age5to14Static = 0
+ var age5to14Outreach = 0
+
+ for (visit in visits) {
+ val mr2Obs = visit.observations[KEY_MR2]?.takeIf {
+ it.dateTime.time in startDate.time until endDate.time
+ } ?: continue
+
+ val participant = participantsMap[visit.participantUuid] ?: continue
+ val mr2DateTime = DateTime(mr2Obs.dateTime.time)
+ val ageGroup = calculateChildAgeGroupAt(participant.birthDate, mr2DateTime)
+ val visitLocation = visit.visitLocation
+
+ when {
+ ageGroup == Constants.GROUP_AGE_SECOND &&
+ visitLocation == Constants.VISIT_PLACE_STATIC -> age1to4Static++
+
+ ageGroup == Constants.GROUP_AGE_SECOND &&
+ (visitLocation == Constants.VISIT_PLACE_OUTREACH ||
+ visitLocation == Constants.VISIT_PLACE_SCHOOL) -> age1to4Outreach++
+
+ ageGroup == Constants.GROUP_AGE_THIRD &&
+ visitLocation == Constants.VISIT_PLACE_STATIC -> age5to14Static++
+
+ ageGroup == Constants.GROUP_AGE_THIRD &&
+ (visitLocation == Constants.VISIT_PLACE_OUTREACH ||
+ visitLocation == Constants.VISIT_PLACE_SCHOOL) -> age5to14Outreach++
+ }
+ }
+
+ val total = age1to4Static + age1to4Outreach + age5to14Static + age5to14Outreach
+ Log.d("Hmis105ViewModel", "CL27 MR2 — 1-4 Static: $age1to4Static, 1-4 Outreach: $age1to4Outreach, " +
+ "5-14 Static: $age5to14Static, 5-14 Outreach: $age5to14Outreach, Total: $total")
+
+ return Hmis105ReportDTO(
+ doses = "CL27. Measles Rubella 2 (MR2)",
+ age1to4Static = age1to4Static,
+ age1to4Outreach = age1to4Outreach,
+ age5to14Static = age5to14Static,
+ age5to14Outreach = age5to14Outreach,
+ total = total
+ )
+ }
+
+ private fun createFullyImmunized2Years(
+ visits: List,
+ participantsMap: Map,
+ startDate: Date,
+ endDate: Date
+ ): Hmis105ReportDTO {
+
+ var age1to4Static = 0
+ var age1to4Outreach = 0
+
+ for (visit in visits) {
+ val mr2Obs = visit.observations[KEY_MR2]?.takeIf {
+ it.dateTime.time in startDate.time until endDate.time
+ } ?: continue
+ val participant = participantsMap[visit.participantUuid] ?: continue
+ val mr2DateTime = DateTime(mr2Obs.dateTime.time)
+ val ageInMonths = calculateAgeInMonthsAt(participant.birthDate, mr2DateTime)
+
+ if (ageInMonths !in 17..24) {
+ Log.d("Hmis105ViewModel", "CL28 skip ${visit.participantUuid} — age at MR2 was $ageInMonths months")
+ continue
+ }
+
+ when (visit.visitLocation) {
+ Constants.VISIT_PLACE_STATIC -> age1to4Static++
+ Constants.VISIT_PLACE_OUTREACH,
+ Constants.VISIT_PLACE_SCHOOL -> age1to4Outreach++
+ else -> age1to4Static++
+ }
+ }
+
+ Log.d("Hmis105ViewModel", "CL28 Final — Static: $age1to4Static, Outreach: $age1to4Outreach")
+ return Hmis105ReportDTO(
+ doses = "CL28. Fully immunized by 2 years",
+ age1to4Static = age1to4Static,
+ age1to4Outreach = age1to4Outreach,
+ total = age1to4Static + age1to4Outreach
+ )
+ }
+
+ private fun updateReportRow(
+ reportRows: MutableMap,
+ vaccine: String,
+ ageGroup: String,
+ deliveryMode: String?
+ ) {
+ val currentRow = reportRows[vaccine] ?: Hmis105ReportDTO(doses = vaccine)
+ val mode = deliveryMode ?: Constants.VISIT_PLACE_STATIC
+
+ val updatedRow = when {
+ ageGroup == Constants.GROUP_AGE_FIRST && mode == Constants.VISIT_PLACE_STATIC ->
+ currentRow.copy(under1Static = currentRow.under1Static + 1)
+
+ ageGroup == Constants.GROUP_AGE_FIRST &&
+ (mode == Constants.VISIT_PLACE_OUTREACH || mode == Constants.VISIT_PLACE_SCHOOL) ->
+ currentRow.copy(under1Outreach = currentRow.under1Outreach + 1)
+
+ ageGroup == Constants.GROUP_AGE_SECOND && mode == Constants.VISIT_PLACE_STATIC ->
+ currentRow.copy(age1to4Static = currentRow.age1to4Static + 1)
+
+ ageGroup == Constants.GROUP_AGE_SECOND &&
+ (mode == Constants.VISIT_PLACE_OUTREACH || mode == Constants.VISIT_PLACE_SCHOOL) ->
+ currentRow.copy(age1to4Outreach = currentRow.age1to4Outreach + 1)
+
+ ageGroup == Constants.GROUP_AGE_THIRD && mode == Constants.VISIT_PLACE_STATIC ->
+ currentRow.copy(age5to14Static = currentRow.age5to14Static + 1)
+
+ ageGroup == Constants.GROUP_AGE_THIRD &&
+ (mode == Constants.VISIT_PLACE_OUTREACH || mode == Constants.VISIT_PLACE_SCHOOL) ->
+ currentRow.copy(age5to14Outreach = currentRow.age5to14Outreach + 1)
+
+ else -> currentRow
+ }
+
+ val total = updatedRow.under1Static + updatedRow.under1Outreach +
+ updatedRow.age1to4Static + updatedRow.age1to4Outreach +
+ updatedRow.age5to14Static + updatedRow.age5to14Outreach
+
+ reportRows[vaccine] = updatedRow.copy(total = total)
+ }
+
+
+ private fun initScreens() {
+ screens = createScreens()
+ setInitialScreen()
+ }
+
+ private fun createScreens(): List {
+ return mutableListOf(Screen.HMIS105_REPORT)
+ }
+
+ private fun setInitialScreen() {
+ if (currentScreen.get() == null) {
+ currentScreen.set(screens.firstOrNull())
+ }
+ }
+
+ enum class Screen(@StringRes val label: Int) {
+ HMIS105_REPORT(R.string.hmis105_report_title)
+ }
+
+ override fun saveInstanceState(outState: Bundle) {
+ selectedStartDate.value?.let { outState.putString("selectedStartDate", it.toString()) }
+ selectedEndDate.value?.let { outState.putString("selectedEndDate", it.toString()) }
+ }
+
+ override fun restoreInstanceState(savedInstanceState: Bundle) {
+ val startDateStr = savedInstanceState.getString("selectedStartDate")
+ val endDateStr = savedInstanceState.getString("selectedEndDate")
+
+ if (startDateStr != null) {
+ try {
+ selectedStartDate.value = DateTime.parse(startDateStr).local
+ } catch (e: Exception) {
+ Log.e("Hmis105ViewModel", "Failed to parse start date: $startDateStr", e)
+ }
+ }
+ if (endDateStr != null) {
+ try {
+ selectedEndDate.value = DateTime.parse(endDateStr).local
+ } catch (e: Exception) {
+ Log.e("Hmis105ViewModel", "Failed to parse end date: $endDateStr", e)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/screens/Hmis105ReportFragment.kt b/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/screens/Hmis105ReportFragment.kt
new file mode 100644
index 000000000..157b00dca
--- /dev/null
+++ b/app/src/main/java/com/jnj/vaccinetracker/reportsoverview/hmis105/screens/Hmis105ReportFragment.kt
@@ -0,0 +1,285 @@
+package com.jnj.vaccinetracker.reportsoverview.hmis105.screens
+
+import android.annotation.SuppressLint
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.jnj.vaccinetracker.R
+import com.jnj.vaccinetracker.common.data.database.typealiases.dateNow
+import com.jnj.vaccinetracker.common.dialogs.ReportOverviewDatePickerDialog
+import com.jnj.vaccinetracker.common.ui.BaseFragment
+import com.jnj.vaccinetracker.common.util.DateUtil
+import com.jnj.vaccinetracker.common.util.FileUtil
+import com.jnj.vaccinetracker.databinding.FragmentHmis105ReportBinding
+import com.jnj.vaccinetracker.reportsoverview.hmis105.adapters.Hmis105Adapter
+import com.jnj.vaccinetracker.reportsoverview.hmis105.dto.Hmis105ReportDTO
+import com.jnj.vaccinetracker.reportsoverview.hmis105.model.Hmis105ViewModel
+import com.soywiz.klock.DateTime
+import com.soywiz.klock.DateFormat
+import com.soywiz.klock.jvm.toDate
+import org.apache.poi.hssf.usermodel.HSSFWorkbook
+
+@RequiresApi(Build.VERSION_CODES.Q)
+class Hmis105ReportFragment : BaseFragment(),
+ ReportOverviewDatePickerDialog.VisitsOverviewDatePickerListener {
+
+ companion object {
+ private const val TAG = "Hmis105ReportFragment"
+ private const val START_DATE_PICKER_DIALOG_TAG = "startDatePickerHmis105"
+ private const val END_DATE_PICKER_DIALOG_TAG = "endDatePickerHmis105"
+ }
+
+ private lateinit var binding: FragmentHmis105ReportBinding
+ private lateinit var adapter: Hmis105Adapter
+ private val viewModel: Hmis105ViewModel by viewModels { viewModelFactory }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ Log.d(TAG, "onCreateView called, savedInstanceState: ${savedInstanceState != null}")
+ setHasOptionsMenu(true)
+ binding = DataBindingUtil.inflate(inflater, R.layout.fragment_hmis105_report, container, false)
+ binding.lifecycleOwner = viewLifecycleOwner
+
+ try {
+ setupRecyclerView()
+ if (savedInstanceState != null) {
+ Log.d(TAG, "Restoring ViewModel state from savedInstanceState")
+ viewModel.restoreInstanceState(savedInstanceState)
+ }
+ if (viewModel.selectedStartDate.value == null && viewModel.selectedEndDate.value == null) {
+ Log.d(TAG, "Initializing default dates")
+ initializeDefaultDates()
+ } else {
+ Log.d(TAG, "Dates already set, updating labels")
+ updateDateLabels()
+ }
+ setupDateButtons()
+ setupDownloadButton()
+ Log.d(TAG, "Fragment setup completed successfully")
+ } catch (ex: Exception) {
+ Log.e(TAG, "Error during fragment setup", ex)
+ Toast.makeText(requireContext(), getString(R.string.hmis105_initialization_error), Toast.LENGTH_LONG).show()
+ }
+
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ Log.d(TAG, "onViewCreated called, savedInstanceState: ${savedInstanceState != null}")
+
+ try {
+ (activity as? AppCompatActivity)?.supportActionBar?.apply {
+ title = getString(R.string.hmis105_report_title)
+ setDisplayHomeAsUpEnabled(true)
+ setHomeButtonEnabled(true)
+ }
+ if (viewModel.reportDTOs.value.isNullOrEmpty() &&
+ viewModel.selectedStartDate.value != null &&
+ viewModel.selectedEndDate.value != null) {
+ Log.d(TAG, "Loading report data")
+ loadReportData()
+ } else if (!viewModel.reportDTOs.value.isNullOrEmpty()) {
+ Log.d(TAG, "Data already loaded, not reloading (configuration change)")
+ }
+ } catch (ex: Exception) {
+ Log.e(TAG, "Error in onViewCreated", ex)
+ Toast.makeText(requireContext(), getString(R.string.hmis105_load_error), Toast.LENGTH_LONG).show()
+ }
+ }
+
+ override fun observeViewModel(lifecycleOwner: LifecycleOwner) {
+ Log.d(TAG, "observeViewModel called")
+ try {
+ viewModel.reportDTOs.observe(lifecycleOwner) { data ->
+ Log.d(TAG, "Report data updated: ${data?.size ?: 0} rows")
+ if (::adapter.isInitialized) {
+ adapter.submitList(data)
+ } else {
+ Log.w(TAG, "Adapter not initialized when data arrived")
+ }
+ }
+
+ viewModel.isLoading.observe(lifecycleOwner) { isLoading ->
+ Log.d(TAG, "Loading state: $isLoading")
+ if (::binding.isInitialized) {
+ binding.progressBar.visibility = if (isLoading == true) View.VISIBLE else View.GONE
+ }
+ }
+ } catch (ex: Exception) {
+ Log.e(TAG, "Error in observeViewModel: ${ex.message}", ex)
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ activity?.onBackPressedDispatcher?.onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ Log.d(TAG, "onSaveInstanceState called")
+ viewModel.saveInstanceState(outState)
+ }
+
+ private fun setupRecyclerView() {
+ adapter = Hmis105Adapter()
+ binding.recyclerViewReport.layoutManager = LinearLayoutManager(requireContext())
+ binding.recyclerViewReport.adapter = adapter
+ }
+
+ private fun initializeDefaultDates() {
+ val today = DateTime.now()
+ val monthStart = DateTime(today.yearInt, today.month, 1)
+ viewModel.selectedStartDate.value = monthStart
+ viewModel.selectedEndDate.value = today
+ updateDateLabels()
+ }
+
+ private fun updateDateLabels() {
+ binding.labelStartDate.text = formatDate(viewModel.selectedStartDate.value)
+ binding.labelEndDate.text = formatDate(viewModel.selectedEndDate.value)
+ }
+
+ private fun setupDateButtons() {
+ binding.btnStartDate.setOnClickListener {
+ showDatePickerDialog(true)
+ }
+ binding.btnEndDate.setOnClickListener {
+ showDatePickerDialog(false)
+ }
+ }
+
+ private fun setupDownloadButton() {
+ binding.btnExportExcel.setOnClickListener {
+ exportToExcel()
+ }
+ }
+
+ private fun showDatePickerDialog(isStartDate: Boolean) {
+ val datePickerDialog = ReportOverviewDatePickerDialog(
+ selectedDate = if (isStartDate) viewModel.selectedStartDate.value else viewModel.selectedEndDate.value
+ )
+ datePickerDialog.show(
+ childFragmentManager,
+ if (isStartDate) START_DATE_PICKER_DIALOG_TAG else END_DATE_PICKER_DIALOG_TAG
+ )
+ }
+
+ override fun onDatePicked(date: DateTime?, tag: String?) {
+ date?.let {
+ if (tag == START_DATE_PICKER_DIALOG_TAG) {
+ viewModel.selectedStartDate.value = it
+ binding.labelStartDate.text = formatDate(it)
+ } else if (tag == END_DATE_PICKER_DIALOG_TAG) {
+ viewModel.selectedEndDate.value = it
+ binding.labelEndDate.text = formatDate(it)
+ }
+ if (viewModel.selectedStartDate.value != null && viewModel.selectedEndDate.value != null) {
+ loadReportData()
+ }
+ }
+ }
+
+ private fun loadReportData() {
+ Log.d(TAG, "loadReportData called")
+ viewModel.getHMIS105Data(viewModel.selectedStartDate.value, viewModel.selectedEndDate.value)
+ }
+
+
+ @SuppressLint("SimpleDateFormat")
+ private fun exportToExcel() {
+ val data = viewModel.reportDTOs.value ?: emptyList()
+ if (data.isEmpty()) {
+ Toast.makeText(requireContext(), getString(R.string.no_data_to_export), Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val fileName = "${buildFileName()}.xls"
+ val mimeType = "application/vnd.ms-excel"
+
+ FileUtil.exportToFile(requireContext(), fileName, mimeType) { outputStream ->
+ HSSFWorkbook().use { workbook ->
+ val sheet = workbook.createSheet(getString(R.string.hmis105_report_title).replace(" ", "_"))
+
+ val headerRow = sheet.createRow(0)
+ headerRow.createCell(0).setCellValue("Doses")
+ headerRow.createCell(1).setCellValue("Under 1 - Static")
+ headerRow.createCell(2).setCellValue("Under 1 - Outreach/School")
+ headerRow.createCell(3).setCellValue("1-4 Years - Static")
+ headerRow.createCell(4).setCellValue("1-4 Years - Outreach/School")
+ headerRow.createCell(5).setCellValue("5-14 Years - Static")
+ headerRow.createCell(6).setCellValue("5-14 Years - Outreach/School")
+ headerRow.createCell(7).setCellValue("Total")
+
+ data.forEachIndexed { index, report ->
+ val row = sheet.createRow(index + 1)
+ row.createCell(0).setCellValue(report.doses)
+ if (!isSectionHeader(report)) {
+ row.createCell(1).setCellValue(report.under1Static.toDouble())
+ row.createCell(2).setCellValue(report.under1Outreach.toDouble())
+ row.createCell(3).setCellValue(report.age1to4Static.toDouble())
+ row.createCell(4).setCellValue(report.age1to4Outreach.toDouble())
+ row.createCell(5).setCellValue(report.age5to14Static.toDouble())
+ row.createCell(6).setCellValue(report.age5to14Outreach.toDouble())
+ row.createCell(7).setCellValue(report.total.toDouble())
+ }
+ }
+
+ sheet.setColumnWidth(0, 4000)
+ sheet.setColumnWidth(1, 4000)
+ sheet.setColumnWidth(2, 4500)
+ sheet.setColumnWidth(3, 4000)
+ sheet.setColumnWidth(4, 4500)
+ sheet.setColumnWidth(5, 4000)
+ sheet.setColumnWidth(6, 4500)
+ sheet.setColumnWidth(7, 4000)
+
+ workbook.write(outputStream)
+ }
+ }
+ }
+
+ private fun isSectionHeader(report: Hmis105ReportDTO): Boolean {
+ return report.under1Static == 0 &&
+ report.under1Outreach == 0 &&
+ report.age1to4Static == 0 &&
+ report.age1to4Outreach == 0 &&
+ report.age5to14Static == 0 &&
+ report.age5to14Outreach == 0 &&
+ report.total == 0 &&
+ report.doses == report.doses.uppercase()
+ }
+
+ private fun buildFileName(): String {
+ return "${getString(R.string.hmis105_report_title).replace(" ", "_")}_${
+ DateUtil.convertDateToString(
+ dateNow(),
+ DateFormat.FORMAT_DATE.toString()
+ )
+ }"
+ }
+
+ private fun formatDate(date: DateTime?): String {
+ return date?.let { DateUtil.convertDateToString(it.toDate(), DateFormat.FORMAT_DATE.toString()) } ?: "N/A"
+ }
+}
diff --git a/app/src/main/res/layout-sw600dp-land/fragment_hmis105_report.xml b/app/src/main/res/layout-sw600dp-land/fragment_hmis105_report.xml
new file mode 100644
index 000000000..9e635f7f8
--- /dev/null
+++ b/app/src/main/res/layout-sw600dp-land/fragment_hmis105_report.xml
@@ -0,0 +1,231 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-sw600dp-land/item_hmis105_report_row.xml b/app/src/main/res/layout-sw600dp-land/item_hmis105_report_row.xml
new file mode 100644
index 000000000..1b10ffc6c
--- /dev/null
+++ b/app/src/main/res/layout-sw600dp-land/item_hmis105_report_row.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-sw600dp/fragment_hmis105_report.xml b/app/src/main/res/layout-sw600dp/fragment_hmis105_report.xml
new file mode 100644
index 000000000..d292c9e34
--- /dev/null
+++ b/app/src/main/res/layout-sw600dp/fragment_hmis105_report.xml
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-sw600dp/item_hmis105_report_row.xml b/app/src/main/res/layout-sw600dp/item_hmis105_report_row.xml
new file mode 100644
index 000000000..dce2ac53a
--- /dev/null
+++ b/app/src/main/res/layout-sw600dp/item_hmis105_report_row.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_flow.xml b/app/src/main/res/layout/activity_flow.xml
new file mode 100644
index 000000000..56e5507a5
--- /dev/null
+++ b/app/src/main/res/layout/activity_flow.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_hmis105_report.xml b/app/src/main/res/layout/fragment_hmis105_report.xml
new file mode 100644
index 000000000..e7bc01e5d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_hmis105_report.xml
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_reports_overview.xml b/app/src/main/res/layout/fragment_reports_overview.xml
index 0cdb1dce3..b46a2857f 100644
--- a/app/src/main/res/layout/fragment_reports_overview.xml
+++ b/app/src/main/res/layout/fragment_reports_overview.xml
@@ -23,7 +23,7 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginBottom="@dimen/return_visit_guide_btn_margin_54_40_20"
- android:paddingVertical="@dimen/report_overview_btn_padding_70_100_70"
+ android:paddingVertical="@dimen/report_overview_btn_padding_70_100_50"
android:backgroundTint="@color/alertLight"
android:text="@string/vaccines_overview_title"
android:textAppearance="@style/ButtonTextStyling"
@@ -38,7 +38,7 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginBottom="@dimen/return_visit_guide_btn_margin_54_40_20"
- android:paddingVertical="@dimen/report_overview_btn_padding_70_100_70"
+ android:paddingVertical="@dimen/report_overview_btn_padding_70_100_50"
android:backgroundTint="@color/colorSecondaryDark"
android:text="@string/registered_children"
android:textAppearance="@style/ButtonTextStyling"
@@ -46,6 +46,20 @@
android:textSize="@dimen/btn_text_size_18_32"
android:textStyle="bold"
app:cornerRadius="@dimen/button_corner_radius" />
+
+
diff --git a/app/src/main/res/layout/item_hmis105_report_row.xml b/app/src/main/res/layout/item_hmis105_report_row.xml
new file mode 100644
index 000000000..5677f5eba
--- /dev/null
+++ b/app/src/main/res/layout/item_hmis105_report_row.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-sw600dp-land/dimens.xml b/app/src/main/res/values-sw600dp-land/dimens.xml
index d64d9cf6d..a4e063cfe 100644
--- a/app/src/main/res/values-sw600dp-land/dimens.xml
+++ b/app/src/main/res/values-sw600dp-land/dimens.xml
@@ -13,5 +13,5 @@
32dp
- 70dp
+ 50dp
diff --git a/app/src/main/res/values-sw600dp/dimens.xml b/app/src/main/res/values-sw600dp/dimens.xml
index 0cd975d6a..5606a4b99 100644
--- a/app/src/main/res/values-sw600dp/dimens.xml
+++ b/app/src/main/res/values-sw600dp/dimens.xml
@@ -16,6 +16,9 @@
80dp
+ 14sp
+ 14sp
+ 16sp
16sp
18sp
20sp
@@ -51,7 +54,7 @@
24sp
- 100dp
+ 100dp
50sp
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index bf21f31ff..87692a252 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -29,12 +29,20 @@
48dp
+ 9sp
+ 10sp
+ 11sp
12sp
+ 13sp
14sp
+ 15sp
16sp
24sp
12sp
12sp
+ 12sp
+ 12sp
+ 12sp
14sp
16sp
16sp
@@ -68,7 +76,7 @@
18sp
- 70dp
+ 70dp
25sp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index aa353b67d..fc8b46c6a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -567,7 +567,19 @@
Warning
Attention: This action is reserved for System Administrators only. If you are an operator, do NOT continue.
Tap Cancel to abort.
+ HMIS 105 Report
+ Unable to initialize the report. Please try again.
+ Unable to load the report. Please try again.
+ No data available to export
Vaccination Date
Vaccination date cannot be in the future
Next visit will be scheduled for: %s
+ Under 1 Static
+ Under 1 Outreach
+ Doses
+ 1-4 Yrs Static
+ 1-4 Yrs Outreach
+ 5-14 Yrs Static
+ 5-14 Yrs Outreach
+ Total