diff --git a/app/build.gradle b/app/build.gradle index b48542e1d..e1953dc2e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -157,8 +157,11 @@ dependencies { implementation "androidx.datastore:datastore-preferences:1.0.0" implementation 'androidx.biometric:biometric:1.2.0-alpha05' + // EncryptedSharedPreferences for secure credential storage + implementation "androidx.security:security-crypto:1.1.0-alpha06" + //tensorflow and mediapipe. - implementation 'com.google.mediapipe:tasks-vision:latest.release' + implementation 'com.google.mediapipe:tasks-vision:0.10.21' implementation ("org.tensorflow:tensorflow-lite:2.11.0") implementation ("org.tensorflow:tensorflow-lite-gpu:2.11.0") implementation ("org.tensorflow:tensorflow-lite-gpu-api:2.11.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4a200d5ce..6bf05e808 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,13 @@ + + + + + + + @@ -49,43 +56,57 @@ tools:targetApi="31"> + android:exported="false" + android:screenOrientation="portrait" + android:windowSoftInputMode="adjustResize" /> - - + android:exported="false" + android:screenOrientation="portrait" + android:windowSoftInputMode="adjustResize" /> + android:exported="false" + android:screenOrientation="portrait" + android:windowSoftInputMode="adjustResize" /> + android:screenOrientation="portrait" + android:exported="false" + android:windowSoftInputMode="adjustResize" /> + android:label="CPHC" + android:screenOrientation="portrait" + android:windowSoftInputMode="adjustResize" /> + + tools:node="remove" /> (android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val bottomPadding = maxOf(imeInsets.bottom, systemBarInsets.bottom) + view.setPadding( + systemBarInsets.left, + systemBarInsets.top, + systemBarInsets.right, + bottomPadding + ) + + statusBarBgView.layoutParams.height = systemBarInsets.top + statusBarBgView.requestLayout() + WindowInsetsCompat.CONSUMED + } + } + + override fun onActivityStarted(activity: Activity) { /* No action needed */ } + override fun onActivityResumed(activity: Activity) { /* No action needed */ } + override fun onActivityPaused(activity: Activity) { /* No action needed */ } + override fun onActivityStopped(activity: Activity) { /* No action needed */ } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { /* No action needed */ } + override fun onActivityDestroyed(activity: Activity) { /* No action needed */ } + }) + + // Set token refresh provider so AuthRefreshInterceptor can refresh JWT when expired + try { + val entryPoint = EntryPointAccessors.fromApplication(this, TokenRefreshEntryPoint::class.java) + TokenRefreshHolder.provider = entryPoint.tokenRefreshProvider() + } catch (e: Exception) { + Timber.w(e, "CHOApplication: Could not set TokenRefreshProvider") + } } + @dagger.hilt.EntryPoint + @dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) + interface TokenRefreshEntryPoint { + fun tokenRefreshProvider(): TokenRefreshProvider + } companion object { fun dataStore(context: Context) = (context.applicationContext as CHOApplication).dataStore } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/ANCVisitsAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/ANCVisitsAdapter.kt new file mode 100644 index 000000000..062b0a74e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/ANCVisitsAdapter.kt @@ -0,0 +1,103 @@ +package org.piramalswasthya.cho.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.piramalswasthya.cho.databinding.RvItemAncVisitBinding +import org.piramalswasthya.cho.model.PatientWithPwrDomain +import org.piramalswasthya.cho.utils.DateTimeUtil + +class ANCVisitsAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + ANCVisitDiffUtilCallBack +) { + + private object ANCVisitDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ) = oldItem == newItem + } + + class ANCVisitViewHolder private constructor( + private val binding: RvItemAncVisitBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): ANCVisitViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemAncVisitBinding.inflate(layoutInflater, parent, false) + return ANCVisitViewHolder(binding) + } + } + + fun bind( + item: PatientWithPwrDomain, + clickListener: ClickListener? + ) { + binding.patientWithPwr = item + binding.clickListener = clickListener + binding.ivCall.setOnClickListener { + clickListener?.onClickCall(item) + } + + // Set weeks of pregnancy + val weeks = item.getWeeksOfPregnancy() + binding.tvWeeksPregnancy.text = if (weeks > 0 && weeks <= 40) { + weeks.toString() + } else { + "N/A" + } + + // Set LMP date + binding.tvLmp.text = item.getFormattedLMPDate() + + // Set EDD + binding.tvEdd.text = item.getFormattedEDD() + + binding.tvLastVisit.text = "N/A" + + // Hide "Add Visit" button if ANC 4 is completed and patient is not HRP + val isAnc4Completed = item.ancRecords.any { it.visitNumber == 4 && it.weight != null } + val isHRP = item.pwr?.isHrp ?: false + binding.btnAddAnc.visibility = if (isAnc4Completed && !isHRP) View.GONE else View.VISIBLE + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ANCVisitViewHolder.from(parent) + + override fun onBindViewHolder(holder: ANCVisitViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedAddANC: ((patientWithPwr: PatientWithPwrDomain) -> Unit)? = null, + private val clickedANCVisits: ((patientWithPwr: PatientWithPwrDomain) -> Unit)? = null, + private val clickedAddPMSMA: ((patientWithPwr: PatientWithPwrDomain) -> Unit)? = null, + private val clickedCall: ((patientWithPwr: PatientWithPwrDomain) -> Unit)? = null + ) { + fun onClickAddANC(item: PatientWithPwrDomain) = + clickedAddANC?.let { it(item) } + + fun onClickANCVisits(item: PatientWithPwrDomain) = + clickedANCVisits?.let { it(item) } + + fun onClickAddPMSMA(item: PatientWithPwrDomain) = + clickedAddPMSMA?.let { it(item) } + + fun onClickCall(item: PatientWithPwrDomain) = + clickedCall?.let { it(item) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/AbortionListAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/AbortionListAdapter.kt new file mode 100644 index 000000000..0e9d3f24d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/AbortionListAdapter.kt @@ -0,0 +1,89 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemAbortionBinding +import org.piramalswasthya.cho.model.AbortionDomain + +class AbortionListAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + AbortionDiffUtilCallBack +) { + + private object AbortionDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: AbortionDomain, + newItem: AbortionDomain + ) = oldItem.patient.patientID == newItem.patient.patientID && + oldItem.abortionRecord?.id == newItem.abortionRecord?.id + + override fun areContentsTheSame( + oldItem: AbortionDomain, + newItem: AbortionDomain + ) = oldItem == newItem + } + + class AbortionViewHolder private constructor( + private val binding: RvItemAbortionBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): AbortionViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemAbortionBinding.inflate(layoutInflater, parent, false) + return AbortionViewHolder(binding) + } + } + + fun bind( + item: AbortionDomain, + clickListener: ClickListener? + ) { + binding.item = item + binding.clickListener = clickListener + binding.context = binding.root.context + + // Set button color based on abortion form status (text is bound via app:abortionActionText) + val isFormFilled = item.isAbortionFormFilled + binding.btnAction.setBackgroundColor( + binding.root.resources.getColor( + if (isFormFilled) android.R.color.holo_green_dark + else android.R.color.holo_red_dark, + null + ) + ) + + + // Set sync icon visibility + binding.ivSync.visibility = if (item.abortionRecord?.syncState == org.piramalswasthya.cho.database.room.SyncState.SYNCED) { + android.view.View.VISIBLE + } else { + android.view.View.GONE + } + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + AbortionViewHolder.from(parent) + + override fun onBindViewHolder(holder: AbortionViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedView: ((patientID: String) -> Unit)? = null, + private val clickedAdd: ((patientID: String) -> Unit)? = null + ) { + fun onClickView(item: AbortionDomain) = + clickedView?.let { it(item.patient.patientID) } + + fun onClickAdd(item: AbortionDomain) = + clickedAdd?.let { it(item.patient.patientID) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/AdolescentListAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/AdolescentListAdapter.kt new file mode 100644 index 000000000..5633ffec0 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/AdolescentListAdapter.kt @@ -0,0 +1,94 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemAdolescentListBinding +import org.piramalswasthya.cho.model.PatientDisplay +import org.piramalswasthya.cho.utils.DateTimeUtil + +class AdolescentListAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + AdolescentDiffUtilCallBack +) { + + private object AdolescentDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientDisplay, + newItem: PatientDisplay + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PatientDisplay, + newItem: PatientDisplay + ) = oldItem == newItem + } + + class AdolescentViewHolder private constructor( + private val binding: RvItemAdolescentListBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): AdolescentViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemAdolescentListBinding.inflate(layoutInflater, parent, false) + return AdolescentViewHolder(binding) + } + } + + fun bind( + item: PatientDisplay, + clickListener: ClickListener? + ) { + binding.patient = item + binding.clickListener = clickListener + + // Set adolescent name + val firstName = item.patient.firstName ?: "" + val lastName = item.patient.lastName ?: "" + binding.tvAdolescentName.text = if (lastName.isNotEmpty()) { + "$firstName $lastName" + } else { + firstName + } + + // Set age + item.patient.dob?.let { + binding.tvAge.text = DateTimeUtil.calculateAgeString(it) + } ?: run { + binding.tvAge.text = binding.root.context.getString(org.piramalswasthya.cho.R.string.na) + } + + // Set beneficiary ID + binding.tvBeneficiaryId.text = item.patient.beneficiaryID?.toString() ?: "NA" + + // Set phone number + binding.tvPhoneNo.text = item.patient.phoneNo ?: "NA" + + // Set gender + binding.tvGender.text = item.gender?.genderName ?: "NA" + + // Set parent name + binding.tvParentName.text = item.patient.parentName ?: "NA" + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + AdolescentViewHolder.from(parent) + + override fun onBindViewHolder(holder: AdolescentViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedView: ((patient: PatientDisplay) -> Unit)? = null + ) { + fun onClickView(item: PatientDisplay) = + clickedView?.let { it(item) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/AncVisitAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/AncVisitAdapter.kt index caf7ad7d9..65c0eff3c 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/AncVisitAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/AncVisitAdapter.kt @@ -15,7 +15,7 @@ class AncVisitAdapter(private val clickListener: AncVisitClickListener) : private object MyDiffUtilCallBack : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: PregnantWomanAncCache, newItem: PregnantWomanAncCache - ) = oldItem.patientID == newItem.patientID + ) = oldItem.patientID == newItem.patientID && oldItem.visitNumber == newItem.visitNumber override fun areContentsTheSame( oldItem: PregnantWomanAncCache, newItem: PregnantWomanAncCache diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/AncVisitBottomSheetAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/AncVisitBottomSheetAdapter.kt new file mode 100644 index 000000000..9ecbd5396 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/AncVisitBottomSheetAdapter.kt @@ -0,0 +1,135 @@ +package org.piramalswasthya.cho.adapter + +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.cho.R +import org.piramalswasthya.cho.databinding.RvItemAncVisitSimpleBinding +import org.piramalswasthya.cho.helpers.Konstants +import org.piramalswasthya.cho.helpers.getWeeksOfPregnancy +import org.piramalswasthya.cho.helpers.getTodayMillis +import org.piramalswasthya.cho.model.PregnantWomanAncCache +import java.text.SimpleDateFormat +import java.util.* + +class AncVisitBottomSheetAdapter( + private val clickListener: AncVisitClickListener +) : ListAdapter(AncVisitDiffCallback()) { + + private var lmpDate: Long = 0L + private var allAncVisits: List = emptyList() + + fun updateData(visits: List, lmpDate: Long) { + this.lmpDate = lmpDate + this.allAncVisits = visits + submitList(visits) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AncVisitViewHolder { + return AncVisitViewHolder.from(parent) + } + + override fun onBindViewHolder(holder: AncVisitViewHolder, position: Int) { + holder.bind(getItem(position), clickListener, lmpDate, allAncVisits) + } + + class AncVisitViewHolder private constructor( + private val binding: RvItemAncVisitSimpleBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind( + item: PregnantWomanAncCache, + clickListener: AncVisitClickListener, + lmpDate: Long, + allAncVisits: List + ) { + val res = binding.root.context.resources + binding.tvVisitTitle.text = res.getString(R.string.anc_visit_number_format, item.visitNumber) + + // Format date + val dateFormat = SimpleDateFormat("dd-MM-yyyy", Locale.getDefault()) + val dateText = if (item.ancDate != null) { + dateFormat.format(Date(item.ancDate)) + } else { + res.getString(R.string.not_scheduled) + } + + // Calculate gestational age + val gaWeeks = getWeeksOfPregnancy(getTodayMillis(), lmpDate) + + // Check if completed (weight is filled) + val isCompleted = item.weight != null + + if (isCompleted) { + // COMPLETED + binding.tvVisitDate.text = res.getString(R.string.completed_with_date, dateText) + binding.tvVisitStatus.text = res.getString(R.string.completed_status) + binding.tvVisitStatus.setTextColor(binding.root.context.getColor(android.R.color.holo_green_dark)) + } else { + // Check if DUE based on GA and previous visit completion + val isDue = when (item.visitNumber) { + 1 -> { + // ANC 1: GA ≤12 weeks + gaWeeks <= Konstants.maxAnc1Week + } + 2 -> { + // ANC 2: ANC 1 completed AND GA ≥14 weeks AND <28 weeks + val anc1Completed = allAncVisits.any { it.visitNumber == 1 && it.weight != null } + anc1Completed && gaWeeks >= Konstants.minAnc2Week && gaWeeks < 28 + } + 3 -> { + // ANC 3: ANC 2 completed AND GA ≥28 weeks AND <36 weeks + val anc2Completed = allAncVisits.any { it.visitNumber == 2 && it.weight != null } + anc2Completed && gaWeeks >= Konstants.minAnc3Week && gaWeeks < 36 + } + 4 -> { + // ANC 4: ANC 3 completed AND GA ≥36 weeks AND ≤40 weeks + val anc3Completed = allAncVisits.any { it.visitNumber == 3 && it.weight != null } + anc3Completed && gaWeeks >= Konstants.minAnc4Week && gaWeeks <= Konstants.maxAnc4Week + } + else -> false + } + + if (isDue) { + // DUE + binding.tvVisitDate.text = res.getString(R.string.scheduled_ga_format, dateText, gaWeeks) + binding.tvVisitStatus.text = res.getString(R.string.due_status) + binding.tvVisitStatus.setTextColor(binding.root.context.getColor(android.R.color.holo_orange_dark)) + } else { + // PENDING (not yet due) + binding.tvVisitDate.text = res.getString(R.string.scheduled_ga_format, dateText, gaWeeks) + binding.tvVisitStatus.text = res.getString(R.string.pending_status) + binding.tvVisitStatus.setTextColor(binding.root.context.getColor(android.R.color.darker_gray)) + } + } + + binding.root.setOnClickListener { + clickListener.onClick(item) + } + } + + companion object { + fun from(parent: ViewGroup): AncVisitViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemAncVisitSimpleBinding.inflate(layoutInflater, parent, false) + return AncVisitViewHolder(binding) + } + } + } + + class AncVisitClickListener(val clickListener: (ancVisit: PregnantWomanAncCache) -> Unit) { + fun onClick(ancVisit: PregnantWomanAncCache) = clickListener(ancVisit) + } +} + +class AncVisitDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: PregnantWomanAncCache, newItem: PregnantWomanAncCache): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: PregnantWomanAncCache, newItem: PregnantWomanAncCache): Boolean { + return oldItem == newItem + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/ApiSearchAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/ApiSearchAdapter.kt index 34aa953b3..3e2bcad71 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/ApiSearchAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/ApiSearchAdapter.kt @@ -54,7 +54,7 @@ class ApiSearchAdapter( tvBenId.text = "Beneficiary ID: ${info.patient.beneficiaryID ?: "NA"}" tvGender.text = "Gender: ${info.genderName ?: "NA"}" tvAge.text = "Age: ${info.patient.age ?: "NA"}" - tvMobile.text = "Mobile No.: ${info.patient.phoneNo ?: "NA"}" + tvMobile.text = "Mob No.: ${info.patient.phoneNo ?: "NA"}" } } diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/CHOCaseRecordItemAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/CHOCaseRecordItemAdapter.kt index 97f6f98f5..abcedb8db 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/CHOCaseRecordItemAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/CHOCaseRecordItemAdapter.kt @@ -58,7 +58,12 @@ class CHOCaseRecordItemAdapter( ) binding.visitNumber.text = item.benVisitNo?.toString() ?: "" - binding.visitDate.text = DateTimeUtil.formatedDate(benFlow?.visitDate) + val visitDateText = if (!benFlow?.visitDate.isNullOrBlank()) { + DateTimeUtil.formatedDate(benFlow?.visitDate) + } else { + item.visitDate?.let { DateTimeUtil.formatDate(it) } ?: "N/A" + } + binding.visitDate.text = visitDateText binding.executePendingBindings() } diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/ChiefComplaintMultiAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/ChiefComplaintMultiAdapter.kt index bc26b054e..648eb2913 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/ChiefComplaintMultiAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/ChiefComplaintMultiAdapter.kt @@ -9,6 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.textfield.TextInputEditText import org.piramalswasthya.cho.R import org.piramalswasthya.cho.model.ChiefComplaintDB +import org.piramalswasthya.cho.utils.MasterDataLocalizer class ChiefComplaintMultiAdapter( private val chiefComplaints: List, @@ -38,20 +39,22 @@ class ChiefComplaintMultiAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { val chiefComplaint = chiefComplaints[position] + val ctx = holder.itemView.context holder.oldLayout.visibility = if (layoutVariant == null) View.VISIBLE else View.GONE holder.newLayout.visibility = if (layoutVariant == null) View.GONE else View.VISIBLE - holder.chiefComplaintName.setText(chiefComplaint.chiefComplaint) - holder.durationInput.setText(chiefComplaint.duration) - holder.durationUnitInput.setText(chiefComplaint.durationUnit) + val localizedChiefComplaint = + MasterDataLocalizer.localizeChiefComplaint(ctx, chiefComplaint.chiefComplaint) + val localizedDurationUnit = + MasterDataLocalizer.localizeDurationUnit(ctx, chiefComplaint.durationUnit) - holder.chiefComplaintName.setText(chiefComplaint.chiefComplaint) + holder.chiefComplaintName.setText(localizedChiefComplaint) holder.durationInput.setText(chiefComplaint.duration) - holder.durationUnitInput.setText(chiefComplaint.durationUnit) + holder.durationUnitInput.setText(localizedDurationUnit) - holder.tvChiefComplaint.text = chiefComplaint.chiefComplaint - holder.tvDurationValue.text = "${chiefComplaint.duration} ${chiefComplaint.durationUnit}" + holder.tvChiefComplaint.text = localizedChiefComplaint + holder.tvDurationValue.text = "${chiefComplaint.duration} $localizedDurationUnit" holder.tvDescription.text = chiefComplaint.description // Add a TextWatcher to update the duration property when the EditText changes diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/ChildRegistrationAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/ChildRegistrationAdapter.kt new file mode 100644 index 000000000..f50f77632 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/ChildRegistrationAdapter.kt @@ -0,0 +1,97 @@ +package org.piramalswasthya.cho.adapter + +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.cho.R +import org.piramalswasthya.cho.databinding.RvItemChildRegBinding +import org.piramalswasthya.cho.model.ChildRegDomain +import org.piramalswasthya.cho.utils.DateTimeUtil + +class ChildRegistrationAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + ChildDiffUtilCallBack +) { + + private object ChildDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ChildRegDomain, + newItem: ChildRegDomain + ) = oldItem.motherPatient.patientID == newItem.motherPatient.patientID && + oldItem.infant.babyIndex == newItem.infant.babyIndex + + override fun areContentsTheSame( + oldItem: ChildRegDomain, + newItem: ChildRegDomain + ) = oldItem == newItem + } + + class ChildViewHolder private constructor( + private val binding: RvItemChildRegBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): ChildViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemChildRegBinding.inflate(layoutInflater, parent, false) + return ChildViewHolder(binding) + } + } + + fun bind( + item: ChildRegDomain, + clickListener: ClickListener? + ) { + binding.item = item + binding.clickListener = clickListener ?: ClickListener(null) + + // Set infant name + binding.tvBabyName.text = item.customName + + // Set button text and color based on child registration status + val isChildRegistered = item.isChildRegistered() + binding.btnAction.text = binding.root.context.getString( + if (isChildRegistered) R.string.view else R.string.register + ) + binding.btnAction.setBackgroundColor( + binding.root.resources.getColor( + if (isChildRegistered) android.R.color.holo_green_dark + else android.R.color.holo_red_dark, + null + ) + ) + + // Set age + item.childPatient?.dob?.let { + binding.tvAge.text = DateTimeUtil.calculateAgeString(it).ifBlank { "0 days" } + } ?: run { + binding.tvAge.text = "NA" + } + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ChildViewHolder.from(parent) + + override fun onBindViewHolder(holder: ChildViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedForm: ((patientID: String, babyIndex: Int, childPatientID: String?) -> Unit)? = null + ) { + fun onClickForm(item: ChildRegDomain) = + clickedForm?.let { + it( + item.motherPatient.patientID, + item.infant.babyIndex, + item.childPatient?.patientID + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/DeliveryOutcomeAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/DeliveryOutcomeAdapter.kt new file mode 100644 index 000000000..fa5f1d3ad --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/DeliveryOutcomeAdapter.kt @@ -0,0 +1,79 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemDeliveryOutcomeBinding +import org.piramalswasthya.cho.model.PatientWithPwrDomain +import org.piramalswasthya.cho.utils.DateTimeUtil + +class DeliveryOutcomeAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + DeliveryOutcomeDiffUtilCallBack +) { + + private object DeliveryOutcomeDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ) = oldItem == newItem + } + + class DeliveryOutcomeViewHolder private constructor( + private val binding: RvItemDeliveryOutcomeBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): DeliveryOutcomeViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemDeliveryOutcomeBinding.inflate(layoutInflater, parent, false) + return DeliveryOutcomeViewHolder(binding) + } + } + + fun bind( + item: PatientWithPwrDomain, + clickListener: ClickListener? + ) { + binding.patientWithPwr = item + binding.clickListener = clickListener + + // Set age + item.patient.dob?.let { + binding.tvAge.text = DateTimeUtil.calculateAgeString(it) + } ?: run { + binding.tvAge.text = "NA" + } + + // Set LMP date + binding.tvLmp.text = item.getFormattedLMPDate() + + // Set EDD + binding.tvEdd.text = item.getFormattedEDD() + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + DeliveryOutcomeViewHolder.from(parent) + + override fun onBindViewHolder(holder: DeliveryOutcomeViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedView: ((patientWithPwr: PatientWithPwrDomain) -> Unit)? = null + ) { + fun onClickView(item: PatientWithPwrDomain) = + clickedView?.let { it(item) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/DiagnosisAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/DiagnosisAdapter.kt index de8f543ef..1d7598f35 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/DiagnosisAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/DiagnosisAdapter.kt @@ -107,7 +107,13 @@ override fun onBindViewHolder(holder: ViewHolder, position: Int) { itemChangeListener.onItemChanged() } - if (isVisitDetail == true && isFollowupVisit == false){ + // When already filled (read-only): only show rows that have data; when editable show all rows + val isCaseReadOnly = isVisitDetail == true && isFollowupVisit == false + val isRowReadOnly = isCaseReadOnly || itemData.isPreFilled + val hasData = itemData.diagnosis.isNotBlank() + holder.itemView.visibility = if (isCaseReadOnly && !hasData) View.GONE else View.VISIBLE + + if (isRowReadOnly) { holder.resetButton.isVisible = false holder.cancelButton.isVisible = false holder.diagnosisInput.isClickable = false @@ -118,6 +124,16 @@ override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.diagnosisInpuTextt.defaultHintTextColor = ColorStateList.valueOf( ContextCompat.getColor(mContext, R.color.disable_field_hint_color) ) + } else { + holder.resetButton.isVisible = true + holder.cancelButton.isVisible = true + holder.diagnosisInput.isClickable = true + holder.diagnosisInput.isFocusable = true + holder.diagnosisInpuTextt.boxBackgroundColor = + ContextCompat.getColor(mContext, R.color.white) + holder.diagnosisInpuTextt.defaultHintTextColor = ColorStateList.valueOf( + ContextCompat.getColor(mContext, R.color.primaryTextColor) + ) } // Update the visibility of the "Cancel" button for all items diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/ECRegistrationAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/ECRegistrationAdapter.kt new file mode 100644 index 000000000..89111fd15 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/ECRegistrationAdapter.kt @@ -0,0 +1,108 @@ +package org.piramalswasthya.cho.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.piramalswasthya.cho.databinding.RvItemPatientEcWithFormBinding +import org.piramalswasthya.cho.model.PatientWithEcrDomain +import java.util.concurrent.TimeUnit + +class ECRegistrationAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter(PatientDiffUtilCallBack) { + + private object PatientDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientWithEcrDomain, + newItem: PatientWithEcrDomain + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PatientWithEcrDomain, + newItem: PatientWithEcrDomain + ) = oldItem == newItem && + oldItem.lastVisitDate == newItem.lastVisitDate && + oldItem.methodOfContraception == newItem.methodOfContraception && + oldItem.antraNextDueDate == newItem.antraNextDueDate && + oldItem.antraInjectionDate == newItem.antraInjectionDate && + oldItem.lmpDateFromTracking == newItem.lmpDateFromTracking + } + + class PatientViewHolder private constructor( + private val binding: RvItemPatientEcWithFormBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): PatientViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemPatientEcWithFormBinding.inflate(layoutInflater, parent, false) + return PatientViewHolder(binding) + } + } + + fun bind( + item: PatientWithEcrDomain, + clickListener: ClickListener? + ) { + binding.patientWithEcr = item + binding.clickListener = clickListener + binding.ivCall.setOnClickListener { + clickListener?.onClickCall(item) + } + + // Always show LMP and Status layout blocks + binding.llLmpDate.visibility = View.VISIBLE + + val lmpDate = item.ecr?.lmpDate ?: item.lmpDateFromTracking + if (lmpDate != null && lmpDate > 0L) { + val daysSinceLMP = TimeUnit.MILLISECONDS.toDays( + System.currentTimeMillis() - lmpDate + ) + binding.ivMissState.visibility = if (daysSinceLMP > 35) View.VISIBLE else View.GONE + } else { + binding.ivMissState.visibility = View.GONE + } + + // Show Last Visit Date if available + binding.llLastVisitDate.visibility = if (item.lastVisitDate != null && item.lastVisitDate!! > 0L) { + View.VISIBLE + } else { + View.INVISIBLE + } + + // Show ANTRA details if selected + binding.llAntraDetails.visibility = if (item.methodOfContraception == "ANTRA Injection") { + View.VISIBLE + } else { + View.GONE + } + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PatientViewHolder.from(parent) + + override fun onBindViewHolder(holder: PatientViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val onAddVisit: ((patientWithEcr: PatientWithEcrDomain) -> Unit)? = null, + private val onViewVisit: ((patientWithEcr: PatientWithEcrDomain) -> Unit)? = null, + private val onCall: ((patientWithEcr: PatientWithEcrDomain) -> Unit)? = null + ) { + fun onAddVisit(item: PatientWithEcrDomain) = + onAddVisit?.let { it(item) } + + fun onViewVisit(item: PatientWithEcrDomain) = + onViewVisit?.let { it(item) } + + fun onClickCall(item: PatientWithEcrDomain) = + onCall?.let { it(item) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/ECTrackingAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/ECTrackingAdapter.kt index c2908a84d..75ef19573 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/ECTrackingAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/ECTrackingAdapter.kt @@ -39,6 +39,11 @@ class ECTrackingAdapter(private val clickListener: ECTrackViewClickListener) : binding.visit = item binding.filledOnString = EligibleCoupleTrackingCache.getECTFilledDateFromLong(item.visitDate) binding.clickListener = clickListener + binding.llAntraDetails.visibility = if (item.antraInjectionDate != null || item.antraDueDate != null) { + android.view.View.VISIBLE + } else { + android.view.View.GONE + } binding.executePendingBindings() } diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/FormInputAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/FormInputAdapter.kt index 111408856..ce7d2558b 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/FormInputAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/FormInputAdapter.kt @@ -2,8 +2,8 @@ package org.piramalswasthya.cho.adapter import android.app.DatePickerDialog import android.app.TimePickerDialog -import android.content.Context.INPUT_METHOD_SERVICE import android.content.res.ColorStateList +import android.content.Context.INPUT_METHOD_SERVICE import android.content.res.Resources import android.graphics.Color import android.os.Build @@ -18,7 +18,6 @@ import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager import android.widget.CheckBox import android.widget.LinearLayout import android.widget.RadioButton @@ -40,8 +39,14 @@ import org.piramalswasthya.cho.databinding.RvItemFormRadioV2Binding import org.piramalswasthya.cho.databinding.RvItemFormTextViewV2Binding import org.piramalswasthya.cho.databinding.RvItemFormTimepickerV2Binding import org.piramalswasthya.cho.helpers.Konstants +import org.piramalswasthya.cho.configuration.Dataset +import org.piramalswasthya.cho.databinding.LayoutUploadFormBinding import org.piramalswasthya.cho.helpers.getDateString import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.utils.KeyboardUtils +import org.piramalswasthya.cho.utils.applySafeDateConstraints +import org.piramalswasthya.cho.utils.setupDropdownKeyboardHandling import org.piramalswasthya.cho.model.InputType.AGE_PICKER import org.piramalswasthya.cho.model.InputType.CHECKBOXES import org.piramalswasthya.cho.model.InputType.DATE_PICKER @@ -61,8 +66,12 @@ class FormInputAdapter( private val imageClickListener: ImageClickListener? = null, private val ageClickListener: AgeClickListener? = null, private val formValueListener: FormValueListener? = null, - private val isEnabled: Boolean = true -) : ListAdapter(FormInputDiffCallBack) { + private val isEnabled: Boolean = true, + private val selectImageClickListener: SelectUploadImageClickListener? = null, + private val viewDocumentListner: ViewDocumentOnClick? = null, + + ) : ListAdapter(FormInputDiffCallBack) { + var disableUpload = false // @Inject // lateinit var preferenceDao: PreferenceDao @@ -73,8 +82,9 @@ class FormInputAdapter( oldItem.id == newItem.id override fun areContentsTheSame(oldItem: FormElement, newItem: FormElement): Boolean { - Timber.d("${oldItem.id} ${oldItem.errorText} ${newItem.errorText}") - return oldItem.errorText == newItem.errorText + val contentsSame = oldItem.errorText == newItem.errorText && oldItem.value == newItem.value + Timber.d("${oldItem.id} errorText: ${oldItem.errorText}==${newItem.errorText}, value: '${oldItem.value}'=='${newItem.value}', same=$contentsSame") + return contentsSame } } @@ -89,19 +99,32 @@ class FormInputAdapter( } } - fun bind(item: FormElement, isEnabled: Boolean, formValueListener: FormValueListener?) { + fun bind( + item: FormElement, + isEnabled: Boolean, + formValueListener: FormValueListener?, + refreshDeliveryOutcomeFields: (() -> Unit)? = null + ) { Timber.d("binding triggered!!! $isEnabled ${item.id}") if (!isEnabled) { + binding.et.isEnabled = false binding.et.isClickable = false binding.et.isFocusable = false + binding.et.isFocusableInTouchMode = false + binding.et.isLongClickable = false + binding.et.isCursorVisible = false handleHintLength(item) binding.form = item binding.et.setText(item.value) binding.executePendingBindings() return } else { + binding.et.isEnabled = true binding.et.isClickable = true binding.et.isFocusable = true + binding.et.isFocusableInTouchMode = true + binding.et.isLongClickable = true + binding.et.isCursorVisible = true } binding.form = item if (item.errorText == null) binding.tilEditText.isErrorEnabled = false @@ -119,8 +142,14 @@ class FormInputAdapter( binding.tilEditText.setEndIconOnClickListener(null) } + if (!binding.et.hasFocus()) { + val currentText = binding.et.text?.toString() ?: "" + val itemValue = item.value ?: "" + if (currentText != itemValue) { + binding.et.setText(itemValue) + } + } - //binding.et.setText(item.value.value) val textWatcher = object : TextWatcher { override fun beforeTextChanged( s: CharSequence?, start: Int, count: Int, after: Int @@ -131,24 +160,33 @@ class FormInputAdapter( } override fun afterTextChanged(editable: Editable?) { -// editable?.length?.let { -// if (it > item.etMaxLength) { -//// editable.delete(item.etMaxLength + 1, it) -// "This field cannot have more than ${item.etMaxLength} characters".let { -// item.errorText = it -// binding.tilEditText.error = it -// } -// return -// } else -// item.errorText = null -// } - item.value = editable?.toString() - Timber.d("editable : $editable Current value : ${item.value} isNull: ${item.value == null} isEmpty: ${item.value == ""}") + val textValue = editable?.toString() ?: "" + item.value = textValue + + val trimmedValue = textValue.trim() + if (trimmedValue.isNotBlank() && item.errorText != null) { + item.errorText = null + binding.tilEditText.isErrorEnabled = false + binding.tilEditText.error = null + } + formValueListener?.onValueChanged(item, -1) if (item.errorText != binding.tilEditText.error) { binding.tilEditText.isErrorEnabled = item.errorText != null binding.tilEditText.error = item.errorText } + + // Delivery outcome fields share a cross-field validation rule. + // Rebind the whole trio so sibling error states refresh when the + // last field in the sum is edited. Gated by an explicit opt-in flag + // (NOT the element id) because ids are reused across datasets — id + // 15/16/17 are height/weight/bmi in the PW registration form, and + // rebinding the focused EditText on every keystroke steals focus. + if (item.refreshSiblingsOnChange) { + binding.root.post { + refreshDeliveryOutcomeFields?.invoke() + } + } // binding.tilEditText.error = null // else if(item.errorText!= null && binding.tilEditText.error==null) // binding.tilEditText.error = item.errorText @@ -237,9 +275,15 @@ class FormInputAdapter( if (hasFocus) binding.et.addTextChangedListener(textWatcher) else { binding.et.removeTextChangedListener(textWatcher) - val imm = - binding.root.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager? - imm!!.toggleSoftInput(InputMethodManager.HIDE_IMPLICIT_ONLY, 0) + // Use a deterministic hide here. The previous toggleSoftInput() + // call *toggled* the IME, so when a field lost focus while the + // keyboard was already closed (e.g. as the RecyclerView recycles + // EditText rows during a scroll) it would re-open the keyboard + // even though no field was tapped. + KeyboardUtils.hideKeyboard(binding.et) +// val imm = +// binding.root.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager? +// imm?.hideSoftInputFromWindow(binding.et.windowToken, 0) } } binding.et.setOnKeyListener(View.OnKeyListener { v, keyCode, event -> @@ -301,8 +345,15 @@ class FormInputAdapter( return } + binding.actvRvDropdown.setupDropdownKeyboardHandling() + binding.actvRvDropdown.setOnItemClickListener { _, _, index, _ -> item.value = item.entries?.get(index) + item.booleanValue = when (index) { + item.trueIndex -> true + item.falseIndex -> false + else -> null + } Timber.d("Item DD : $item") // if (item.hasDependants || item.hasAlertError) { formValueListener?.onValueChanged(item, index) @@ -330,10 +381,8 @@ class FormInputAdapter( fun bind( item: FormElement, isEnabled: Boolean, formValueListener: FormValueListener? ) { - if (!isEnabled) { - binding.rg.isClickable = false - binding.rg.isFocusable = false - } + binding.rg.isClickable = isEnabled + binding.rg.isFocusable = isEnabled // binding.rg.isEnabled = isEnabled binding.invalidateAll() binding.form = item @@ -344,49 +393,37 @@ class FormInputAdapter( item.entries?.let { items -> orientation = item.orientation ?: LinearLayout.HORIZONTAL weightSum = items.size.toFloat() + val isHorizontal = orientation == LinearLayout.HORIZONTAL items.forEach { val rdBtn = RadioButton(this.context) rdBtn.layoutParams = RadioGroup.LayoutParams( - RadioGroup.LayoutParams.WRAP_CONTENT, + if (isHorizontal) 0 else RadioGroup.LayoutParams.MATCH_PARENT, RadioGroup.LayoutParams.WRAP_CONTENT, 1.0F ).apply { gravity = Gravity.CENTER_HORIZONTAL } rdBtn.id = View.generateViewId() - val colorStateList = ColorStateList( - arrayOf( - intArrayOf(-android.R.attr.state_checked), - intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - binding.root.resources.getColor( - android.R.color.darker_gray, - binding.root.context.theme - ), // disabled - binding.root.resources.getColor( - android.R.color.darker_gray, - binding.root.context.theme - ) // enabled - ) - ) - - if (!isEnabled) rdBtn.buttonTintList = colorStateList rdBtn.text = it addView(rdBtn) if (item.value == it) rdBtn.isChecked = true + rdBtn.setOnClickListener { + KeyboardUtils.hideKeyboard(binding.root) + KeyboardUtils.hideKeyboardFromActivity(binding.root.context) + binding.rg.clearFocus() + } rdBtn.setOnCheckedChangeListener { _, b -> if (b) { item.value = it + val index = item.entries!!.indexOf(it) + item.booleanValue = when (index) { + item.trueIndex -> true + item.falseIndex -> false + else -> null + } if (item.hasDependants || item.hasAlertError) { - Timber.d( - "listener trigger : ${item.id} ${ - item.entries!!.indexOf( - it - ) - } $it" - ) formValueListener?.onValueChanged( - item, item.entries!!.indexOf(it) + item, index ) } } @@ -408,7 +445,11 @@ class FormInputAdapter( if (!isEnabled) { binding.rg.children.forEach { it.isClickable = false + it.isFocusable = false + it.isEnabled = true } + } else { + binding.rg.isEnabled = true } if (item.errorText != null) binding.llContent.setBackgroundResource(R.drawable.state_errored) else binding.llContent.setBackgroundResource(0) @@ -464,6 +505,11 @@ class FormInputAdapter( if (item.errorText != null) binding.clRi.setBackgroundResource(R.drawable.state_errored) else binding.clRi.setBackgroundResource(0) + // Clear listeners before removing views to prevent false unchecked callbacks + // that would strip values from item.value during rebind + for (i in 0 until binding.llChecks.childCount) { + (binding.llChecks.getChildAt(i) as? CheckBox)?.setOnCheckedChangeListener(null) + } binding.llChecks.removeAllViews() binding.llChecks.apply { item.entries?.let { items -> @@ -476,10 +522,6 @@ class FormInputAdapter( RadioGroup.LayoutParams.WRAP_CONTENT, 1.0F ) - if (!isEnabled) { - cbx.isClickable = false - cbx.isFocusable = false - } cbx.id = View.generateViewId() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) cbx.setTextAppearance( context, android.R.style.TextAppearance_Material_Medium @@ -487,10 +529,20 @@ class FormInputAdapter( else cbx.setTextAppearance(android.R.style.TextAppearance_Material_Subhead) cbx.text = it addView(cbx) - if (item.value?.contains(it) == true) cbx.isChecked = true + if (item.value?.split(",")?.any { s -> s.trim() == it } == true) cbx.isChecked = true + cbx.setOnClickListener { + KeyboardUtils.hideKeyboard(binding.root) + KeyboardUtils.hideKeyboardFromActivity(binding.root.context) + binding.llChecks.clearFocus() + } + if (!isEnabled) { + cbx.isClickable = false + cbx.isFocusable = false + cbx.isEnabled = true + } cbx.setOnCheckedChangeListener { _, b -> if (b) { - if (item.value != null) item.value = item.value + it + if (item.value != null && item.value!!.isNotEmpty()) item.value = item.value + "," + it else item.value = it if (item.hasDependants || item.hasAlertError) { Timber.d( @@ -506,11 +558,11 @@ class FormInputAdapter( } } else { if (item.value?.contains(it) == true) { - item.value = item.value?.replace(it, "") + item.value = item.value!!.split(",").filter { s -> s.trim() != it }.joinToString(",").trim().takeIf { str -> str.isNotEmpty() } ?: null } } formValueListener?.onValueChanged( - item, index * (if (b) 1 else -1) + item, (index + 1) * (if (b) 1 else -1) ) if (item.value.isNullOrBlank()) { item.value = null @@ -546,8 +598,11 @@ class FormInputAdapter( binding.form = item binding.invalidateAll() if (!isEnabled) { + binding.et.isEnabled = false binding.et.isFocusable = false binding.et.isClickable = false + binding.et.isFocusableInTouchMode = false + binding.et.isLongClickable = false binding.executePendingBindings() return } @@ -559,10 +614,19 @@ class FormInputAdapter( item.errorText?.also { binding.tilEditText.error = it } ?: run { binding.tilEditText.error = null } binding.et.setOnClickListener { + KeyboardUtils.hideKeyboard(binding.et) + KeyboardUtils.hideKeyboardFromActivity(binding.root.context) + item.value?.let { value -> - thisYear = value.substring(6).toInt() - thisMonth = value.substring(3, 5).trim().toInt() - 1 - thisDay = value.substring(0, 2).trim().toInt() + val parts = value.split("[-/]".toRegex()) + if (parts.size >= 3) { + thisDay = parts[0].trim().toIntOrNull() ?: thisDay + thisMonth = (parts[1].trim().toIntOrNull() ?: (thisMonth + 1)) - 1 + thisYear = parts[2].trim().toIntOrNull() ?: thisYear + } + } + val formatDate: (Long) -> String? = { millis -> + item.dateFormat?.let { Dataset.getDateFromLong(millis, it) } ?: getDateString(millis) } val datePickerDialog = DatePickerDialog( it.context, { _, year, month, day -> @@ -571,21 +635,18 @@ class FormInputAdapter( set(Calendar.MONTH, month) set(Calendar.DAY_OF_MONTH, day) }.timeInMillis - if (item.min != null && millis < item.min!!) { - item.value = getDateString(item.min) - } else if (item.max != null && millis > item.max!!) - item.value = getDateString(item.max) - else - item.value = getDateString(millis) -// "${if (day > 9) day else "0$day"}-${if (month > 8) month + 1 else "0${month + 1}"}-$year" + item.value = when { + item.min != null && millis < item.min!! -> formatDate(item.min!!) + item.max != null && millis > item.max!! -> formatDate(item.max!!) + else -> formatDate(millis) + } binding.invalidateAll() if (item.hasDependants) formValueListener?.onValueChanged(item, -1) }, thisYear, thisMonth, thisDay ) item.errorText = null binding.tilEditText.error = null - datePickerDialog.datePicker.maxDate = item.max ?: 0 - datePickerDialog.datePicker.minDate = item.min ?: 0 + datePickerDialog.datePicker.applySafeDateConstraints(item.min, item.max) if (item.showYearFirstInDatePicker) datePickerDialog.datePicker.touchables[0].performClick() datePickerDialog.show() @@ -611,22 +672,69 @@ class FormInputAdapter( binding.form = item binding.et.isEnabled = isEnabled binding.et.setOnClickListener { + KeyboardUtils.hideKeyboard(binding.et) + KeyboardUtils.hideKeyboardFromActivity(binding.root.context) + val hour: Int + val hourOfDay: Int // 24-hour format (0-23) for TimePickerDialog val minute: Int if (item.value == null) { val currentTime = Calendar.getInstance() - hour = currentTime.get(Calendar.HOUR_OF_DAY) + hourOfDay = currentTime.get(Calendar.HOUR_OF_DAY) minute = currentTime.get(Calendar.MINUTE) } else { - hour = item.value!!.substringBefore(":").toInt() - minute = item.value!!.substringAfter(":").toInt() - Timber.d("Time picker hour min : $hour $minute") + // Parse existing time value (handle both 12-hour with AM/PM and 24-hour formats) + val timeValue = item.value!! + val timeParts = timeValue.split(":") + if (timeParts.size >= 2) { + val hourStr = timeParts[0].trim() + val minuteAndAmPm = timeParts[1].trim() + val minuteStr = minuteAndAmPm.substringBefore(" ").trim() + + val parsedHour = hourStr.toIntOrNull() ?: 0 + val hasAmPm = timeValue.contains("AM", ignoreCase = true) || + timeValue.contains("PM", ignoreCase = true) || + timeValue.contains("am", ignoreCase = true) || + timeValue.contains("pm", ignoreCase = true) + + if (hasAmPm) { + // Already in 12-hour format with AM/PM - convert to 24-hour format + val isPM = timeValue.contains("PM", ignoreCase = true) || + timeValue.contains("pm", ignoreCase = true) + hourOfDay = when { + parsedHour == 12 && !isPM -> 0 // 12 AM = 0:00 + parsedHour == 12 && isPM -> 12 // 12 PM = 12:00 + isPM -> parsedHour + 12 // PM: add 12 + else -> parsedHour // AM: keep as is (except 12 AM handled above) + } + } else { + // Already in 24-hour format + hourOfDay = parsedHour + } + minute = minuteStr.toIntOrNull() ?: 0 + Timber.d("Time picker parsed hourOfDay: $hourOfDay, minute: $minute from value: $timeValue") + } else { + // Fallback to current time if parsing fails + val currentTime = Calendar.getInstance() + hourOfDay = currentTime.get(Calendar.HOUR_OF_DAY) + minute = currentTime.get(Calendar.MINUTE) + } } - val mTimePicker = TimePickerDialog(it.context, { _, hourOfDay, minuteOfHour -> - item.value = "$hourOfDay:$minuteOfHour" + + val mTimePicker = TimePickerDialog(it.context, { _, selectedHourOfDay, selectedMinute -> + // Format time in 12-hour format with AM/PM + // selectedHourOfDay is in 24-hour format (0-23) even when dialog is in 12-hour mode + val amPm = if (selectedHourOfDay < 12) "AM" else "PM" + val displayHour = when { + selectedHourOfDay == 0 -> 12 + selectedHourOfDay > 12 -> selectedHourOfDay - 12 + else -> selectedHourOfDay + } + val formattedMinute = String.format("%02d", selectedMinute) + item.value = "$displayHour:$formattedMinute $amPm" binding.invalidateAll() - }, hour, minute, false) + }, hourOfDay, minute, false) mTimePicker.setTitle("Select Time") mTimePicker.show() } @@ -692,6 +800,11 @@ class FormInputAdapter( binding.form = item if (isEnabled) { binding.clickListener = clickListener + binding.et.setOnClickListener { + KeyboardUtils.hideKeyboard(binding.et) + KeyboardUtils.hideKeyboardFromActivity(binding.root.context) + clickListener?.onAgeClick(item) + } // if (item.errorText == null) binding.tilEditText.isErrorEnabled = false // Timber.d("Bound EditText item ${item.title} with ${item.required}") // binding.tilEditText.error = item.errorText @@ -759,6 +872,7 @@ class FormInputAdapter( TIME_PICKER -> TimePickerInputViewHolder.from(parent) HEADLINE -> HeadlineViewHolder.from(parent) AGE_PICKER -> AgePickerViewInputViewHolder.from(parent) + InputType.FILE_UPLOAD -> FileUploadInputViewHolder.from(parent) } } @@ -767,7 +881,7 @@ class FormInputAdapter( val isEnabled = if (isEnabled) item.isEnabled else false when (item.inputType) { EDIT_TEXT -> (holder as EditTextInputViewHolder).bind( - item, isEnabled, formValueListener + item, isEnabled, formValueListener, ::refreshDeliveryOutcomeFields ) DROPDOWN -> (holder as DropDownInputViewHolder).bind(item, isEnabled, formValueListener) @@ -786,7 +900,12 @@ class FormInputAdapter( isEnabled, formValueListener ) - + InputType.FILE_UPLOAD -> (holder as FileUploadInputViewHolder).bind( + item, + selectImageClickListener, + viewDocumentListner, + isEnabled = isEnabled && !disableUpload + ) TIME_PICKER -> (holder as TimePickerInputViewHolder).bind(item, isEnabled) HEADLINE -> (holder as HeadlineViewHolder).bind(item, formValueListener) AGE_PICKER -> (holder as AgePickerViewInputViewHolder).bind( @@ -797,38 +916,135 @@ class FormInputAdapter( } } + class FileUploadInputViewHolder private constructor(private val binding: LayoutUploadFormBinding) : + ViewHolder(binding.root) { + companion object { + fun from(parent: ViewGroup): ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = LayoutUploadFormBinding.inflate(layoutInflater, parent, false) + return FileUploadInputViewHolder(binding) + } + } + + + fun bind( + item: FormElement, + clickListener: SelectUploadImageClickListener?, + documentOnClick: ViewDocumentOnClick?, + isEnabled: Boolean + ) { + binding.form = item + binding.tvTitle.text = item.title + binding.clickListener = clickListener + binding.documentclickListener = documentOnClick + binding.btnView.visibility = + if (item.value != null && documentOnClick != null) View.VISIBLE else View.GONE + + if (isEnabled) { + binding.addFile.visibility = View.VISIBLE +// binding.addFile.isEnabled = true +// binding.addFile.alpha = 1f + } else { + binding.addFile.visibility = View.GONE +// binding.addFile.isEnabled = false +// binding.addFile.alpha = 0.5f + } + } + + } + override fun getItemViewType(position: Int) = getItem(position).inputType.ordinal + fun refreshDeliveryOutcomeFields() { + val startIndex = currentList.indexOfFirst { it.refreshSiblingsOnChange } + if (startIndex != -1) { + notifyItemRangeChanged(startIndex, 3) + } + } + /** * Validation Result : -1 -> all good * else index of element creating trouble + * @param recyclerView Optional RecyclerView to sync EditText values before validation */ - fun validateInput(resources: Resources): Int { - var retVal = -1 - if (!isEnabled) return retVal + fun validateInput(resources: Resources, recyclerView: androidx.recyclerview.widget.RecyclerView? = null): Int { + if (!isEnabled) return -1 + + recyclerView?.let { syncAllEditTextValues(it) } + + clearErrorsForValidFields(resources) + + val firstErrorIndex = findFirstFieldWithError() + if (firstErrorIndex != -1) return firstErrorIndex + + return validateRequiredFields(resources) + } + + private fun clearErrorsForValidFields(resources: Resources) { + val requiredError = resources.getString(R.string.form_input_empty_error) + currentList.forEachIndexed { index, it -> + if (it.inputType != TEXT_VIEW && it.required) { + val trimmedValue = it.value?.trim() + if (!trimmedValue.isNullOrBlank() && it.errorText == requiredError) { + it.errorText = null + notifyItemChanged(index) + } + } + } + } + + private fun findFirstFieldWithError(): Int { currentList.forEachIndexed { index, it -> - Timber.d("Error text for ${it.title} ${it.errorText}") if (it.inputType != TEXT_VIEW && it.errorText != null) { - retVal = index - return@forEachIndexed + return index } } - Timber.d("Validation : $retVal") - if (retVal != -1) return retVal + return -1 + } + + private fun validateRequiredFields(resources: Resources): Int { + var retVal = -1 currentList.forEachIndexed { index, it -> if (it.inputType != TEXT_VIEW && it.required) { - if (it.value.isNullOrBlank()) { - Timber.d("validateInput called for item $it, with index ${index}") + val trimmedValue = it.value?.trim() + if (trimmedValue.isNullOrBlank()) { it.errorText = resources.getString(R.string.form_input_empty_error) notifyItemChanged(index) if (retVal == -1) retVal = index } } - /* if(it.regex!=null){ - Timber.d("Regex not null") - retVal= false - }*/ } return retVal } -} \ No newline at end of file + + class SelectUploadImageClickListener(private val selectImageClick: (formId: Int) -> Unit) { + + fun onSelectImageClick(form: FormElement) = selectImageClick(form.id) + + } + + class ViewDocumentOnClick(private val viewDocument: (formId: Int) -> Unit) { + + fun onViewDocumentClick(form: FormElement) = viewDocument(form.id) + + } + + fun syncAllEditTextValues(recyclerView: androidx.recyclerview.widget.RecyclerView) { + for (i in 0 until recyclerView.childCount) { + val child = recyclerView.getChildAt(i) + val position = recyclerView.getChildAdapterPosition(child) + if (position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) { + val formElement = getItem(position) + if (formElement.inputType == EDIT_TEXT) { + val editText = child.findViewById(R.id.et) + if (editText != null) { + val currentText = editText.text?.toString() ?: "" + if (currentText != formElement.value) { + formElement.value = currentText + } + } + } + } + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/IconGridAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/IconGridAdapter.kt new file mode 100644 index 000000000..6f605f73b --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/IconGridAdapter.kt @@ -0,0 +1,56 @@ +package org.piramalswasthya.cho.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.navigation.NavDirections +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.piramalswasthya.cho.databinding.RvItemIconGridBinding +import org.piramalswasthya.cho.model.Icon + +class IconGridAdapter( + private val clickListener: GridIconClickListener +) : + ListAdapter(IconDiffCallback) { + object IconDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Icon, newItem: Icon) = + oldItem.title == newItem.title + + override fun areContentsTheSame(oldItem: Icon, newItem: Icon) = + (oldItem == newItem) + + } + + + class IconViewHolder private constructor(private val binding: RvItemIconGridBinding) : + RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): IconViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemIconGridBinding.inflate(layoutInflater, parent, false) + return IconViewHolder(binding) + } + } + + fun bind(item: Icon, clickListener: GridIconClickListener) { + binding.homeIcon = item + binding.clickListener = clickListener + binding.executePendingBindings() + } + + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + IconViewHolder.from(parent) + + override fun onBindViewHolder(holder: IconViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class GridIconClickListener(val selectedListener: (dest: NavDirections) -> Unit) { + fun onClicked(icon: Icon) = selectedListener(icon.navAction) + + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/InfantListAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/InfantListAdapter.kt new file mode 100644 index 000000000..bd9e226f4 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/InfantListAdapter.kt @@ -0,0 +1,114 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemInfantListBinding +import org.piramalswasthya.cho.model.PatientDisplay +import org.piramalswasthya.cho.utils.DateTimeUtil +import java.util.concurrent.TimeUnit + +class InfantListAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + InfantDiffUtilCallBack +) { + + private object InfantDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientDisplay, + newItem: PatientDisplay + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PatientDisplay, + newItem: PatientDisplay + ) = oldItem == newItem + } + + class InfantViewHolder private constructor( + private val binding: RvItemInfantListBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): InfantViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemInfantListBinding.inflate(layoutInflater, parent, false) + return InfantViewHolder(binding) + } + } + + fun bind( + item: PatientDisplay, + clickListener: ClickListener? + ) { + binding.patient = item + binding.clickListener = clickListener + + // Set infant name + val firstName = item.patient.firstName ?: "" + val lastName = item.patient.lastName ?: "" + binding.tvInfantName.text = if (lastName.isNotEmpty()) { + "$firstName $lastName" + } else { + firstName + } + + // Set age + item.patient.dob?.let { + binding.tvAge.text = DateTimeUtil.calculateAgeString(it) + } ?: run { + binding.tvAge.text = binding.root.context.getString(org.piramalswasthya.cho.R.string.na) + } + + // Set DOB + item.patient.dob?.let { + binding.tvDob.text = DateTimeUtil.formatDate(it) + } ?: run { + binding.tvDob.text = binding.root.context.getString(org.piramalswasthya.cho.R.string.na) + } + + // Set beneficiary ID + binding.tvBeneficiaryId.text = item.patient.beneficiaryID?.toString() ?: "NA" + + // Set phone number + binding.tvPhoneNo.text = item.patient.phoneNo ?: "NA" + + // Set gender + binding.tvGender.text = item.gender?.genderName ?: "NA" + + // Set parent name + binding.tvParentName.text = item.patient.parentName ?: "NA" + + // Set days old (for overdue indicator) + item.patient.dob?.let { dob -> + val daysOld = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - dob.time) + binding.dueIcon.visibility = if (daysOld > 42) { + android.view.View.VISIBLE + } else { + android.view.View.GONE + } + } ?: run { + binding.dueIcon.visibility = android.view.View.GONE + } + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + InfantViewHolder.from(parent) + + override fun onBindViewHolder(holder: InfantViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedView: ((patient: PatientDisplay) -> Unit)? = null + ) { + fun onClickView(item: PatientDisplay) = + clickedView?.let { it(item) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/InfantRegistrationAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/InfantRegistrationAdapter.kt new file mode 100644 index 000000000..86f92360d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/InfantRegistrationAdapter.kt @@ -0,0 +1,77 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemInfantRegBinding +import org.piramalswasthya.cho.model.InfantRegDomain + +class InfantRegistrationAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + InfantDiffUtilCallBack +) { + + private object InfantDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: InfantRegDomain, + newItem: InfantRegDomain + ) = oldItem.motherPatient.patientID == newItem.motherPatient.patientID && + oldItem.babyIndex == newItem.babyIndex + + override fun areContentsTheSame( + oldItem: InfantRegDomain, + newItem: InfantRegDomain + ) = oldItem == newItem + } + + class InfantViewHolder private constructor( + private val binding: RvItemInfantRegBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): InfantViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemInfantRegBinding.inflate(layoutInflater, parent, false) + return InfantViewHolder(binding) + } + } + + fun bind( + item: InfantRegDomain, + clickListener: ClickListener? + ) { + binding.item = item + binding.clickListener = if (item.isActionEnabled()) clickListener else null + + // Set button color based on registration status (text is bound via app:infantRegActionText) + val showRegister = item.shouldShowRegisterAction() + binding.btnAction.setBackgroundColor( + binding.root.resources.getColor( + if (showRegister) android.R.color.holo_red_dark else android.R.color.holo_green_dark, + null + ) + ) + binding.btnAction.isEnabled = item.isActionEnabled() + binding.btnAction.alpha = if (item.isActionEnabled()) 1f else 0.7f + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + InfantViewHolder.from(parent) + + override fun onBindViewHolder(holder: InfantViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedForm: ((patientID: String, babyIndex: Int) -> Unit)? = null + ) { + fun onClickForm(item: InfantRegDomain) = + clickedForm?.let { it(item.motherPatient.patientID, item.babyIndex) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/NeonatalOutcomeAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/NeonatalOutcomeAdapter.kt new file mode 100644 index 000000000..c923166db --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/NeonatalOutcomeAdapter.kt @@ -0,0 +1,61 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemNeonatalOutcomeBinding +import org.piramalswasthya.cho.model.PatientWithPwrDomain + +class NeonatalOutcomeAdapter(private val clickListener: ClickListener) : + ListAdapter( + NeonatalOutcomeDiffCallback() + ) { + + class ViewHolder(private val binding: RvItemNeonatalOutcomeBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(patientWithPwr: PatientWithPwrDomain, clickListener: ClickListener) { + binding.patientWithPwr = patientWithPwr + binding.clickListener = clickListener + binding.executePendingBindings() + } + + companion object { + fun from(parent: ViewGroup): ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemNeonatalOutcomeBinding.inflate(layoutInflater, parent, false) + return ViewHolder(binding) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder.from(parent) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener(val clickListener: (patientWithPwr: PatientWithPwrDomain) -> Unit) { + fun onClick(patientWithPwr: PatientWithPwrDomain) = clickListener(patientWithPwr) + } +} + +class NeonatalOutcomeDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ): Boolean { + return oldItem.patient.patientID == newItem.patient.patientID + } + + override fun areContentsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ): Boolean { + return oldItem == newItem + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/PNCMotherAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/PNCMotherAdapter.kt new file mode 100644 index 000000000..35d8f3a3e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/PNCMotherAdapter.kt @@ -0,0 +1,131 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemPncMotherBinding +import org.piramalswasthya.cho.model.PatientWithPncDomain +import org.piramalswasthya.cho.utils.DateTimeUtil +import org.piramalswasthya.cho.repositories.PncRepo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PNCMotherAdapter( + private val clickListener: ClickListener? = null, + private val pncRepo: PncRepo +) : ListAdapter( + PNCMotherDiffUtilCallBack +) { + + private object PNCMotherDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientWithPncDomain, + newItem: PatientWithPncDomain + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PatientWithPncDomain, + newItem: PatientWithPncDomain + ) = oldItem == newItem + } + + class PNCMotherViewHolder private constructor( + private val binding: RvItemPncMotherBinding + ) : RecyclerView.ViewHolder(binding.root) { + + private var bindJob: Job? = null + + companion object { + fun from(parent: ViewGroup): PNCMotherViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemPncMotherBinding.inflate(layoutInflater, parent, false) + return PNCMotherViewHolder(binding) + } + } + + fun bind( + item: PatientWithPncDomain, + clickListener: ClickListener?, + pncRepo: PncRepo + ) { + binding.patientWithPnc = item + binding.clickListener = clickListener + + // Set age + item.patient.dob?.let { + binding.tvAge.text = DateTimeUtil.calculateAgeString(it) + } ?: run { + binding.tvAge.text = "NA" + } + + // Set delivery date + binding.tvDeliveryDate.text = item.getFormattedDeliveryDate() + + // Set days since delivery + val daysSinceDelivery = item.getDaysSinceDelivery() + binding.tvDaysSinceDelivery.text = when { + daysSinceDelivery == null -> "NA" + daysSinceDelivery > 0 -> "$daysSinceDelivery days" + else -> "Today" + } + + // Enable/disable Add Visit button based on eligibility + bindJob?.cancel() + bindJob = CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { + val isEnabled = withContext(Dispatchers.IO) { + checkAddVisitEligibility(item, pncRepo) + } + if (!isActive) return@launch // ViewHolder may have been recycled + binding.btnAddVisit.isEnabled = isEnabled + binding.btnAddVisit.alpha = if (isEnabled) 1.0f else 0.5f + } + + binding.executePendingBindings() + } + + private suspend fun checkAddVisitEligibility( + item: PatientWithPncDomain, + pncRepo: PncRepo + ): Boolean { + // Get last visit number + val lastVisitNumber = pncRepo.getLastVisitNumber(item.patient.patientID) ?: 0 + val availableVisits = listOf(1, 3, 7, 14, 21, 28, 42).filter { it > lastVisitNumber } + + // No more visits available + if (availableVisits.isEmpty()) return false + + val nextVisitNumber = availableVisits.first() + + // First visit is always allowed + if (lastVisitNumber == 0) return true + + // For subsequent visits, check days since delivery + val daysSinceDelivery = item.getDaysSinceDelivery() ?: return false + + // Enable button if enough days have elapsed + return daysSinceDelivery >= nextVisitNumber + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PNCMotherViewHolder.from(parent) + + override fun onBindViewHolder(holder: PNCMotherViewHolder, position: Int) { + holder.bind(getItem(position), clickListener, pncRepo) + } + + class ClickListener( + private val onAddVisit: ((patientWithPnc: PatientWithPncDomain) -> Unit)? = null, + private val onViewVisits: ((patientWithPnc: PatientWithPncDomain) -> Unit)? = null + ) { + fun clickAddVisit(item: PatientWithPncDomain) = onAddVisit?.invoke(item) + fun clickViewVisits(item: PatientWithPncDomain) = onViewVisits?.invoke(item) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/PatientItemAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/PatientItemAdapter.kt index f50ec10dc..0540d09e4 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/PatientItemAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/PatientItemAdapter.kt @@ -1,8 +1,10 @@ package org.piramalswasthya.cho.adapter import android.content.Context +import android.graphics.Bitmap import android.os.Build import android.util.Log +import android.util.LruCache import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,32 +13,72 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import org.piramalswasthya.cho.R import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.databinding.PatientListItemViewBinding import org.piramalswasthya.cho.model.PatientDisplayWithVisitInfo import org.piramalswasthya.cho.network.ESanjeevaniApiService import org.piramalswasthya.cho.ui.home.HomeViewModel -import org.piramalswasthya.cho.utils.Constants.pattern import org.piramalswasthya.cho.utils.DateTimeUtil +import org.piramalswasthya.cho.utils.ImgUtils class PatientItemAdapter( private val apiService: ESanjeevaniApiService, var context: Context, private val clickListener: BenClickListener, private val showAbha: Boolean = false, + private val showEditButton: Boolean = false, ) : ListAdapter(BenDiffUtilCallBack) { + + companion object { + // Bitmap cache for beneficiary photos to avoid repeated Base64 decode on scroll/rebind. + private val benImageBitmapCache = object : LruCache(6 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024 + } + } + private object BenDiffUtilCallBack : DiffUtil.ItemCallback() { + private fun stableKey(item: PatientDisplayWithVisitInfo): String { + return item.patient.beneficiaryID?.toString() + ?: "${item.patient.patientID}_${item.benVisitNo ?: -1}" + } + override fun areItemsTheSame( oldItem: PatientDisplayWithVisitInfo, newItem: PatientDisplayWithVisitInfo - ) = oldItem.patient.beneficiaryID == newItem.patient.beneficiaryID + ) = stableKey(oldItem) == stableKey(newItem) override fun areContentsTheSame( oldItem: PatientDisplayWithVisitInfo, newItem: PatientDisplayWithVisitInfo - ) = oldItem == newItem + ): Boolean { + val oldPatient = oldItem.patient + val newPatient = newItem.patient + + return oldPatient.firstName == newPatient.firstName && + oldPatient.lastName == newPatient.lastName && + oldPatient.dob == newPatient.dob && + oldPatient.phoneNo == newPatient.phoneNo && + oldPatient.beneficiaryID == newPatient.beneficiaryID && + oldPatient.syncState == newPatient.syncState && + oldPatient.healthIdDetails?.healthIdNumber == newPatient.healthIdDetails?.healthIdNumber && + oldPatient.benImage == newPatient.benImage && + oldItem.genderName == newItem.genderName && + oldItem.villageName == newItem.villageName && + oldItem.visitDate == newItem.visitDate && + oldItem.referDate == newItem.referDate && + oldItem.referTo == newItem.referTo && + oldItem.nurseFlag == newItem.nurseFlag && + oldItem.doctorFlag == newItem.doctorFlag && + oldItem.labtechFlag == newItem.labtechFlag && + oldItem.pharmacist_flag == newItem.pharmacist_flag + } } + init { + setHasStableIds(true) + } + class BenViewHolder private constructor(private val binding: PatientListItemViewBinding) : RecyclerView.ViewHolder(binding.root) { companion object { @@ -52,20 +94,22 @@ class PatientItemAdapter( item: PatientDisplayWithVisitInfo, clickListener: BenClickListener?, showAbha: Boolean, + showEditButton: Boolean, mContext: Context ) { var gender = "" binding.benVisitInfo = item binding.clickListener = clickListener binding.showAbha = showAbha + binding.showEditButton = showEditButton binding.hasAbha = !item.patient.healthIdDetails?.healthIdNumber.isNullOrEmpty() val firstName = item.patient.firstName ?: "" val lastName = item.patient.lastName ?: "" val capitalizedFirstName = firstName.split(" ") - .joinToString(" ") { it -> it.replaceFirstChar { it.uppercaseChar() } } + .joinToString(" ") { token -> token.replaceFirstChar { it.uppercaseChar() } } val capitalizedLastName = lastName.split(" ") - .joinToString(" ") { it -> it.replaceFirstChar { it.uppercaseChar() } } + .joinToString(" ") { token -> token.replaceFirstChar { it.uppercaseChar() } } val fullName = "$capitalizedFirstName $capitalizedLastName" binding.patientName.text = fullName @@ -76,54 +120,49 @@ class PatientItemAdapter( val visitDateText = item.visitDate?.let { DateTimeUtil.formatDate(it) } if (visitDateText.isNullOrBlank()) { - binding.visitDate.text = "NA" - binding.referDate.text = "NA" + binding.visitDate.text = "" + binding.referDate.text = "" } else { binding.visitDate.text = visitDateText binding.referDate.text = visitDateText } - binding.patientPhoneNo.text = item.patient.phoneNo ?: "NA" + binding.patientPhoneNo.text = item.patient.phoneNo + ?.takeIf { it.isNotBlank() } + ?: mContext.getString(R.string.not_available) if (item.villageName.isNullOrBlank()) { - binding.village.text = "NA" + binding.village.text = "" } else binding.village.text = item.villageName binding.patientGender.text = item.genderName gender = item.genderName.toString() - if (item.patient.dob != null) { - val type = DateTimeUtil.getPatientTypeByAge(item.patient.dob!!) - if (type == "new_born_baby") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_new_born_baby) - } else if (type == "infant") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_infant) - } else if (type == "child") { - //male female check - if (gender == "Male") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_boy) - } else if (gender == "Female") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_girl) + // Try to load the beneficiary photo first; fall back to age/gender icon if none + val benImage = item.patient.benImage + if (!benImage.isNullOrEmpty()) { + val cacheKey = "${item.patient.patientID}:${benImage.hashCode()}" + val cachedBitmap = PatientItemAdapter.benImageBitmapCache.get(cacheKey) + if (cachedBitmap != null) { + Glide.with(mContext) + .load(cachedBitmap) + .placeholder(R.drawable.ic_person) + .circleCrop() + .into(binding.ivPatientIcon) + } else { + val base64Data = if (benImage.contains(",")) benImage.substringAfter(",") else benImage + val bitmap = ImgUtils.decodeBase64ToBitmap(base64Data) + if (bitmap != null) { + PatientItemAdapter.benImageBitmapCache.put(cacheKey, bitmap) + Glide.with(mContext) + .load(bitmap) + .placeholder(R.drawable.ic_person) + .circleCrop() + .into(binding.ivPatientIcon) } else { - - } - - } else if (type == "adolescence") { - if (gender == "Male") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_boy) - } else if (gender == "Female") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_girl) - } else { - - } - - } else if (type == "adult") { - if (gender == "Male") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_male) - } else if (gender == "Female") { - binding.ivPatientIcon.setImageResource(R.drawable.ic_female) - } else { - binding.ivPatientIcon.setImageResource(R.drawable.ic_unisex) + setDefaultPatientIcon(item.patient.dob, gender, binding) } } + } else { + setDefaultPatientIcon(item.patient.dob, gender, binding) } if (item.patient.syncState == SyncState.SYNCED) { @@ -150,23 +189,26 @@ class PatientItemAdapter( } - if (item.patient.syncState == SyncState.SYNCED) { - // binding.ivSyncState.visibility = View.VISIBLE + // Show BeneficiaryID if it exists, regardless of sync state + if (item.patient.beneficiaryID != null) { binding.patientBenId.text = item.patient.beneficiaryID.toString() binding.llBenId.visibility = View.VISIBLE + } else { + binding.llBenId.visibility = View.GONE + } + + if (item.patient.syncState == SyncState.SYNCED ) { + // binding.ivSyncState.visibility = View.VISIBLE binding.btnAbha.isEnabled = true } else { binding.btnAbha.isEnabled = false - binding.llBenId.visibility = View.GONE // binding.ivSyncState.visibility = View.GONE } - /* Commented as prescription button should not display to user*/ - - if(item.doctorFlag == 9){ - binding.prescriptionDownloadBtn.visibility = View.VISIBLE - }else{ - binding.prescriptionDownloadBtn.visibility = View.GONE - } + // Show prescription download when doctor has submitted (medicine prescribed) or pharmacist has submitted + // doctorFlag 2 = submitted with test pending, 3 = post-lab, 9 = submitted without test + val showPrescriptionDownload = item.pharmacist_flag == 9 || + item.doctorFlag == 2 || item.doctorFlag == 3 || item.doctorFlag == 9 + binding.prescriptionDownloadBtn.visibility = if (showPrescriptionDownload) View.VISIBLE else View.GONE if (item.referTo != null) { binding.referToLl.visibility = View.VISIBLE @@ -180,15 +222,40 @@ class PatientItemAdapter( binding.executePendingBindings() } + + private fun setDefaultPatientIcon(dob: java.util.Date?, gender: String, binding: PatientListItemViewBinding) { + if (dob != null) { + val type = DateTimeUtil.getPatientTypeByAge(dob) + when (type) { + "new_born_baby" -> binding.ivPatientIcon.setImageResource(R.drawable.ic_new_born_baby) + "infant" -> binding.ivPatientIcon.setImageResource(R.drawable.ic_infant) + "child", "adolescence" -> { + if (gender == "Male") binding.ivPatientIcon.setImageResource(R.drawable.ic_boy) + else if (gender == "Female") binding.ivPatientIcon.setImageResource(R.drawable.ic_girl) + } + "adult" -> { + if (gender == "Male") binding.ivPatientIcon.setImageResource(R.drawable.ic_male) + else if (gender == "Female") binding.ivPatientIcon.setImageResource(R.drawable.ic_female) + else binding.ivPatientIcon.setImageResource(R.drawable.ic_unisex) + } + } + } + } } override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ) = BenViewHolder.from(parent) + override fun getItemId(position: Int): Long { + val item = getItem(position) + val uniqueKey = "${item.patient.patientID}:${item.benVisitNo ?: -1}" + return uniqueKey.hashCode().toLong() + } + override fun onBindViewHolder(holder: BenViewHolder, position: Int) { // patientId = getItem(position).patient.patientID - holder.bind(getItem(position), clickListener, showAbha, holder.itemView.context) + holder.bind(getItem(position), clickListener, showAbha, showEditButton, holder.itemView.context) } @@ -198,6 +265,7 @@ class PatientItemAdapter( private val clickedEsanjeevani: (benVisitInfo: PatientDisplayWithVisitInfo) -> Unit, private val clickedDownloadPrescription: (benVisitInfo: PatientDisplayWithVisitInfo) -> Unit, private val syncIconButton: (benVisitInfo: PatientDisplayWithVisitInfo) -> Unit, + private val clickedEdit: (benVisitInfo: PatientDisplayWithVisitInfo) -> Unit ) { fun onClickedBen(item: PatientDisplayWithVisitInfo) = clickedBen( item, @@ -219,7 +287,10 @@ class PatientItemAdapter( fun onClickSync(item: PatientDisplayWithVisitInfo) { syncIconButton(item) } + fun onClickEdit(item: PatientDisplayWithVisitInfo) { + clickedEdit(item) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/PharmacistItemAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/PharmacistItemAdapter.kt index 5f2c4374c..47e503f88 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/PharmacistItemAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/PharmacistItemAdapter.kt @@ -1,17 +1,15 @@ package org.piramalswasthya.cho.adapter -import android.annotation.SuppressLint import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import okhttp3.internal.notifyAll +import org.piramalswasthya.cho.R import org.piramalswasthya.cho.databinding.PharmacistListItemViewBinding import org.piramalswasthya.cho.model.PrescriptionItemDTO import timber.log.Timber @@ -21,12 +19,13 @@ class PharmacistItemAdapter( private val context: Context, private var issueType: String, private val clickListener: PharmacistClickListener, + private val networkAvailabilityCheck: () -> Boolean ) : ListAdapter(BenDiffUtilCallBack) { private object BenDiffUtilCallBack : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: PrescriptionItemDTO, newItem: PrescriptionItemDTO - ) = oldItem.drugID == newItem.drugID + ) = oldItem.id == newItem.id override fun areContentsTheSame( oldItem: PrescriptionItemDTO, newItem: PrescriptionItemDTO @@ -50,13 +49,17 @@ class PharmacistItemAdapter( fun bind( item: PrescriptionItemDTO, issueType:String, - clickListener: PharmacistClickListener + clickListener: PharmacistClickListener, + networkAvailabilityCheck: () -> Boolean ) { Timber.d("*******************DAta Prescription DTO************** ",clickListener) binding.prescription = item binding.clickListener = clickListener - binding.medicationName.text = (item.genericDrugName + " "+ item.drugStrength) + binding.medicationName.text = listOf( + item.genericDrugName.trim(), + item.drugStrength?.trim().orEmpty() + ).filter { it.isNotBlank() }.joinToString(" ") binding.formValue.text = (item.drugForm ?: "") if(item.duration!=null){ binding.durationValue.text = (item.duration ?:"") + " " + (item.durationUnit ?: "") @@ -65,18 +68,52 @@ class PharmacistItemAdapter( binding.doseValue.text = item.dose ?: "" binding.quantityPrescribedValue.text = item.qtyPrescribed.toString() ?: "" binding.routeValue.text = item.route ?: "" - binding.quantityDispensedValue.text = item.qtyPrescribed.toString() ?: "" - binding.specialInstructionValue.text = item.dose ?: "" - binding.btnViewBatch.text = if (issueType == "Manual Issue") { - "Select Batch" + val dispensedQty = if (issueType == "Manual Issue") { + item.batchList.filter { it.isSelected }.sumOf { it.dispenseQuantity } } else { - "View Batch" +// item.batchList.sumOf { it.qty } + item.qtyPrescribed.toString() } - binding.btnViewBatch.setOnClickListener { - if (issueType == "Manual Issue") { - clickListener.onClickSelectBatch(item) - } else { - clickListener.onClickViewBatch(item) + binding.quantityValue.text = item.batchList.sumOf { it.qty }.toString() + + binding.quantityDispensedValue.text = dispensedQty.toString() + binding.specialInstructionValue.text = item.instructions ?: "" + + // Handle button visibility and text based on issue type and network availability + when (issueType) { + "Manual Issue" -> { + val isNetworkAvailable = networkAvailabilityCheck() + val hasSelectedBatch = item.batchList.any { it.isSelected && it.dispenseQuantity > 0 } + binding.btnViewBatch.text = if (hasSelectedBatch) "Edit Batch" else "Select Batch" + binding.btnViewBatch.visibility = android.view.View.VISIBLE + binding.btnViewBatch.isEnabled = isNetworkAvailable + binding.btnViewBatch.setOnClickListener { + if (isNetworkAvailable) { + clickListener.onClickSelectBatch(item) + } else { + android.widget.Toast.makeText( + binding.root.context, + binding.root.context.getString(R.string.network_required_manual_batch), + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + } + "System Issue" -> { + // Hide/comment the view batch button for system issue + binding.btnViewBatch.visibility = android.view.View.GONE + // Alternatively, you can comment out the button functionality: + /* + binding.btnViewBatch.text = "View Batch" + binding.btnViewBatch.visibility = android.view.View.VISIBLE + binding.btnViewBatch.isEnabled = false + binding.btnViewBatch.setOnClickListener { + clickListener.onClickViewBatch(item) + } + */ + } + else -> { + binding.btnViewBatch.visibility = android.view.View.GONE } } binding.executePendingBindings() @@ -90,7 +127,7 @@ class PharmacistItemAdapter( override fun onBindViewHolder(holder: BenViewHolder, position: Int) { drugID = getItem(position).drugID.toString() - holder.bind(getItem(position), issueType , clickListener) + holder.bind(getItem(position), issueType, clickListener, networkAvailabilityCheck) // holder.itemView.findViewById(R.id.submit_btn).setOnClickListener { // When submit button is clicked // network = isInternetAvailable(context) @@ -144,4 +181,4 @@ class PharmacistItemAdapter( notifyDataSetChanged() // Refresh UI with updated label } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/PmsmaListAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/PmsmaListAdapter.kt new file mode 100644 index 000000000..7bdf74a77 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/PmsmaListAdapter.kt @@ -0,0 +1,66 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemPmsmaBinding +import org.piramalswasthya.cho.model.PmsmaDomain + +class PmsmaListAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + PmsmaDiffUtilCallBack +) { + + private object PmsmaDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PmsmaDomain, + newItem: PmsmaDomain + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PmsmaDomain, + newItem: PmsmaDomain + ) = oldItem == newItem + } + + class PmsmaViewHolder private constructor( + private val binding: RvItemPmsmaBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): PmsmaViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemPmsmaBinding.inflate(layoutInflater, parent, false) + return PmsmaViewHolder(binding) + } + } + + fun bind( + item: PmsmaDomain, + clickListener: ClickListener? + ) { + binding.item = item + binding.clickListener = clickListener + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PmsmaViewHolder.from(parent) + + override fun onBindViewHolder(holder: PmsmaViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedAdd: ((item: PmsmaDomain) -> Unit)? = null, + private val clickedView: ((item: PmsmaDomain) -> Unit)? = null, + ) { + fun onClickAdd(item: PmsmaDomain) = clickedAdd?.invoke(item) + fun onClickView(item: PmsmaDomain) = clickedView?.invoke(item) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/PregnantWomenAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/PregnantWomenAdapter.kt new file mode 100644 index 000000000..d797ea0e9 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/PregnantWomenAdapter.kt @@ -0,0 +1,80 @@ +package org.piramalswasthya.cho.adapter + +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.cho.databinding.RvItemPregnantWomanBinding +import org.piramalswasthya.cho.model.PatientWithPwrDomain + +class PregnantWomenAdapter( + private val clickListener: ClickListener? = null +) : ListAdapter( + PregnantWomanDiffUtilCallBack +) { + + private object PregnantWomanDiffUtilCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ) = oldItem.patient.patientID == newItem.patient.patientID + + override fun areContentsTheSame( + oldItem: PatientWithPwrDomain, + newItem: PatientWithPwrDomain + ) = oldItem == newItem + } + + class PregnantWomanViewHolder private constructor( + private val binding: RvItemPregnantWomanBinding + ) : RecyclerView.ViewHolder(binding.root) { + + companion object { + fun from(parent: ViewGroup): PregnantWomanViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = RvItemPregnantWomanBinding.inflate(layoutInflater, parent, false) + return PregnantWomanViewHolder(binding) + } + } + + fun bind( + item: PatientWithPwrDomain, + clickListener: ClickListener? + ) { + binding.patientWithPwr = item + binding.clickListener = clickListener + + // Handle PWR status display (registered or not) + if (item.pwr == null || !item.isActive()) { + // Not registered or inactive pregnancy + binding.btnViewPwr.text = binding.root.context.getString(org.piramalswasthya.cho.R.string.register) + binding.btnViewPwr.setBackgroundColor( + binding.root.resources.getColor(android.R.color.holo_red_dark, null) + ) + } else { + // Already registered and active + binding.btnViewPwr.text = binding.root.context.getString(org.piramalswasthya.cho.R.string.view) + binding.btnViewPwr.setBackgroundColor( + binding.root.resources.getColor(android.R.color.holo_green_dark, null) + ) + } + + binding.executePendingBindings() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + PregnantWomanViewHolder.from(parent) + + override fun onBindViewHolder(holder: PregnantWomanViewHolder, position: Int) { + holder.bind(getItem(position), clickListener) + } + + class ClickListener( + private val clickedView: ((patientWithPwr: PatientWithPwrDomain) -> Unit)? = null + ) { + fun onClickView(item: PatientWithPwrDomain) = + clickedView?.let { it(item) } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/PrescriptionAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/PrescriptionAdapter.kt index ead62d905..41b5ae1db 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/PrescriptionAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/PrescriptionAdapter.kt @@ -9,6 +9,7 @@ import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import android.widget.TextView import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.recyclerview.widget.RecyclerView @@ -21,17 +22,22 @@ import org.piramalswasthya.cho.model.PrescriptionValues import org.piramalswasthya.cho.ui.commons.case_record.FormItemAdapter import org.piramalswasthya.cho.ui.setSpinnerItems import org.piramalswasthya.cho.utils.HelperUtil +import org.piramalswasthya.cho.utils.setupDropdownKeyboardHandling +import org.piramalswasthya.cho.utils.KeyboardUtils class PrescriptionAdapter( private val isVisitDetail: Boolean? = null, private val isFollowupVisit: Boolean? = null, + private val isMedicineDispensedByPharmacist: Boolean = false, + private var dispensedLockedItemCount: Int = 0, private val itemList: MutableList, private val formMD: List, private val frequencyDropDown: List, private val unitDropDown: List, private val instructionDropdown: List, private val itemMasterForFilter: List, - private val itemChangeListener: RecyclerViewItemChangeListenersP + private val itemChangeListener: RecyclerViewItemChangeListenersP, + private val fetchStockListener: ((Int, (Int) -> Unit) -> Unit)? = null ) : RecyclerView.Adapter() { private var durationCount = 0 @@ -52,6 +58,7 @@ class PrescriptionAdapter( val addButton : TextView = itemView.findViewById(R.id.addButton) val subtractButton : TextView = itemView.findViewById(R.id.subtractButton) val textPrescriptionHeading : TextView = itemView.findViewById(R.id.textPrescriptionHeading) + val textStock : TextView = itemView.findViewById(R.id.textStock) // Dropdown Fields val formOptionsDropDown: TextInputLayout = itemView.findViewById(R.id.dosagesDropDown) @@ -110,8 +117,9 @@ class PrescriptionAdapter( itemData.frequency = "" itemData.duration = "" itemData.instructions = "" - itemData.unit = "" + itemData.unit = "Day(s)" itemData.id = null + textStock.text = "" notifyItemChanged(position) itemChangeListener.onItemChanged() } @@ -121,6 +129,9 @@ class PrescriptionAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { durationCount=0 val itemData = itemList[position] + + android.util.Log.d("PrescriptionAdapter", "onBindViewHolder: position=$position, title=${itemData.title}, isDispensed=${itemData.isDispensed}, form=${itemData.form}, id=${itemData.id}") + holder.subtractButton.isEnabled = false holder.addButton.setOnClickListener { durationCount = itemData.duration.toIntOrNull()?.takeIf { it <= maxDuration } ?: 0 @@ -132,7 +143,7 @@ class PrescriptionAdapter( } // Disable the "Add" button when the duration count reaches the maximum if (durationCount >= maxDuration) { - Toast.makeText(holder.itemView.context, "Maximum value allowed for Duration is 6.", Toast.LENGTH_SHORT).show() + Toast.makeText(holder.itemView.context, holder.itemView.context.getString(R.string.max_duration_allowed_6), Toast.LENGTH_SHORT).show() holder.addButton.isEnabled = false } // Enable the "Subtract" button @@ -161,7 +172,15 @@ class PrescriptionAdapter( holder.addButton.isEnabled = true } - if (isVisitDetail == true && isFollowupVisit == false){ + // Read-only in view mode; dispensed rows always stay locked across multi-cycle edits. + val isCaseReadOnly = (isVisitDetail == true && isFollowupVisit == false) + val isDispensedLockedRow = itemData.isDispensed || (isMedicineDispensedByPharmacist && position < dispensedLockedItemCount) + val isRowReadOnly = isCaseReadOnly || isDispensedLockedRow + val hasData = itemData.id != null || itemData.form.isNotBlank() || itemData.frequency.isNotBlank() || + itemData.duration.isNotBlank() || itemData.instructions.isNotBlank() || itemData.unit.isNotBlank() + holder.itemView.visibility = if (isRowReadOnly && !hasData) View.GONE else View.VISIBLE + + if (isRowReadOnly) { holder.subtractButton.isEnabled = false holder.addButton.isEnabled = false @@ -175,20 +194,52 @@ class PrescriptionAdapter( holder.resetButton.isVisible = false holder.cancelButton.isVisible = false + } else { + holder.resetButton.isVisible = true + holder.cancelButton.isVisible = true } - // Bind data and set listeners for user interactions - holder.formOptions.setText(itemData.form) - holder.frequencyOptions.setText(itemData.frequency) + // Bind data WITHOUT triggering text watchers (set data before adding listeners) + // Use setText with BufferType.EDITABLE to avoid triggering watchers + holder.formOptions.setText(itemData.form, false) + holder.frequencyOptions.setText(itemData.frequency, false) holder.durationInput.setText(itemData.duration) - holder.instructionOption.setText(itemData.instructions) - holder.unitOption.setText(itemData.unit) + holder.instructionOption.setText(itemData.instructions, false) + holder.unitOption.setText(itemData.unit, false) holder.cancelButton.isEnabled = itemCount > 1 holder.resetButton.isEnabled = false + android.util.Log.d("PrescriptionAdapter", "Data bound: position=$position, form='${itemData.form}', frequency='${itemData.frequency}', duration='${itemData.duration}'") + + // Show "Dispensed" label in green for dispensed medicines + if (itemData.isDispensed) { + holder.textPrescriptionHeading.text = "${itemData.title} - Dispensed" + holder.textPrescriptionHeading.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.stock_green)) + } else { + holder.textPrescriptionHeading.text = itemData.title + holder.textPrescriptionHeading.setTextColor(ContextCompat.getColor(holder.itemView.context, android.R.color.black)) + } + if(itemData.id!=null){ var st = formMD.find { it.itemID==itemData.id } - holder.formOptions.setText(st?.dropdownForMed.toString()) + holder.formOptions.setText(st?.dropdownForMed.toString(), false) + + android.util.Log.d("PrescriptionAdapter", "Setting form from ID: position=$position, id=${itemData.id}, form=${st?.dropdownForMed}") + + itemData.id?.let { id -> + fetchStockListener?.invoke(id) { stock -> + if (stock > 0) { + holder.textStock.text = holder.itemView.context.getString(R.string.stock_label, stock) + holder.textStock.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.stock_green)) + } else { + holder.textStock.text = holder.itemView.context.getString(R.string.out_of_stock) + holder.textStock.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.stock_red)) + } + } + } + } else { + android.util.Log.d("PrescriptionAdapter", "No ID for position=$position, clearing stock text") + holder.textStock.text = "" } // holder.formOptions.setSpinnerItems(formMD.map { it.dropdownForMed }.toTypedArray()) @@ -209,7 +260,20 @@ class PrescriptionAdapter( selectedItem?.let { holder.formOptions.setText(selectedName) + holder.formOptions.setSelection(holder.formOptions.text?.length ?: 0) itemData.id = it.itemID + + it.itemID?.let { id -> + fetchStockListener?.invoke(id) { stock -> + if (stock > 0) { + holder.textStock.text = holder.itemView.context.getString(R.string.stock_label, stock) + holder.textStock.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.stock_green)) + } else { + holder.textStock.text = holder.itemView.context.getString(R.string.out_of_stock) + holder.textStock.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.stock_red)) + } + } + } } } @@ -229,6 +293,38 @@ class PrescriptionAdapter( formMD.map { it.dropdownForMed }.toTypedArray() ?.let { holder.formOptions.setSpinnerItems(it) } + holder.formOptions.threshold = 0 + holder.frequencyOptions.threshold = 0 + holder.unitOption.threshold = 0 + holder.instructionOption.threshold = 0 + + if (!isRowReadOnly) { + holder.formOptions.setupDropdownKeyboardHandling() + + // Keep editable autocomplete behavior for text-entry dropdowns. + holder.formOptions.showSoftInputOnFocus = true + holder.formOptions.setOnTouchListener(null) + holder.formOptions.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) holder.formOptions.showDropDown() + } + holder.formOptions.setOnClickListener { holder.formOptions.showDropDown() } + + // Frequency, Unit, Instruction are pure dropdowns (endIconMode=dropdown_menu). + // Do NOT apply setupDropdownKeyboardHandling — it conflicts with Material's + // dropdown_menu behaviour, causing a grey/disabled look and blocking field taps. + val dropdownFields = listOf(holder.frequencyOptions, holder.unitOption, holder.instructionOption) + for (field in dropdownFields) { + field.setOnClickListener { field.showDropDown() } + field.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + KeyboardUtils.hideKeyboard(field) + field.showDropDown() + } + } + } + } + + holder.formOptions.addTextChangedListener{ itemData.form= it.toString() holder.updateResetButtonState() @@ -248,7 +344,7 @@ class PrescriptionAdapter( if (!s.isNullOrBlank() && s.length == 1 && s[0] == '0') s.clear() if (!s.isNullOrBlank() && s.toString().toInt()>6) { s.clear() - Toast.makeText(holder.itemView.context, "Maximum value allowed for Duration is 6.", Toast.LENGTH_SHORT).show() + Toast.makeText(holder.itemView.context, holder.itemView.context.getString(R.string.max_duration_allowed_6), Toast.LENGTH_SHORT).show() } else { itemData.duration = s.toString() @@ -275,7 +371,8 @@ class PrescriptionAdapter( // Update the visibility of the "Reset" button for all items holder.updateResetButtonState() - holder.textPrescriptionHeading.text = "Medicine - ${position + 1}" + // Don't overwrite the title if it's already set (especially for dispensed medicines) + // The title with "Dispensed" label is already set above } @@ -289,7 +386,12 @@ class PrescriptionAdapter( override fun getItemCount(): Int = itemList.size + fun setDispensedLockedItemCount(count: Int) { + dispensedLockedItemCount = count.coerceAtLeast(0) + notifyDataSetChanged() + } + } interface RecyclerViewItemChangeListenersP { fun onItemChanged() -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/SelectBatchAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/SelectBatchAdapter.kt index 42b8f0c03..0b29734a2 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/SelectBatchAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/SelectBatchAdapter.kt @@ -3,6 +3,7 @@ package org.piramalswasthya.cho.adapter import android.content.Context import android.text.Editable import android.text.TextWatcher +import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.widget.doAfterTextChanged @@ -42,7 +43,7 @@ class SelectBatchAdapter( fun bind( item: PrescriptionBatchDTO, ) { - binding.tvExpiryDate.text = DateTimeUtil.formatDateStr(item.expiryDate) + binding.tvExpiryDate.text = item.expiryDate binding.tvQuantityInHand.text = "In Hand Qty: ${item.qty.toString()}" binding.tvBatchNo.text = "Batch No: ${item.batchNo.toString()}" binding.checkboxSelect.setOnCheckedChangeListener(null) diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/SubCategoryAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/SubCategoryAdapter.kt index ce8021a10..f83945fc9 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/SubCategoryAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/SubCategoryAdapter.kt @@ -1,12 +1,29 @@ package org.piramalswasthya.cho.adapter import android.content.Context -import android.view.View -import android.view.ViewGroup import android.widget.ArrayAdapter -import android.widget.TextView -import org.piramalswasthya.cho.model.SubVisitCategory +import android.widget.Filter -class SubCategoryAdapter(context: Context, resource: Int,textViewResourceId:Int, subCats: List) : - ArrayAdapter(context, resource,textViewResourceId, subCats) { +class SubCategoryAdapter( + context: Context, + resource: Int, + textViewResourceId: Int, + private val subCats: List +) : ArrayAdapter(context, resource, textViewResourceId, subCats) { + + override fun getFilter(): Filter = object : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + // Sub Category / Reason for Visit are pickers, not search fields. + // Always return the full list so stale text (e.g. after navigating back) + // doesn't narrow the dropdown to only the already-selected item. + return FilterResults().apply { + values = subCats + count = subCats.size + } + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + notifyDataSetChanged() + } + } } diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/ViewPagerAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/ViewPagerAdapter.kt index 5f691cf6b..055265361 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/ViewPagerAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/ViewPagerAdapter.kt @@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter import org.piramalswasthya.cho.ui.home.HomeFragment +import org.piramalswasthya.cho.ui.home.RMNCHAFragment import org.piramalswasthya.cho.ui.home_activity.DashboardFragment class ViewPagerAdapter(supportFragmentManager: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(supportFragmentManager, lifecycle) { @@ -14,15 +15,15 @@ class ViewPagerAdapter(supportFragmentManager: FragmentManager, lifecycle: Lifec private val mFragmentTitleList = ArrayList() override fun getItemCount(): Int { - return 2 + return 3 } override fun createFragment(position: Int): Fragment { - return if(position==0){ - HomeFragment() - } - else{ - DashboardFragment() - } + return when(position) { + 0 -> HomeFragment() + 1 -> DashboardFragment() + 2 -> RMNCHAFragment() + else -> HomeFragment() + } } } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/adapter/VillageDropdownAdapter.kt b/app/src/main/java/org/piramalswasthya/cho/adapter/VillageDropdownAdapter.kt index 1f213683f..a69a07407 100644 --- a/app/src/main/java/org/piramalswasthya/cho/adapter/VillageDropdownAdapter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/adapter/VillageDropdownAdapter.kt @@ -22,13 +22,15 @@ class VillageDropdownAdapter( override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = super.getView(position, convertView, parent) - (view as? TextView)?.text = dataList[position].villageName + val item = getItem(position) + (view as? TextView)?.text = item?.villageName.orEmpty() return view } override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { val view = super.getDropDownView(position, convertView, parent) - (view as? TextView)?.text = dataList[position].villageName + val item = getItem(position) + (view as? TextView)?.text = item?.villageName.orEmpty() return view } diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/ChildBeneficiaryRegistrationDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/ChildBeneficiaryRegistrationDataset.kt new file mode 100644 index 000000000..fad88b319 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/ChildBeneficiaryRegistrationDataset.kt @@ -0,0 +1,152 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.DeliveryOutcomeCache +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InfantRegCache +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.Patient +import org.piramalswasthya.cho.network.getDateFromLong + +class ChildBeneficiaryRegistrationDataset( + context: Context, + currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + private val childName = FormElement( + id = 1, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.name_of_child), + required = true, + hasDependants = false + ) + + private val dob = FormElement( + id = 2, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.date_of_birth), + required = true, + hasDependants = false, + isEnabled = false, + max = System.currentTimeMillis() + ) + + private val sex = FormElement( + id = 3, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_sex), + entries = resources.getStringArray(R.array.no_sex_array), + required = true, + hasDependants = false, + isEnabled = false + ) + + private val motherName = FormElement( + id = 4, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.mother_name), + required = false, + hasDependants = false + ) + + private val fatherName = FormElement( + id = 5, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.father_s_name), + required = false, + hasDependants = false + ) + + private val mobileNumber = FormElement( + id = 6, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.tv_mob_no_ph), + required = false, + hasDependants = false + ) + + private val birthWeight = FormElement( + id = 7, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.no_birth_weight), + required = false, + hasDependants = false + ) + + suspend fun setUpPage( + motherPatient: Patient, + deliveryOutcomeCache: DeliveryOutcomeCache, + infantRegCache: InfantRegCache, + existingChild: Patient? + ) { + val fullMotherName = + "${motherPatient.firstName.orEmpty()} ${motherPatient.lastName.orEmpty()}".trim() + val childDisplayName = existingChild?.let { child -> + listOfNotNull(child.firstName, child.lastName).joinToString(" ").trim() + }.takeUnless { it.isNullOrBlank() } + ?: infantRegCache.babyName + ?: "baby of ${motherPatient.firstName.orEmpty()}".trim() + + childName.value = childDisplayName + motherName.value = fullMotherName + fatherName.value = motherPatient.spouseName ?: motherPatient.parentName ?: "" + mobileNumber.value = motherPatient.phoneNo ?: "" + birthWeight.value = infantRegCache.weight?.let { (it * 1000).toInt().toString() } + + val childDobMillis = existingChild?.dob?.time ?: deliveryOutcomeCache.dateOfDelivery + dob.value = childDobMillis?.let { getDateFromLong(it) } + dob.min = childDobMillis + + val sexId = existingChild?.genderID ?: infantRegCache.genderID + sex.value = sexId?.let { id -> + sex.entries?.getOrNull(id - 1) + } + + setUpPage( + listOf( + childName, + dob, + sex, + motherName, + fatherName, + mobileNumber, + birthWeight + ) + ) + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + childName.id -> validateAllAlphabetsSpecialOnEditText(childName) + else -> -1 + } + } + + fun getChildFirstName(): String { + val name = childName.value?.trim().orEmpty() + return name.substringBefore(" ").ifBlank { name } + } + + fun getChildLastName(): String? { + val name = childName.value?.trim().orEmpty() + val last = name.substringAfter(" ", "").trim() + return last.ifBlank { null } + } + + fun getChildFullName(): String = childName.value?.trim().orEmpty() + + fun getDobMillis(): Long? = Dataset.getLongFromDate(dob.value).takeIf { it != 0L } + + fun getGenderID(): Int? { + val selected = sex.value ?: return null + return sex.entries?.indexOf(selected)?.plus(1) + } + + fun getFatherName(): String? = fatherName.value?.trim().takeUnless { it.isNullOrEmpty() } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + // This dataset maps directly to Patient entity fields via getters. + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/ChildRegistrationDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/ChildRegistrationDataset.kt new file mode 100644 index 000000000..633264da6 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/ChildRegistrationDataset.kt @@ -0,0 +1,1003 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import android.widget.LinearLayout +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.InfantRegCache +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.DeliveryOutcomeCache +import org.piramalswasthya.cho.model.Patient +import org.piramalswasthya.cho.network.getDateFromLong +import org.piramalswasthya.cho.network.getLongFromDate + +class ChildRegistrationDataset( + context: Context, currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + companion object { + private const val WEIGHT_MIN = 500 + private const val WEIGHT_MAX = 6000 + private const val OUTCOME_LIVE_BIRTH_INDEX = 0 + private const val CURRENT_STATUS_DIED_INDEX = 3 + } + + // ─── Existing FLW fields ──────────────────────────────────────────── + + private var babyName = FormElement( + id = 1, + inputType = InputType.TEXT_VIEW, + title = "Name of Baby", + required = false, + hasDependants = false + ) + + private var infantTerm = FormElement( + id = 2, + inputType = InputType.TEXT_VIEW, + title = "Infant Term", + entries = arrayOf("Full Term", "Pre Term"), + required = false, + hasDependants = false + ) + + private var corticosteroidGiven = FormElement( + id = 3, + inputType = InputType.RADIO, + title = "Was Corticosteroid Inj. given?", + entries = arrayOf("Yes", "No", "Don't Know"), + required = false, + hasDependants = false + ) + + // Q2: Outcome at Birth (BRD) + private val outcomeAtBirth = FormElement( + id = 4, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_outcome_at_birth), + arrayId = R.array.no_outcome_at_birth_array, + entries = resources.getStringArray(R.array.no_outcome_at_birth_array), + required = true, + hasDependants = true, + orientation = LinearLayout.VERTICAL + ) + + // Q3: Sex + private var gender = FormElement( + id = 5, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_sex), + arrayId = R.array.no_sex_array, + entries = resources.getStringArray(R.array.no_sex_array), + required = true, + hasDependants = false, + hasAlertError = true, + orientation = LinearLayout.VERTICAL, + ) + + // Q4: Cried immediately after birth? + private var babyCriedAtBirth = FormElement( + id = 6, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_cried_immediately), + arrayId = R.array.no_cried_immediately_array, + entries = resources.getStringArray(R.array.no_cried_immediately_array), + required = true, + hasDependants = true, + orientation = LinearLayout.VERTICAL + ) + + // Q5: Type of resuscitation (multi-select, shown if cried after resuscitation) + private val typeOfResuscitation = FormElement( + id = 7, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_type_of_resuscitation), + arrayId = R.array.no_type_of_resuscitation_array, + entries = resources.getStringArray(R.array.no_type_of_resuscitation_array), + required = false, + hasDependants = false + ) + + private var resuscitation = FormElement( + id = 8, + inputType = InputType.RADIO, + title = "If No, Resuscitation Done", + entries = arrayOf("Yes", "No"), + required = true, + hasDependants = false + ) + + private var referred = FormElement( + id = 9, + inputType = InputType.RADIO, + title = "Referred to higher facility for further management", + entries = arrayOf("Yes", "No", "NA"), + required = false, + hasDependants = false + ) + + // Q6: Birth Weight (grams) + private var weight = FormElement( + id = 10, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_birth_weight), + required = true, + hasDependants = false, + etMaxLength = 4, + min = WEIGHT_MIN.toLong(), + max = WEIGHT_MAX.toLong(), + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + ) + + // Q7: Any congenital anomaly detected? + private var hadBirthDefect = FormElement( + id = 11, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_congenital_anomaly_detected), + arrayId = R.array.no_congenital_anomaly_array, + entries = resources.getStringArray(R.array.no_congenital_anomaly_array), + required = true, + hasDependants = true, + orientation = LinearLayout.VERTICAL + ) + + // Q8: Type of congenital anomaly (multi-select) + private var birthDefect = FormElement( + id = 12, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_type_of_congenital_anomaly), + arrayId = R.array.no_type_of_congenital_anomaly_array, + entries = resources.getStringArray(R.array.no_type_of_congenital_anomaly_array), + required = false, + hasDependants = true + ) + + // Q9: Other congenital anomaly + private var otherDefect = FormElement( + id = 13, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_other_congenital_anomaly), + required = false, + hasDependants = false, + etMaxLength = 300 + ) + + // Q10: Newborn Complications (multi-select) + private val newbornComplications = FormElement( + id = 14, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_newborn_complications), + arrayId = R.array.no_newborn_complications_array, + entries = resources.getStringArray(R.array.no_newborn_complications_array), + required = false, + hasDependants = false + ) + + // Q11: Current Status of Baby + private val currentStatusOfBaby = FormElement( + id = 15, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_current_status_of_baby), + arrayId = R.array.no_current_status_array, + entries = resources.getStringArray(R.array.no_current_status_array), + required = true, + hasDependants = true, + orientation = LinearLayout.VERTICAL + ) + + // Q12: Cause of Death (multi-select, shown if Died) + private val causeOfDeath = FormElement( + id = 16, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_cause_of_death), + arrayId = R.array.no_cause_of_death_array, + entries = resources.getStringArray(R.array.no_cause_of_death_array), + required = false, + hasDependants = true + ) + + // Q13: Other cause of death + private val otherCauseOfDeath = FormElement( + id = 17, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_other_cause_of_death), + required = false, + hasDependants = false, + etMaxLength = 300 + ) + + private var breastFeedingStarted = FormElement( + id = 18, + inputType = InputType.RADIO, + title = "Breast feeding started within 1 hour of birth", + entries = arrayOf("Yes", "No"), + required = true, + hasDependants = false, + ) + + // Q14: Birth dose vaccines given (multi-select) + private val birthDoseVaccinesGiven = FormElement( + id = 19, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_birth_dose_vaccines_given), + arrayId = R.array.no_birth_dose_vaccines_array, + entries = resources.getStringArray(R.array.no_birth_dose_vaccines_array), + required = true, + hasDependants = true + ) + + // Q15: Reason for not giving birth dose vaccines + private val reasonForNoVaccines = FormElement( + id = 20, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_reason_for_no_vaccines), + required = false, + hasDependants = false, + etMaxLength = 200 + ) + + private val opv0Dose = FormElement( + id = 21, + inputType = InputType.DATE_PICKER, + title = "OPV0 Dose", + required = false, + max = System.currentTimeMillis(), + hasDependants = false + ) + + private val bcgDose = FormElement( + id = 22, + inputType = InputType.DATE_PICKER, + title = "BCG Dose", + required = false, + max = System.currentTimeMillis(), + hasDependants = false + ) + + private val hepBDose = FormElement( + id = 23, + inputType = InputType.DATE_PICKER, + title = "HEP B-0 Dose", + required = false, + max = System.currentTimeMillis(), + hasDependants = false + ) + + // Q16: Vitamin K injection given? + private val vitaminKInjectionGiven = FormElement( + id = 24, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_vitamin_k_injection_given), + entries = arrayOf(resources.getString(R.string.yes), resources.getString(R.string.no)), + required = true, + hasDependants = true + ) + + // Q17: Reason for no Vitamin K + private val reasonForNoVitaminK = FormElement( + id = 25, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_reason_for_no_vitamin_k), + required = false, + hasDependants = false, + etMaxLength = 200 + ) + + private val vitkDose = FormElement( + id = 26, + inputType = InputType.DATE_PICKER, + title = "VITK Dose", + required = false, + max = System.currentTimeMillis(), + hasDependants = false + ) + + // Q18: Birth Certificate issued? + private val birthCertificateIssued = FormElement( + id = 27, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_birth_certificate_issued), + arrayId = R.array.no_birth_certificate_array, + entries = resources.getStringArray(R.array.no_birth_certificate_array), + required = true, + hasDependants = false, + hasAlertError = true, + orientation = LinearLayout.VERTICAL + ) + + // ─── Helper: generate baby name from patient & delivery data ──────── + private fun generateBabyName( + ben: Patient?, + deliveryOutcomeCache: DeliveryOutcomeCache, + babyIndex: Int + ): String? { + if (ben == null) return null + return if (deliveryOutcomeCache.liveBirth == null || deliveryOutcomeCache.liveBirth == 1) + "baby of ${ben.firstName}" + else + "baby ${babyIndex + 1} of ${ben.firstName}" + } + + // ─── Helper: restore all saved field values from cache ───────────── + private fun restoreSavedValues(saved: InfantRegCache) { + infantTerm.value = saved.infantTerm + corticosteroidGiven.value = saved.corticosteroidGiven + outcomeAtBirth.value = when (saved.outcomeAtBirth) { + "Diedrelative during delivery" -> resources.getStringArray(R.array.no_outcome_at_birth_array).getOrNull(3) + else -> getLocalValueInArray(R.array.no_outcome_at_birth_array, saved.outcomeAtBirth) + } + + gender.value = saved.genderID?.let { + if (it > 0 && it <= (gender.entries?.size ?: 0)) gender.entries?.get(it - 1) else null + } + babyCriedAtBirth.value = when { + saved.babyCriedAtBirth == true -> resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(0) // Immediate cry + saved.resuscitation == true -> resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(1) // Cried after resuscitation + isStillbirthOrDiedAtBirth(outcomeAtBirth.value) -> resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(2) // Not applicable (Stillbirth) + else -> null + } + typeOfResuscitation.value = getLocalValuesInArray(R.array.no_type_of_resuscitation_array, saved.typeOfResuscitation) + resuscitation.value = saved.resuscitation?.let { if (it) "Yes" else "No" } + referred.value = saved.referred + hadBirthDefect.value = getLocalValueInArray(R.array.no_congenital_anomaly_array, saved.hadBirthDefect) + birthDefect.value = getLocalValuesInArray(R.array.no_type_of_congenital_anomaly_array, saved.birthDefect) + otherDefect.value = saved.otherDefect?.takeIf { it.isNotBlank() } + weight.value = saved.weight?.let { (it * 1000).toInt().toString() } // Convert kg to grams for display + breastFeedingStarted.value = saved.breastFeedingStarted?.let { if (it) "Yes" else "No" } + newbornComplications.value = getLocalValuesInArray(R.array.no_newborn_complications_array, saved.newbornComplications) + currentStatusOfBaby.value = getLocalValueInArray(R.array.no_current_status_array, saved.currentStatusOfBaby) + causeOfDeath.value = getLocalValuesInArray(R.array.no_cause_of_death_array, saved.causeOfDeath) + otherCauseOfDeath.value = saved.otherCauseOfDeath?.takeIf { it.isNotBlank() } + birthDoseVaccinesGiven.value = getLocalValuesInArray(R.array.no_birth_dose_vaccines_array, saved.birthDoseVaccinesGiven) + reasonForNoVaccines.value = saved.reasonForNoVaccines?.takeIf { it.isNotBlank() } + vitaminKInjectionGiven.value = when (saved.vitaminKInjectionGiven) { + true -> resources.getString(R.string.yes) + false -> resources.getString(R.string.no) + else -> null + } + reasonForNoVitaminK.value = saved.reasonForNoVitaminK?.takeIf { it.isNotBlank() } + birthCertificateIssued.value = getLocalValueInArray(R.array.no_birth_certificate_array, saved.birthCertificateIssued) + + opv0Dose.value = saved.opv0Dose?.let { getDateFromLong(it) } + bcgDose.value = saved.bcgDose?.let { getDateFromLong(it) } + hepBDose.value = saved.hepBDose?.let { getDateFromLong(it) } + vitkDose.value = saved.vitkDose?.let { getDateFromLong(it) } + } + + // ─── Helper: insert a field (and optional sub-field) after anchor ── + private fun insertAfter( + list: MutableList, + anchor: FormElement, + primary: FormElement, + secondary: FormElement? = null + ) { + val idx = list.indexOf(anchor) + if (idx < 0) return + if (!list.contains(primary)) { + list.add(idx + 1, primary) + } + if (secondary != null && !list.contains(secondary)) { + val primaryIndex = list.indexOf(primary).takeIf { it >= 0 } ?: idx + list.add(primaryIndex + 1, secondary) + } + } + + // ─── Helper: restore conditional (dependant) fields into the list ── + private fun restoreConditionalFields( + saved: InfantRegCache, + list: MutableList + ) { + val localizedHadBirthDefect = getLocalValueInArray(R.array.no_congenital_anomaly_array, saved.hadBirthDefect) + val localizedCurrentStatus = getLocalValueInArray(R.array.no_current_status_array, saved.currentStatusOfBaby) + val localizedCauseOfDeath = getLocalValuesInArray(R.array.no_cause_of_death_array, saved.causeOfDeath) + val localizedBirthDoseVaccines = getLocalValuesInArray(R.array.no_birth_dose_vaccines_array, saved.birthDoseVaccinesGiven) + + if (saved.babyCriedAtBirth == false || saved.resuscitation == true) { + val criedIdx = list.indexOf(babyCriedAtBirth) + if (criedIdx >= 0 && !saved.typeOfResuscitation.isNullOrBlank() && !list.contains(typeOfResuscitation)) { + list.add(criedIdx + 1, typeOfResuscitation) + } + } + val anomalyArray = resources.getStringArray(R.array.no_congenital_anomaly_array) + if (localizedHadBirthDefect == anomalyArray.getOrNull(0) || localizedHadBirthDefect == anomalyArray.getOrNull(2)) { + val other = if (hasSelectedOption(birthDefect.value, getOtherOption(birthDefect.entries))) otherDefect else null + insertAfter(list, hadBirthDefect, birthDefect, other) + } + if (localizedCurrentStatus == resources.getStringArray(R.array.no_current_status_array).getOrNull(3)) { + val other = if (hasSelectedOption(localizedCauseOfDeath, getOtherOption(causeOfDeath.entries))) otherCauseOfDeath else null + insertAfter(list, currentStatusOfBaby, causeOfDeath, other) + } + if (hasSelectedOption(localizedBirthDoseVaccines, resources.getStringArray(R.array.no_birth_dose_vaccines_array).getOrNull(3))) { + insertAfter(list, birthDoseVaccinesGiven, reasonForNoVaccines) + } + if (saved.vitaminKInjectionGiven == false) { + insertAfter(list, vitaminKInjectionGiven, reasonForNoVitaminK) + } + } + + // ─── Helper: infer infant term from gestational age ──────────────── + private fun inferInfantTerm(deliveryOutcomeCache: DeliveryOutcomeCache) { + if (!infantTerm.value.isNullOrBlank()) return + deliveryOutcomeCache.gestationalAgeAtDelivery?.let { gaString -> + val weeks = gaString.substringBefore("w").trim().toIntOrNull() + if (weeks != null) { + infantTerm.value = if (weeks < 37) "Pre Term" else "Full Term" + } + } + } + + // ─── Helper: pre-populate defaults from delivery outcome ─────────── + private fun prepopulateDefaults(deliveryOutcomeCache: DeliveryOutcomeCache) { + inferInfantTerm(deliveryOutcomeCache) + + val deliveryDate = deliveryOutcomeCache.dateOfDelivery?.let { getDateFromLong(it) } + if (deliveryDate != null) { + if (opv0Dose.value == null) opv0Dose.value = deliveryDate + if (bcgDose.value == null) bcgDose.value = deliveryDate + if (hepBDose.value == null) hepBDose.value = deliveryDate + if (vitkDose.value == null) vitkDose.value = deliveryDate + } + } + + suspend fun setUpPage( + ben: Patient?, + deliveryOutcomeCache: DeliveryOutcomeCache, + babyIndex: Int, + saved: InfantRegCache? + ) { + val list = mutableListOf( + babyName, + infantTerm, + corticosteroidGiven, + outcomeAtBirth + ) + + deliveryOutcomeCache.dateOfDelivery?.let { + opv0Dose.min = it + bcgDose.min = it + hepBDose.min = it + vitkDose.min = it + } + + babyName.value = saved?.babyName ?: generateBabyName(ben, deliveryOutcomeCache, babyIndex) + + if (saved != null) { + restoreSavedValues(saved) + } + + //prepopulateDefaults(deliveryOutcomeCache) + + if (isStillbirthOrDiedAtBirth(outcomeAtBirth.value)) { + birthDoseVaccinesGiven.required = false + vitaminKInjectionGiven.required = false + list.add(newbornComplications) + } else { + list.addAll(getLiveBirthBaseFields()) + } + if (saved != null) { + restoreConditionalFields(saved, list) + } + refreshConditionalRequirements() + + setUpPage(list) + } + + // ─── Helper: toggle dependant fields based on a condition ────────── + private fun toggleDependant( + source: FormElement, + condition: Boolean, + showItems: List, + hideItems: List = emptyList() + ): Int { + return if (condition) { + triggerDependants(source = source, removeItems = hideItems, addItems = showItems) + } else { + triggerDependants(source = source, removeItems = showItems + hideItems, addItems = emptyList()) + } + } + + private fun hasSelectedOption(value: String?, option: String?): Boolean { + if (value.isNullOrBlank() || option.isNullOrBlank()) return false + return value.split(",").any { it.trim() == option } + } + + private fun getOtherOption(entries: Array?): String? { + return entries?.firstOrNull { it.contains("Other", ignoreCase = true) } + } + + private fun isLiveBirthOutcome(value: String?): Boolean { + val liveBirth = resources.getStringArray(R.array.no_outcome_at_birth_array) + .getOrNull(OUTCOME_LIVE_BIRTH_INDEX) + return value == liveBirth + } + + private fun isStillbirthOrDiedAtBirth(value: String?): Boolean { + if (value.isNullOrBlank()) return false + return !isLiveBirthOutcome(value) + } + + private fun getOutcomeDependentFields(): List { + return listOf( + gender, + babyCriedAtBirth, + typeOfResuscitation, + weight, + hadBirthDefect, + birthDefect, + otherDefect, + newbornComplications, + currentStatusOfBaby, + causeOfDeath, + otherCauseOfDeath, + breastFeedingStarted, + birthDoseVaccinesGiven, + reasonForNoVaccines, + opv0Dose, + bcgDose, + hepBDose, + vitaminKInjectionGiven, + reasonForNoVitaminK, + vitkDose, + birthCertificateIssued + ) + } + + private fun getLiveBirthBaseFields(): List { + return mutableListOf().apply { + add(gender) + add(babyCriedAtBirth) + if (babyCriedAtBirth.value == resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(1)) { + add(typeOfResuscitation) + } + add(weight) + add(hadBirthDefect) + val anomalyArray = resources.getStringArray(R.array.no_congenital_anomaly_array) + val hasAnomaly = hadBirthDefect.value == anomalyArray.getOrNull(0) || + hadBirthDefect.value == anomalyArray.getOrNull(2) + if (hasAnomaly) { + add(birthDefect) + if (hasSelectedOption(birthDefect.value, getOtherOption(birthDefect.entries))) { + add(otherDefect) + } + } + add(newbornComplications) + add(currentStatusOfBaby) + if (currentStatusOfBaby.value == resources.getStringArray(R.array.no_current_status_array) + .getOrNull(CURRENT_STATUS_DIED_INDEX) + ) { + add(causeOfDeath) + if (hasSelectedOption(causeOfDeath.value, getOtherOption(causeOfDeath.entries))) { + add(otherCauseOfDeath) + } + } else { + add(breastFeedingStarted) + add(birthDoseVaccinesGiven) + if (hasSelectedOption( + birthDoseVaccinesGiven.value, + resources.getStringArray(R.array.no_birth_dose_vaccines_array).getOrNull(3) + ) + ) { + add(reasonForNoVaccines) + } + add(opv0Dose) + add(bcgDose) + add(hepBDose) + add(vitaminKInjectionGiven) + if (vitaminKInjectionGiven.value == resources.getString(R.string.no)) { + add(reasonForNoVitaminK) + } + add(vitkDose) + add(birthCertificateIssued) + } + } + } + + private fun resetConditionalRequirements() { + birthDefect.required = false + otherDefect.required = false + causeOfDeath.required = false + otherCauseOfDeath.required = false + reasonForNoVaccines.required = false + reasonForNoVitaminK.required = false + birthDoseVaccinesGiven.required = true + vitaminKInjectionGiven.required = true + } + + private fun refreshConditionalRequirements() { + resetConditionalRequirements() + + if (!isLiveBirthOutcome(outcomeAtBirth.value)) { + birthDoseVaccinesGiven.required = false + vitaminKInjectionGiven.required = false + return + } + + val anomalyArray = resources.getStringArray(R.array.no_congenital_anomaly_array) + if (hadBirthDefect.value == anomalyArray.getOrNull(0) || hadBirthDefect.value == anomalyArray.getOrNull(2)) { + birthDefect.required = true + } + if (hasSelectedOption(birthDefect.value, getOtherOption(birthDefect.entries))) { + otherDefect.required = true + } + + if (currentStatusOfBaby.value == resources.getStringArray(R.array.no_current_status_array) + .getOrNull(CURRENT_STATUS_DIED_INDEX) + ) { + causeOfDeath.required = true + if (hasSelectedOption(causeOfDeath.value, getOtherOption(causeOfDeath.entries))) { + otherCauseOfDeath.required = true + } + birthDoseVaccinesGiven.required = false + vitaminKInjectionGiven.required = false + } else { + val noneVaccine = resources.getStringArray(R.array.no_birth_dose_vaccines_array).getOrNull(3) + if (hasSelectedOption(birthDoseVaccinesGiven.value, noneVaccine)) { + reasonForNoVaccines.required = true + } + if (vitaminKInjectionGiven.value == resources.getString(R.string.no)) { + reasonForNoVitaminK.required = true + } + } + } + + private suspend fun handleOutcomeAtBirthChange(selectedValue: String?): Int { + resetConditionalRequirements() + + val fieldsToAdd = if (isLiveBirthOutcome(selectedValue)) { + getLiveBirthBaseFields() + } else { + birthDoseVaccinesGiven.required = false + vitaminKInjectionGiven.required = false + listOf(newbornComplications) + } + + if (isStillbirthOrDiedAtBirth(selectedValue)) { + emitAlertErrorMessage(R.string.no_alert_stillbirth) + } + + val index = getIndexOfElement(outcomeAtBirth) + if (index == -1) return -1 + + return triggerDependants( + source = outcomeAtBirth, + removeItems = getOutcomeDependentFields(), + addItems = fieldsToAdd.distinct(), + position = index + 1 + ) + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + outcomeAtBirth.id -> { + val selected = outcomeAtBirth.entries?.getOrNull(index) + outcomeAtBirth.value = selected + val updateIndex = handleOutcomeAtBirthChange(selected) + refreshConditionalRequirements() + updateIndex + } + + gender.id -> { + val selected = gender.entries?.getOrNull(index) + gender.value = selected + if (selected == gender.entries?.getOrNull(2)) { + emitAlertErrorMessage(R.string.no_alert_ambiguous_sex) + } + -1 + } + + babyCriedAtBirth.id -> { + val selected = babyCriedAtBirth.entries?.getOrNull(index) + babyCriedAtBirth.value = selected + // "Cried after resuscitation" is index 1 — show typeOfResuscitation + val updateIndex = toggleDependant( + source = babyCriedAtBirth, + condition = selected == resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(1), + showItems = listOf(typeOfResuscitation) + ) + refreshConditionalRequirements() + updateIndex + } + + hadBirthDefect.id -> { + val selected = hadBirthDefect.entries?.getOrNull(index) + hadBirthDefect.value = selected + // "Yes" or "Suspected" (index 0 or 2) → show birthDefect + val anomalyArray = resources.getStringArray(R.array.no_congenital_anomaly_array) + val updateIndex = toggleDependant( + source = hadBirthDefect, + condition = selected == anomalyArray.getOrNull(0) || selected == anomalyArray.getOrNull(2), + showItems = listOf(birthDefect), + hideItems = listOf(otherDefect) + ) + refreshConditionalRequirements() + updateIndex + } + + birthDefect.id -> { + val updateIndex = toggleDependant( + source = birthDefect, + condition = hasSelectedOption(birthDefect.value, getOtherOption(birthDefect.entries)), + showItems = listOf(otherDefect) + ) + refreshConditionalRequirements() + updateIndex + } + + currentStatusOfBaby.id -> { + val selected = currentStatusOfBaby.entries?.getOrNull(index) + currentStatusOfBaby.value = selected + + // Admitted statuses require PNC counseling alert + val statusArray = resources.getStringArray(R.array.no_current_status_array) + if (selected == statusArray.getOrNull(1) || selected == statusArray.getOrNull(2)) { + emitAlertErrorMessage(R.string.no_alert_pnc_counseling) + } + + val updateIndex = if (selected == statusArray.getOrNull(CURRENT_STATUS_DIED_INDEX)) { + emitAlertErrorMessage(R.string.no_alert_neonatal_death) + triggerDependants( + source = currentStatusOfBaby, + removeItems = listOf( + otherCauseOfDeath, + breastFeedingStarted, + birthDoseVaccinesGiven, + reasonForNoVaccines, + opv0Dose, + bcgDose, + hepBDose, + vitaminKInjectionGiven, + reasonForNoVitaminK, + vitkDose, + birthCertificateIssued + ), + addItems = listOf(causeOfDeath), + position = getIndexOfElement(currentStatusOfBaby) + 1 + ) + } else { + triggerDependants( + source = currentStatusOfBaby, + removeItems = listOf(causeOfDeath, otherCauseOfDeath), + addItems = listOf( + breastFeedingStarted, + birthDoseVaccinesGiven, + opv0Dose, + bcgDose, + hepBDose, + vitaminKInjectionGiven, + vitkDose, + birthCertificateIssued + ), + position = getIndexOfElement(currentStatusOfBaby) + 1 + ) + } + refreshConditionalRequirements() + updateIndex + } + + causeOfDeath.id -> { + val updateIndex = toggleDependant( + source = causeOfDeath, + condition = hasSelectedOption(causeOfDeath.value, getOtherOption(causeOfDeath.entries)), + showItems = listOf(otherCauseOfDeath) + ) + refreshConditionalRequirements() + updateIndex + } + + birthDoseVaccinesGiven.id -> { + val realIndex = (if (index < 0) -index else index) - 1 + val localizedEntries = birthDoseVaccinesGiven.entries + // Locale-neutral "None" identification. + val noneIndex = englishResources + .getStringArray(birthDoseVaccinesGiven.arrayId) + .indexOf("None") + val noneLocalized = localizedEntries?.getOrNull(noneIndex) + val clickedOption = localizedEntries?.getOrNull(realIndex) + val isNoneOption = noneIndex >= 0 && realIndex == noneIndex + val isChecked = index > 0 + + if (clickedOption != null && isChecked) { + if (isNoneOption) { + birthDoseVaccinesGiven.value = clickedOption + } else { + val parts = (birthDoseVaccinesGiven.value ?: "") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() && it != noneLocalized } + birthDoseVaccinesGiven.value = + if (parts.isEmpty()) null else parts.joinToString(",") + } + forceRefreshId(birthDoseVaccinesGiven.id) + } + + val updateIndex = toggleDependant( + source = birthDoseVaccinesGiven, + condition = hasSelectedOption(birthDoseVaccinesGiven.value, noneLocalized), + showItems = listOf(reasonForNoVaccines) + ) + refreshConditionalRequirements() + updateIndex + } + + vitaminKInjectionGiven.id -> { + val selected = vitaminKInjectionGiven.entries?.getOrNull(index) + vitaminKInjectionGiven.value = selected + val updateIndex = toggleDependant( + source = vitaminKInjectionGiven, + condition = selected == resources.getString(R.string.no), + showItems = listOf(reasonForNoVitaminK) + ) + refreshConditionalRequirements() + updateIndex + } + + birthCertificateIssued.id -> { + val selected = birthCertificateIssued.entries?.getOrNull(index) + birthCertificateIssued.value = selected + if (selected == birthCertificateIssued.entries?.getOrNull(2)) { // "No (Not applied)" + emitAlertErrorMessage(R.string.no_alert_birth_certificate_legal) + } + -1 + } + + weight.id -> { + val validation = validateIntMinMax(weight) + if (weight.errorText == null) { + weight.value?.toIntOrNull()?.let { weightInGm -> + when { + weightInGm < 1000 -> emitAlertErrorMessage(R.string.no_alert_elbw) + weightInGm < 1500 -> emitAlertErrorMessage(R.string.no_alert_vlbw) + weightInGm < 2500 -> emitAlertErrorMessage(R.string.no_alert_lbw) + weightInGm >= 4000 -> emitAlertErrorMessage(R.string.no_alert_macrosomia) + } + } + } + validation + } + + newbornComplications.id -> { + val realIndex = (if (index < 0) -index else index) - 1 + val localizedEntries = newbornComplications.entries + // Identify "None" by its position in the English array so the + // logic works in every locale (Hindi, Assamese, etc.). + val noneIndex = englishResources + .getStringArray(newbornComplications.arrayId) + .indexOf("None") + val noneLocalized = localizedEntries?.getOrNull(noneIndex) + val clickedOption = localizedEntries?.getOrNull(realIndex) + val isNoneOption = noneIndex >= 0 && realIndex == noneIndex + val isChecked = index > 0 + + if (clickedOption != null && isChecked) { + if (isNoneOption) { + // "None" just checked → clear every other selection. + newbornComplications.value = clickedOption + } else { + // A real complication was checked → drop "None" if it was set. + val parts = (newbornComplications.value ?: "") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() && it != noneLocalized } + newbornComplications.value = + if (parts.isEmpty()) null else parts.joinToString(",") + } + // DiffUtil cannot detect the in-place mutation (same FormElement + // reference in old & new lists), so signal the fragment to call + // notifyItemChanged on this row. + forceRefreshId(newbornComplications.id) + } + + if (!hasSelectedOption(newbornComplications.value, noneLocalized) && + !newbornComplications.value.isNullOrBlank() + ) { + emitAlertErrorMessage(R.string.no_alert_complications) + } + -1 + } + + otherDefect.id -> { + validateAllAlphabetsSpecialOnEditText(otherDefect) + } + + reasonForNoVaccines.id -> { + validateAllAlphabetsSpecialOnEditText(reasonForNoVaccines) + } + + reasonForNoVitaminK.id -> { + validateAllAlphabetsSpecialOnEditText(reasonForNoVitaminK) + } + + otherCauseOfDeath.id -> { + validateAllAlphabetsSpecialOnEditText(otherCauseOfDeath) + } + + else -> -1 + } + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as InfantRegCache).let { form -> + form.babyName = babyName.value + form.infantTerm = infantTerm.value + form.corticosteroidGiven = corticosteroidGiven.value + form.outcomeAtBirth = getEnglishValueInArray(R.array.no_outcome_at_birth_array, outcomeAtBirth.value) + + form.genderID = gender.value?.let { value -> + gender.entries?.indexOf(value)?.plus(1) + } + + // Map cried immediately to existing boolean field + val criedValue = babyCriedAtBirth.value + val immediateCry = resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(0) + val criedAfterResuscitation = resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(1) + form.babyCriedAtBirth = when (criedValue) { + immediateCry -> true + criedAfterResuscitation -> false + else -> null + } + form.resuscitation = when (criedValue) { + immediateCry -> false + criedAfterResuscitation -> true + else -> null + } + form.typeOfResuscitation = if (criedValue == criedAfterResuscitation) { + getEnglishValuesInArray(R.array.no_type_of_resuscitation_array, typeOfResuscitation.value) + } else { + null + } + + form.referred = referred.value + form.hadBirthDefect = getEnglishValueInArray(R.array.no_congenital_anomaly_array, hadBirthDefect.value) + form.birthDefect = getEnglishValuesInArray(R.array.no_type_of_congenital_anomaly_array, birthDefect.value) + form.otherDefect = otherDefect.value + // Convert grams to kg for DB storage (existing field is in kg) + form.weight = weight.value?.toDoubleOrNull()?.let { it / 1000.0 } + form.breastFeedingStarted = breastFeedingStarted.value == "Yes" + + form.newbornComplications = getEnglishValuesInArray(R.array.no_newborn_complications_array, newbornComplications.value) + form.currentStatusOfBaby = getEnglishValueInArray(R.array.no_current_status_array, currentStatusOfBaby.value) + form.causeOfDeath = getEnglishValuesInArray(R.array.no_cause_of_death_array, causeOfDeath.value) + form.otherCauseOfDeath = otherCauseOfDeath.value + form.birthDoseVaccinesGiven = getEnglishValuesInArray(R.array.no_birth_dose_vaccines_array, birthDoseVaccinesGiven.value) + form.reasonForNoVaccines = reasonForNoVaccines.value + + form.vitaminKInjectionGiven = when (vitaminKInjectionGiven.value) { + resources.getString(R.string.yes) -> true + resources.getString(R.string.no) -> false + else -> null + } + form.reasonForNoVitaminK = reasonForNoVitaminK.value + form.birthCertificateIssued = getEnglishValueInArray(R.array.no_birth_certificate_array, birthCertificateIssued.value) + + form.opv0Dose = getLongFromDate(opv0Dose.value) + form.bcgDose = getLongFromDate(bcgDose.value) + form.hepBDose = getLongFromDate(hepBDose.value) + form.vitkDose = getLongFromDate(vitkDose.value) + + if (isStillbirthOrDiedAtBirth(form.outcomeAtBirth)) { + form.genderID = null + form.babyCriedAtBirth = null + form.resuscitation = null + form.typeOfResuscitation = null + form.weight = null + form.hadBirthDefect = null + form.birthDefect = null + form.otherDefect = null + form.currentStatusOfBaby = null + form.causeOfDeath = null + form.otherCauseOfDeath = null + form.breastFeedingStarted = null + form.birthDoseVaccinesGiven = null + form.reasonForNoVaccines = null + form.opv0Dose = null + form.bcgDose = null + form.hepBDose = null + form.vitaminKInjectionGiven = null + form.reasonForNoVitaminK = null + form.vitkDose = null + form.birthCertificateIssued = null + } + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/Dataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/Dataset.kt index 6385f2fd8..07cd26f52 100644 --- a/app/src/main/java/org/piramalswasthya/cho/configuration/Dataset.kt +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/Dataset.kt @@ -4,7 +4,9 @@ import android.content.Context import android.content.res.Resources import android.util.Range import androidx.annotation.StringRes +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import org.piramalswasthya.cho.R import org.piramalswasthya.cho.helpers.Languages @@ -40,21 +42,29 @@ abstract class Dataset(context: Context, currentLanguage: Languages) { * Helper function to get resource instance chosen language. */ - protected companion object { + companion object { + const val DATE_FORMAT_DD_MM_YYYY = "dd/MM/yyyy" + fun getLongFromDate(dateString: String?): Long { val f = SimpleDateFormat("dd-MM-yyyy", Locale.ENGLISH) val date = dateString?.let { f.parse(it) } return date?.time ?: 0L } + fun getLongFromDate(dateString: String?, format: String): Long { + val f = SimpleDateFormat(format, Locale.ENGLISH) + val date = dateString?.let { f.parse(it) } + return date?.time ?: 0L + } + fun getFinancialYear(dateString: String?): String? { val f = SimpleDateFormat("dd-MM-yyyy", Locale.ENGLISH) val date = dateString?.let { f.parse(it) } return date?.let { if (it.month >= 3) { - "" + (it.year + 1900) + " - " + (it.year + 1902) + "" + (it.year + 1900) + " - " + (it.year + 1901) } else { - "" + (it.year + 1899) + " - " + (it.year + 1901) + "" + (it.year + 1899) + " - " + (it.year + 1900) } } } @@ -75,8 +85,14 @@ abstract class Dataset(context: Context, currentLanguage: Languages) { cal.timeInMillis = dateLong val f = SimpleDateFormat("dd-MM-yyyy", Locale.ENGLISH) return f.format(cal.time) + } - + fun getDateFromLong(dateLong: Long, format: String): String? { + if (dateLong == 0L) return null + val cal = Calendar.getInstance() + cal.timeInMillis = dateLong + val f = SimpleDateFormat(format, Locale.ENGLISH) + return f.format(cal.time) } fun getMinDateOfReg(): Long { @@ -98,6 +114,18 @@ abstract class Dataset(context: Context, currentLanguage: Languages) { private val _alertErrorMessageFlow = MutableStateFlow(null) val alertErrorMessageFlow = _alertErrorMessageFlow.asStateFlow() + /** Signals that a specific FormElement's row needs the adapter to call + * notifyItemChanged. Used when we mutate a FormElement's `value` in place + * (e.g. "None" mutual exclusion) — DiffUtil cannot detect it because the + * list still holds the same reference, so the fragment must force a + * rebind explicitly. */ + private val _forceRefreshIdFlow = MutableSharedFlow(extraBufferCapacity = 8) + val forceRefreshIdFlow = _forceRefreshIdFlow.asSharedFlow() + + protected fun forceRefreshId(id: Int) { + _forceRefreshIdFlow.tryEmit(id) + } + suspend fun resetErrorMessageFlow() { _alertErrorMessageFlow.emit(null) } @@ -119,23 +147,37 @@ abstract class Dataset(context: Context, currentLanguage: Languages) { abstract fun mapValues(cacheModel: FormDataModel, pageNumber: Int = 0) protected fun getIndexOfElement(element: FormElement) = list.indexOf(element) + protected fun getFormList(): List = list suspend fun updateList(formId: Int, index: Int) { - list.find { it.id == formId }?.let { + val formElement = list.find { it.id == formId } + val previousErrorText = formElement?.errorText + formElement?.let { if (it.inputType == InputType.DROPDOWN) { it.errorText = null } } val updateIndex = handleListOnValueChanged(formId, index) - if (updateIndex != -1) { + val currentErrorText = formElement?.errorText + val errorStateChanged = previousErrorText != currentErrorText + + // Emit list if: + // 1. updateIndex != -1 (list structure changed - fields added/removed), OR + // 2. Error state changed (for validation feedback) + // NOTE: We don't emit when only value changes (not error) to prevent unnecessary rebinds + // that could reset EditText fields while user is typing + if (updateIndex != -1 || errorStateChanged) { val newList = list.toMutableList() -// if (updateUIForCurrentElement) { -// Timber.d("Updating UI element ...") -// newList[updateIndex] = list[updateIndex].cloneForm() -// updateUIForCurrentElement = false -// } - Timber.d("Emitting ${newList}}") -// _listFlow.emit(emptyList()) + Timber.d("Emitting list (updateIndex=$updateIndex, errorChanged=$errorStateChanged)") _listFlow.emit(newList) + // The list emission alone is not enough when only errorText was + // mutated in place: DiffUtil's areContentsTheSame compares the same + // FormElement reference on both sides, so the change is invisible + // and the row stays visually stale. Force a rebind on this row. + if (errorStateChanged && updateIndex == -1 && formElement?.inputType != InputType.EDIT_TEXT) { + forceRefreshId(formId) + } + } else { + Timber.d("Skipping list emission (only value changed, no structural or error changes)") } } @@ -733,12 +775,14 @@ abstract class Dataset(context: Context, currentLanguage: Languages) { else { val sys = matchResult.groupValues[1].toInt() val dia = matchResult.groupValues[2].toInt() - bp.errorText = if (sys < minSys) "Systole should not be less than $minSys" - else if (sys > maxSys) "Systole should not be greater than $maxSys" - else if (dia < minDia) "Diastole should not be less then $minDia" - else if (dia > maxDia) "Diastole should not be greater than $maxDia" - else if (dia > sys) "Diastole cannot be greater than systole" - else null + bp.errorText = when { + sys < minSys -> "Systolic should not be less than $minSys" + sys > maxSys -> "Systolic should not be greater than $maxSys" + dia < minDia -> "Diastolic should not be less than $minDia" + dia > maxDia -> "Diastolic should not be greater than $maxDia" + dia > sys -> "Diastolic cannot be greater than systolic" + else -> null + } } return -1 @@ -779,18 +823,68 @@ abstract class Dataset(context: Context, currentLanguage: Languages) { fun getLocalValueInArray(arrayId: Int, entry: String?): String? { entry?.let { - return resources.getStringArray(arrayId)[englishResources.getStringArray(arrayId) - .indexOf(it)] + val englishArray = englishResources.getStringArray(arrayId) + val index = englishArray.indexOf(it) + if (index != -1) { + return resources.getStringArray(arrayId)[index] + } + // Value may already be in the local language — check the local array + val localArray = resources.getStringArray(arrayId) + if (localArray.contains(it)) { + return it + } + return it // Fallback: return the entry as-is to avoid crash } return null } fun getEnglishValueInArray(arrayId: Int, entry: String?): String? { entry?.let { - return englishResources.getStringArray(arrayId)[resources.getStringArray(arrayId) - .indexOf(it)] + val localArray = resources.getStringArray(arrayId) + val index = localArray.indexOf(it) + if (index != -1) { + return englishResources.getStringArray(arrayId)[index] + } + // Value may already be in English — check the English array + val englishArray = englishResources.getStringArray(arrayId) + if (englishArray.contains(it)) { + return it + } + return it // Fallback: return the entry as-is to avoid crash } return null } -} \ No newline at end of file + /** + * Multi-select variant of [getLocalValueInArray]. Splits a comma-separated + * stored value (typically what FormElement uses for CHECKBOXES) and re-localizes + * each item, returning a comma-joined string in the current locale. + */ + fun getLocalValuesInArray(arrayId: Int, entry: String?): String? { + if (entry.isNullOrBlank()) return null + return entry.split(",") + .mapNotNull { raw -> + getLocalValueInArray(arrayId, raw.trim())?.takeIf { it.isNotBlank() } + } + .distinct() + .joinToString(",") + .takeIf { it.isNotBlank() } + } + + /** + * Multi-select variant of [getEnglishValueInArray]. Splits a comma-separated + * displayed value and converts each item to its English canonical form so the + * persisted DB value is locale-neutral. + */ + fun getEnglishValuesInArray(arrayId: Int, entry: String?): String? { + if (entry.isNullOrBlank()) return null + return entry.split(",") + .mapNotNull { raw -> + getEnglishValueInArray(arrayId, raw.trim())?.takeIf { it.isNotBlank() } + } + .distinct() + .joinToString(",") + .takeIf { it.isNotBlank() } + } + +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/DeliveryOutcomeDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/DeliveryOutcomeDataset.kt new file mode 100644 index 000000000..ad7dd3d2b --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/DeliveryOutcomeDataset.kt @@ -0,0 +1,957 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import android.widget.LinearLayout +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.helpers.getWeeksOfPregnancy +import org.piramalswasthya.cho.model.DeliveryOutcomeCache +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.PregnantWomanAncCache +import org.piramalswasthya.cho.model.PregnantWomanRegistrationCache +import java.util.Calendar +import java.util.concurrent.TimeUnit + + +class DeliveryOutcomeDataset( + context: Context, currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + companion object { + private const val WEEKS_IN_PREGNANCY = 40 + private const val DAYS_IN_WEEK = 7 + private const val EDD_TOLERANCE_WEEKS_BEFORE = 4 + private const val EDD_TOLERANCE_WEEKS_AFTER = 2 + private const val POST_TERM_THRESHOLD_DAYS = 14 + private const val PRETERM_THRESHOLD_WEEKS = 37 + private const val EXTREMELY_PRETERM_WEEKS = 28 + private const val VERY_PRETERM_WEEKS = 32 + private const val POST_TERM_WEEKS = 42 + + // Indices in do_place_of_delivery_array + private const val PLACE_PRIVATE_HOSPITAL_INDEX = 6 + private const val PLACE_HOME_DELIVERY_INDEX = 7 + private const val PLACE_ON_THE_WAY_INDEX = 8 + + // Indices in do_mode_of_delivery_array + private const val MODE_ASSISTED_INDEX = 1 + private const val MODE_LSCS_INDEX = 2 + private const val MODE_EMERGENCY_LSCS_INDEX = 3 + + // Indices in do_delivery_conducted_by_array + private const val CONDUCTED_BY_DAI_TBA_INDEX = 4 + private const val CONDUCTED_BY_FAMILY_MEMBER_INDEX = 5 + private const val CONDUCTED_BY_SELF_INDEX = 6 + } + + // Woman Details Section (Display-only, auto-populated) + private val womanName = FormElement( + id = 100, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.do_woman_name), + required = false + ) + + private val womanAge = FormElement( + id = 101, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.do_woman_age), + required = false + ) + + private val caseId = FormElement( + id = 102, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.do_case_id), + required = false + ) + + // Date of Delivery (dd/MM/yyyy per JIRA MHWC-199) + private val dateOfDelivery = FormElement( + id = 1, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.do_delivery_date), + arrayId = -1, + required = true, + hasDependants = true, + max = System.currentTimeMillis(), // Cannot be future date + dateFormat = DATE_FORMAT_DD_MM_YYYY + ) + + // Time of Delivery (12-hour format) + private val timeOfDelivery = FormElement( + id = 2, + inputType = InputType.TIME_PICKER, + title = resources.getString(R.string.do_delivery_time), + arrayId = -1, + required = false, + hasDependants = true + ) + + // Gestational Age at Delivery (Auto-calculated, display-only) + private val gestationalAgeAtDelivery = FormElement( + id = 3, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.do_gestational_age_at_delivery), + required = false + ) + + // Place of Delivery + private val placeOfDelivery = FormElement( + id = 4, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.do_place_of_delivery), + entries = resources.getStringArray(R.array.do_place_of_delivery_array), + required = true, + hasDependants = true + ) + + // Private Hospital Name (Conditional) + private val privateHospitalName = FormElement( + id = 5, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.do_private_hospital_name), + required = false, + etMaxLength = 100, + etInputType = android.text.InputType.TYPE_CLASS_TEXT, + hasDependants = false + ) + + // Delivery Conducted By + private val deliveryConductedBy = FormElement( + id = 6, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.do_delivery_conducted_by), + entries = resources.getStringArray(R.array.do_delivery_conducted_by_array), + required = true, + hasDependants = true + ) + + // Mode of Delivery + private val modeOfDelivery = FormElement( + id = 7, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.do_mode_of_delivery), + entries = resources.getStringArray(R.array.do_mode_of_delivery_array), + required = true, + hasDependants = true + ) + + // Indication for LSCS/Assisted (Multi-select checkbox) + private val indicationForLSCS = FormElement( + id = 8, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.do_indication_for_lscs), + entries = resources.getStringArray(R.array.do_indication_for_lscs_array), + required = false, // Will be set to true conditionally + hasDependants = true + ) + + // Indication Other (Free text if "Other" selected) + private val indicationForLSCSOther = FormElement( + id = 9, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.do_indication_for_lscs_other), + required = false, + etMaxLength = 200, + etInputType = android.text.InputType.TYPE_CLASS_TEXT, + multiLine = true, + hasDependants = false + ) + + // Mother's Condition (post-delivery) + /** 4 options: use vertical layout per requirement (vertical when >2 options). */ + private val motherCondition = FormElement( + id = 10, + inputType = InputType.RADIO, + title = resources.getString(R.string.do_mother_condition), + entries = resources.getStringArray(R.array.do_mother_condition_array), + required = true, + hasDependants = true, + hasAlertError = true, + orientation = LinearLayout.VERTICAL + ) + + private val maternalComplications = FormElement( + id = 11, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.do_maternal_complications), + entries = resources.getStringArray(R.array.do_maternal_complications_array), + required = true, + hasDependants = true, + hasAlertError = true + ) + + /** 2 options: use horizontal layout per requirement (horizontal when ≤2 options). */ + private val motherCurrentlyAdmitted = FormElement( + id = 12, + inputType = InputType.RADIO, + title = resources.getString(R.string.do_mother_currently_admitted), + entries = resources.getStringArray(R.array.do_mother_admitted_array), + required = true, + hasDependants = true, + orientation = LinearLayout.HORIZONTAL + ) + + private val dateOfDischarge = FormElement( + id = 13, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.do_discharge_date), + arrayId = -1, + required = true, + hasDependants = false, + max = System.currentTimeMillis() + ) + + private val timeOfDischarge = FormElement( + id = 14, + inputType = InputType.TIME_PICKER, + title = resources.getString(R.string.do_discharge_time), + arrayId = -1, + required = false, + hasDependants = false + ) + + private val deliveryOutcome = FormElement( + id = 15, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.do_delivery_outcome), + required = true, + hasDependants = false, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + etMaxLength = 1, + max = 6, + min = 0, + refreshSiblingsOnChange = true + ) + + private val liveBirth = FormElement( + id = 16, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.do_live_birth), + required = true, + hasDependants = false, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + etMaxLength = 1, + max = 6, + min = 0, + refreshSiblingsOnChange = true + ) + + private val stillBirth = FormElement( + id = 17, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.do_still_birth), + required = true, + hasDependants = false, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + etMaxLength = 1, + max = 6, + min = 0, + refreshSiblingsOnChange = true + ) + + // Store EDD and LMP for calculations + private var eddDate: Long = 0L + private var lmpDate: Long = 0L + private var patientCaseId: String? = null + private var deliveryDateMillis: Long = 0L + + /** + * Set up the form page with patient and pregnancy data + */ + suspend fun setUpPage( + pwr: PregnantWomanRegistrationCache, + anc: PregnantWomanAncCache?, + saved: DeliveryOutcomeCache?, + patientName: String, + patientAge: String, + caseId: String + ) { + // Validate LMP date + if (pwr.lmpDate <= 0) { + throw IllegalStateException("Invalid LMP date: ${pwr.lmpDate}") + } + + // Store LMP and calculate EDD + lmpDate = pwr.lmpDate + eddDate = getEddFromLmp(lmpDate) + patientCaseId = caseId + + // Note: Woman name, age, and case ID are displayed in the card header, + // so they are not included in the form elements list + + // Set date validation constraints: EDD ± 4 weeks + val minDate = eddDate - TimeUnit.DAYS.toMillis(EDD_TOLERANCE_WEEKS_BEFORE * DAYS_IN_WEEK.toLong()) + val maxDate = minOf( + eddDate + TimeUnit.DAYS.toMillis(EDD_TOLERANCE_WEEKS_AFTER * DAYS_IN_WEEK.toLong()), + System.currentTimeMillis() // Cannot be future date + ) + dateOfDelivery.min = maxOf(minDate, pwr.lmpDate) + dateOfDelivery.max = maxDate + + val formElements = mutableListOf( + dateOfDelivery, + timeOfDelivery, + gestationalAgeAtDelivery, + placeOfDelivery, + deliveryConductedBy, + modeOfDelivery + ) + + if (saved == null) { + // New record - set default date to today (dd/MM/yyyy per JIRA) + dateOfDelivery.value = getDateFromLong(System.currentTimeMillis(), DATE_FORMAT_DD_MM_YYYY) + deliveryDateMillis = System.currentTimeMillis() + dateOfDischarge.min = deliveryDateMillis + // Calculate and display initial gestational age + updateGestationalAge(System.currentTimeMillis()) + + // Initialize mother condition fields + motherCondition.value = null + motherCurrentlyAdmitted.value = null + maternalComplications.value = null + dateOfDischarge.value = null + + // Initialize new outcome fields + stillBirth.value = "0" + deliveryOutcome.value = null + liveBirth.value = null + timeOfDischarge.value = null + + // Add mother condition fields for new records (both halves of form) + formElements.add(motherCondition) + formElements.add(motherCurrentlyAdmitted) + + // Add delivery outcome fields + formElements.add(deliveryOutcome) + formElements.add(liveBirth) + formElements.add(stillBirth) + } else { + // Load saved values (dd/MM/yyyy per JIRA) + dateOfDelivery.value = saved.dateOfDelivery?.let { getDateFromLong(it, DATE_FORMAT_DD_MM_YYYY) } + deliveryDateMillis = saved.dateOfDelivery ?: System.currentTimeMillis() + dateOfDischarge.min = deliveryDateMillis + dateOfDischarge.max = System.currentTimeMillis() + + timeOfDelivery.value = saved.timeOfDelivery + placeOfDelivery.value = getLocalValueInArray( + R.array.do_place_of_delivery_array, + saved.placeOfDelivery + ) + deliveryConductedBy.value = getLocalValueInArray( + R.array.do_delivery_conducted_by_array, + saved.deliveryConductedBy + ) + modeOfDelivery.value = getLocalValueInArray( + R.array.do_mode_of_delivery_array, + saved.modeOfDelivery + ) + // saved.* dropdown fields are stored in English; re-localize for display + // so the user sees the form in their current UI language regardless of + // which language the data was originally entered in. + indicationForLSCS.value = getLocalValuesInArray( + R.array.do_indication_for_lscs_array, + saved.indicationForLSCS + ) + indicationForLSCSOther.value = saved.indicationForLSCSOther + privateHospitalName.value = saved.privateHospitalName + + // Load mother condition fields + motherCondition.value = getLocalValueInArray( + R.array.do_mother_condition_array, + saved.motherCondition + ) + motherCurrentlyAdmitted.value = when (saved.motherCurrentlyAdmitted) { + true -> resources.getStringArray(R.array.do_mother_admitted_array)[0] + false -> resources.getStringArray(R.array.do_mother_admitted_array)[1] + null -> null + } + maternalComplications.value = getLocalValuesInArray( + R.array.do_maternal_complications_array, + saved.maternalComplications + ) + dateOfDischarge.value = saved.dateOfDischarge?.let { getDateFromLong(it) } + timeOfDischarge.value = saved.timeOfDischarge + deliveryOutcome.value = saved.deliveryOutcome?.toString() + liveBirth.value = saved.liveBirth?.toString() + stillBirth.value = saved.stillBirth?.toString() + + // Update gestational age for saved date + saved.dateOfDelivery?.let { updateGestationalAge(it) } + + // Handle conditional fields based on saved values + handlePlaceOfDeliveryChange(placeOfDelivery.value) + handleModeOfDeliveryChange(modeOfDelivery.value) + handleDeliveryConductedByChange(deliveryConductedBy.value) + + // Add mother condition fields (second half of form) + formElements.add(motherCondition) + + // Add maternal complications if mother condition is "Complication" or "Critical" + val conditionIndex = resources.getStringArray(R.array.do_mother_condition_array).indexOf(motherCondition.value) + if (conditionIndex == 1 || conditionIndex == 2) { + formElements.add(maternalComplications) + } + + // Always add admission status field + formElements.add(motherCurrentlyAdmitted) + + // Add discharge date if mother is discharged (not currently admitted) + if (motherCurrentlyAdmitted.value == resources.getStringArray(R.array.do_mother_admitted_array)[1]) { + formElements.add(dateOfDischarge) + formElements.add(timeOfDischarge) + } + + // Add delivery outcome fields at the end + formElements.add(deliveryOutcome) + formElements.add(liveBirth) + formElements.add(stillBirth) + } + + setUpPage(formElements) + } + + /** + * Alternative setUpPage for simpler use case (backward compatibility) + */ + suspend fun setUpPage(deliveryDateMillis: Long, saved: DeliveryOutcomeCache?, isDelivered: Boolean = false) { + this.deliveryDateMillis = deliveryDateMillis + dateOfDischarge.min = deliveryDateMillis + dateOfDischarge.max = System.currentTimeMillis() + dateOfDelivery.max = System.currentTimeMillis() + + val list = mutableListOf( + dateOfDelivery, + motherCondition, + motherCurrentlyAdmitted + ) + + if (saved != null) { + dateOfDelivery.value = saved.dateOfDelivery?.let { getDateFromLong(it) } + this.deliveryDateMillis = saved.dateOfDelivery ?: deliveryDateMillis + dateOfDelivery.min = this.deliveryDateMillis + dateOfDischarge.min = this.deliveryDateMillis + + motherCondition.value = getLocalValueInArray( + R.array.do_mother_condition_array, + saved.motherCondition + ) + motherCurrentlyAdmitted.value = when (saved.motherCurrentlyAdmitted) { + true -> resources.getStringArray(R.array.do_mother_admitted_array)[0] + false -> resources.getStringArray(R.array.do_mother_admitted_array)[1] + null -> null + } + maternalComplications.value = getLocalValuesInArray( + R.array.do_maternal_complications_array, + saved.maternalComplications + ) + dateOfDischarge.value = saved.dateOfDischarge?.let { getDateFromLong(it) } + timeOfDischarge.value = saved.timeOfDischarge + + val conditionIndex = resources.getStringArray(R.array.do_mother_condition_array).indexOf(motherCondition.value) + if (conditionIndex == 1 || conditionIndex == 2) { + list.add(list.indexOf(motherCondition) + 1, maternalComplications) + } + if (motherCurrentlyAdmitted.value == resources.getStringArray(R.array.do_mother_admitted_array)[1]) { + list.add(list.indexOf(motherCurrentlyAdmitted) + 1, dateOfDischarge) + list.add(list.indexOf(dateOfDischarge) + 1, timeOfDischarge) + } + } else { + dateOfDelivery.value = getDateFromLong(deliveryDateMillis) + motherCondition.value = null + motherCurrentlyAdmitted.value = null + maternalComplications.value = null + dateOfDischarge.value = null + timeOfDischarge.value = null + } + + setUpPage(list) + } + + /** + * Calculate gestational age in weeks and days format (e.g., "38w 5d") + */ + private fun calculateGestationalAge(deliveryDate: Long): String { + if (lmpDate <= 0) return "NA" + + val daysDiff = TimeUnit.MILLISECONDS.toDays(deliveryDate - lmpDate) + val weeks = (daysDiff / DAYS_IN_WEEK).toInt() + val days = (daysDiff % DAYS_IN_WEEK).toInt() + + return "${weeks}w ${days}d" + } + + /** + * Get gestational age classification for alerts + */ + private fun getGestationalAgeClassification(weeks: Int): String? { + return when { + weeks < EXTREMELY_PRETERM_WEEKS -> resources.getString(R.string.do_alert_extremely_preterm) + weeks < VERY_PRETERM_WEEKS -> resources.getString(R.string.do_alert_very_preterm) + weeks < PRETERM_THRESHOLD_WEEKS -> resources.getString(R.string.do_alert_moderate_preterm) + weeks < POST_TERM_WEEKS -> resources.getString(R.string.do_alert_term) + else -> resources.getString(R.string.do_alert_post_term) + } + } + + /** + * Update gestational age display and show classification alert + */ + private suspend fun updateGestationalAge(deliveryDate: Long) { + val gestationalAgeStr = calculateGestationalAge(deliveryDate) + gestationalAgeAtDelivery.value = gestationalAgeStr + + if (lmpDate > 0) { + val weeks = TimeUnit.MILLISECONDS.toDays(deliveryDate - lmpDate).toInt() / DAYS_IN_WEEK + + // Show gestational age classification alert in dialog + when { + weeks < EXTREMELY_PRETERM_WEEKS -> emitAlertErrorMessage(R.string.do_alert_extremely_preterm) + weeks < VERY_PRETERM_WEEKS -> emitAlertErrorMessage(R.string.do_alert_very_preterm) + weeks < PRETERM_THRESHOLD_WEEKS -> emitAlertErrorMessage(R.string.do_alert_moderate_preterm) + weeks < POST_TERM_WEEKS -> emitAlertErrorMessage(R.string.do_alert_term) + else -> emitAlertErrorMessage(R.string.do_alert_post_term) + } + } + } + + /** + * Validate date of delivery against EDD constraints + */ + private suspend fun validateDeliveryDate(): Boolean { + val deliveryDateLong = dateOfDelivery.value?.let { getLongFromDate(it, DATE_FORMAT_DD_MM_YYYY) } ?: return false + + // Check if future date + if (deliveryDateLong > System.currentTimeMillis()) { + dateOfDelivery.errorText = resources.getString(R.string.do_error_future_date) + return false + } + + // Check EDD ± 4 weeks constraint + val minAllowed = eddDate - TimeUnit.DAYS.toMillis(EDD_TOLERANCE_WEEKS_BEFORE * DAYS_IN_WEEK.toLong()) + val maxAllowed = eddDate + TimeUnit.DAYS.toMillis(EDD_TOLERANCE_WEEKS_AFTER * DAYS_IN_WEEK.toLong()) + + if (deliveryDateLong < minAllowed || deliveryDateLong > maxAllowed) { + val minDateStr = getDateFromLong(minAllowed, DATE_FORMAT_DD_MM_YYYY) ?: "" + val maxDateStr = getDateFromLong(maxAllowed, DATE_FORMAT_DD_MM_YYYY) ?: "" + dateOfDelivery.errorText = resources.getString( + R.string.do_error_date_range, + minDateStr, + maxDateStr + ) + return false + } + + val daysFromEDD = TimeUnit.MILLISECONDS.toDays(deliveryDateLong - eddDate).toInt() + if (daysFromEDD >= POST_TERM_THRESHOLD_DAYS) { + emitAlertErrorMessage(R.string.do_alert_post_term_delivery) + } + + // Check for preterm delivery (<37 weeks) + val weeks = TimeUnit.MILLISECONDS.toDays(deliveryDateLong - lmpDate).toInt() / DAYS_IN_WEEK + if (weeks < PRETERM_THRESHOLD_WEEKS) { + emitAlertErrorMessage(R.string.do_alert_preterm_delivery) + } + + dateOfDelivery.errorText = null + return true + } + + /** + * Handle place of delivery change - show alerts and enable/disable hospital name + */ + private suspend fun handlePlaceOfDeliveryChange(selectedValue: String?) { + val placeArray = resources.getStringArray(R.array.do_place_of_delivery_array) + when (selectedValue) { + placeArray[PLACE_PRIVATE_HOSPITAL_INDEX] -> { // "Private Hospital" + val index = getIndexOfElement(placeOfDelivery) + if (index != -1 && getIndexOfElement(privateHospitalName) == -1) { + triggerDependants( + source = placeOfDelivery, + removeItems = emptyList(), + addItems = listOf(privateHospitalName), + position = index + 1 + ) + } + privateHospitalName.isEnabled = true + } + placeArray[PLACE_HOME_DELIVERY_INDEX] -> { // "Home delivery" + if (getIndexOfElement(privateHospitalName) != -1) { + triggerDependants( + source = placeOfDelivery, + removeItems = listOf(privateHospitalName), + addItems = emptyList() + ) + privateHospitalName.value = null + } + emitAlertErrorMessage(R.string.do_alert_home_delivery) + } + placeArray[PLACE_ON_THE_WAY_INDEX] -> { // "On the way to facility" + if (getIndexOfElement(privateHospitalName) != -1) { + triggerDependants( + source = placeOfDelivery, + removeItems = listOf(privateHospitalName), + addItems = emptyList() + ) + privateHospitalName.value = null + } + emitAlertErrorMessage(R.string.do_alert_on_the_way) + } + else -> { + if (getIndexOfElement(privateHospitalName) != -1) { + triggerDependants( + source = placeOfDelivery, + removeItems = listOf(privateHospitalName), + addItems = emptyList() + ) + privateHospitalName.value = null + } + } + } + } + + /** + * Handle mode of delivery change - enable/disable indication field + */ + private suspend fun handleModeOfDeliveryChange(selectedValue: String?) { + val modeArray = resources.getStringArray(R.array.do_mode_of_delivery_array) + val requiresIndication = selectedValue == modeArray[MODE_ASSISTED_INDEX] || // "Vacuum/ Forceps Assisted" + selectedValue == modeArray[MODE_LSCS_INDEX] || // "Lower Segment Cesarean Section (LSCS)" + selectedValue == modeArray[MODE_EMERGENCY_LSCS_INDEX] // "Emergency LSCS" + + if (requiresIndication) { + indicationForLSCS.required = true + val index = getIndexOfElement(modeOfDelivery) + if (index != -1 && getIndexOfElement(indicationForLSCS) == -1) { + triggerDependants( + source = modeOfDelivery, + removeItems = emptyList(), + addItems = listOf(indicationForLSCS), + position = index + 1 + ) + } + } else { + indicationForLSCS.required = false + val removeItems = mutableListOf() + if (getIndexOfElement(indicationForLSCS) != -1) { + removeItems.add(indicationForLSCS) + indicationForLSCS.value = null + indicationForLSCS.errorText = null + } + if (getIndexOfElement(indicationForLSCSOther) != -1) { + removeItems.add(indicationForLSCSOther) + indicationForLSCSOther.value = null + } + if (removeItems.isNotEmpty()) { + triggerDependants( + source = modeOfDelivery, + removeItems = removeItems, + addItems = emptyList() + ) + } + } + } + + /** Indices in do_delivery_conducted_by_array for unskilled delivery (Dai/TBA, Family member, Self/Unassisted). */ + private val unskilledDeliveryConductedByIndices = setOf( + CONDUCTED_BY_DAI_TBA_INDEX, + CONDUCTED_BY_FAMILY_MEMBER_INDEX, + CONDUCTED_BY_SELF_INDEX + ) + + /** + * Handle delivery conducted by change - show alert for unskilled delivery. + * Prefers actual value (deliveryConductedBy.value set by adapter) for reliability; + * index/selectedValue used as fallback (e.g. when loading saved record). + */ + private suspend fun handleDeliveryConductedByChange(selectedValue: String?, selectedIndex: Int? = null) { + val valueToCheck = deliveryConductedBy.value?.trim() ?: selectedValue?.trim() + val shouldAlert = when { + valueToCheck != null -> { + val conductedByArray = resources.getStringArray(R.array.do_delivery_conducted_by_array) + val unskilledOptions = listOf( + conductedByArray[CONDUCTED_BY_DAI_TBA_INDEX], // "Dai/TBA (Traditional Birth Attendant)" + conductedByArray[CONDUCTED_BY_FAMILY_MEMBER_INDEX], // "Family member" + conductedByArray[CONDUCTED_BY_SELF_INDEX] // "Self/Unassisted" + ) + valueToCheck in unskilledOptions.map { it.trim() } + } + selectedIndex != null -> selectedIndex in unskilledDeliveryConductedByIndices + else -> false + } + if (shouldAlert) { + emitAlertErrorMessage(R.string.do_alert_unskilled_delivery) + } + } + + /** + * Handle indication for LSCS change - enable/disable "Other" text field + */ + private suspend fun handleIndicationForLSCSChange() { + val indicationArray = resources.getStringArray(R.array.do_indication_for_lscs_array) + val otherOption = indicationArray[indicationArray.size - 1] // "Other (specify)" + + if (indicationForLSCS.value?.contains(otherOption) == true) { + indicationForLSCSOther.required = true + val index = getIndexOfElement(indicationForLSCS) + if (index != -1 && getIndexOfElement(indicationForLSCSOther) == -1) { + triggerDependants( + source = indicationForLSCS, + removeItems = emptyList(), + addItems = listOf(indicationForLSCSOther), + position = index + 1 + ) + } + } else { + indicationForLSCSOther.required = false + if (getIndexOfElement(indicationForLSCSOther) != -1) { + triggerDependants( + source = indicationForLSCS, + removeItems = listOf(indicationForLSCSOther), + addItems = emptyList() + ) + indicationForLSCSOther.value = null + indicationForLSCSOther.errorText = null + } + } + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + dateOfDelivery.id -> handleDateOfDeliveryChange() + timeOfDelivery.id -> -1 + placeOfDelivery.id -> { + val selectedValue = placeOfDelivery.entries?.getOrNull(index) + placeOfDelivery.value = selectedValue + handlePlaceOfDeliveryChange(selectedValue) + getIndexOfElement(placeOfDelivery) + } + deliveryConductedBy.id -> { + val selectedValue = deliveryConductedBy.entries?.getOrNull(index) + deliveryConductedBy.value = selectedValue + handleDeliveryConductedByChange(selectedValue, index) + -1 + } + modeOfDelivery.id -> { + val selectedValue = modeOfDelivery.entries?.getOrNull(index) + modeOfDelivery.value = selectedValue + handleModeOfDeliveryChange(selectedValue) + getIndexOfElement(modeOfDelivery) + } + indicationForLSCS.id -> handleIndicationForLSCSValidation() + indicationForLSCSOther.id -> handleIndicationForLSCSOtherValidation() + privateHospitalName.id -> -1 + motherCondition.id -> handleMotherConditionChange(index) + motherCurrentlyAdmitted.id -> handleMotherAdmittedChange(index) + dateOfDischarge.id -> handleDateOfDischargeValidation() + maternalComplications.id -> handleMaternalComplicationsValidation() + deliveryOutcome.id -> validateDeliveryOutcome(deliveryOutcome) + liveBirth.id -> validateDeliveryOutcome(liveBirth) + stillBirth.id -> validateDeliveryOutcome(stillBirth) + else -> -1 + } + } + + private suspend fun handleDateOfDeliveryChange(): Int { + if (validateDeliveryDate()) { + getLongFromDate(dateOfDelivery.value, DATE_FORMAT_DD_MM_YYYY)?.let { + updateGestationalAge(it) + deliveryDateMillis = it + dateOfDischarge.min = it + // Re-validate discharge date: if set and now before new delivery date, show error + dateOfDischarge.value?.let { dischargeStr -> + val dischargeLong = getLongFromDate(dischargeStr) + dateOfDischarge.errorText = if (dischargeLong != 0L && dischargeLong < deliveryDateMillis) { + resources.getString(R.string.do_discharge_date_before_delivery) + } else null + } + } + } + return -1 + } + + private suspend fun handleIndicationForLSCSValidation(): Int { + handleIndicationForLSCSChange() + // Validate indication is selected if required + if (indicationForLSCS.required && indicationForLSCS.value.isNullOrBlank()) { + indicationForLSCS.errorText = resources.getString(R.string.do_error_indication_required) + } else { + indicationForLSCS.errorText = null + } + return getIndexOfElement(indicationForLSCS) + } + + private fun handleIndicationForLSCSOtherValidation(): Int { + val value = indicationForLSCSOther.value?.trim() + if (indicationForLSCSOther.required && value.isNullOrBlank()) { + indicationForLSCSOther.errorText = resources.getString(R.string.do_error_indication_other_required) + } else { + indicationForLSCSOther.errorText = null + } + return -1 + } + + private suspend fun handleMotherConditionChange(index: Int): Int { + maternalComplications.value = null + maternalComplications.errorText = null + return when (index) { + 0, 3 -> { // Healthy/Stable or Maternal Death + triggerDependants( + source = motherCondition, + removeItems = listOf(maternalComplications), + addItems = emptyList() + ) + getIndexOfElement(motherCondition) + } + 1, 2 -> { // Complication or Critical + val currentIndex = getIndexOfElement(motherCondition) + triggerDependants( + source = motherCondition, + removeItems = emptyList(), + addItems = listOf(maternalComplications), + position = if (currentIndex != -1) currentIndex + 1 else -1 + ) + getIndexOfElement(motherCondition) + } + else -> -1 + } + } + + private suspend fun handleMotherAdmittedChange(index: Int): Int { + dateOfDischarge.value = null + dateOfDischarge.errorText = null + timeOfDischarge.value = null + timeOfDischarge.errorText = null + val result = triggerDependants( + source = motherCurrentlyAdmitted, + passedIndex = index, + triggerIndex = 1, // Index 1 = "No (Discharged)" + target = listOf(dateOfDischarge, timeOfDischarge) + ) + return result + } + + private fun handleDateOfDischargeValidation(): Int { + dateOfDischarge.value?.let { dateStr -> + val dischargeLong = getLongFromDate(dateStr) + dateOfDischarge.errorText = if (dischargeLong != 0L && dischargeLong < deliveryDateMillis) { + resources.getString(R.string.do_discharge_date_before_delivery) + } else null + } ?: run { dateOfDischarge.errorText = null } + return -1 + } + + private fun handleMaternalComplicationsValidation(): Int { + if (!maternalComplications.value.isNullOrBlank()) { + maternalComplications.errorText = null + } + return -1 + } + + /** + * Validate delivery outcome fields: ensures deliveryOutcome = liveBirth + stillBirth + */ + private fun validateDeliveryOutcome(formElement: FormElement): Int { + formElement.errorText = formElement.value?.takeIf { it.isNotEmpty() }?.toLongOrNull()?.let { + formElement.min?.let { min -> + formElement.max?.let { max -> + if (it < min) { + resources.getString( + R.string.form_input_min_limit_error, formElement.title, min + ) + } else if (it > max) { + resources.getString( + R.string.form_input_max_limit_error, formElement.title, max + ) + } else null + } + } + } + + if (!liveBirth.value.isNullOrEmpty() && !stillBirth.value.isNullOrEmpty() && + !deliveryOutcome.value.isNullOrEmpty() && formElement.errorText.isNullOrEmpty() + ) { + if (deliveryOutcome.value!!.toInt() != liveBirth.value!!.toInt() + stillBirth.value!!.toInt()) { + formElement.errorText = + "Outcome of Delivery should be equal to sum of Live and Still births" + } else { + deliveryOutcome.errorText = null + liveBirth.errorText = null + stillBirth.errorText = null + } + } + + if (!deliveryOutcome.value.isNullOrEmpty()) { + stillBirth.max = deliveryOutcome.value?.toLongOrNull() + liveBirth.max = deliveryOutcome.value?.toLongOrNull() + } + return -1 + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + val form = cacheModel as DeliveryOutcomeCache + val admittedYes = resources.getStringArray(R.array.do_mother_admitted_array)[0] + val conditionMaternalDeath = resources.getStringArray(R.array.do_mother_condition_array)[3] + + // Map delivery details fields. Persist every dropdown / radio / checkbox + // value in its English canonical form so the DB stays locale-neutral. + // Display-time localization is handled in setUpPage. + form.dateOfDelivery = getLongFromDate(dateOfDelivery.value, DATE_FORMAT_DD_MM_YYYY)?.takeIf { it != 0L } ?: deliveryDateMillis + form.timeOfDelivery = timeOfDelivery.value + form.placeOfDelivery = getEnglishValueInArray(R.array.do_place_of_delivery_array, placeOfDelivery.value) + form.modeOfDelivery = getEnglishValueInArray(R.array.do_mode_of_delivery_array, modeOfDelivery.value) + form.deliveryConductedBy = getEnglishValueInArray(R.array.do_delivery_conducted_by_array, deliveryConductedBy.value) + form.gestationalAgeAtDelivery = gestationalAgeAtDelivery.value + form.indicationForLSCS = getEnglishValuesInArray(R.array.do_indication_for_lscs_array, indicationForLSCS.value) + form.indicationForLSCSOther = indicationForLSCSOther.value + form.privateHospitalName = privateHospitalName.value + + // Map mother condition fields + form.motherCondition = getEnglishValueInArray(R.array.do_mother_condition_array, motherCondition.value) + form.maternalComplications = getEnglishValuesInArray(R.array.do_maternal_complications_array, maternalComplications.value)?.takeIf { s -> s.isNotBlank() } + form.motherCurrentlyAdmitted = motherCurrentlyAdmitted.value == admittedYes + form.dateOfDischarge = dateOfDischarge.value?.let { getLongFromDate(it) }.takeIf { it != 0L } + form.timeOfDischarge = timeOfDischarge.value + form.deliveryOutcome = deliveryOutcome.value?.toIntOrNull() + form.liveBirth = liveBirth.value?.toIntOrNull() + form.stillBirth = stillBirth.value?.toIntOrNull() + // motherCondition.value is local; conditionMaternalDeath is also local — same-locale compare. + form.isDeath = motherCondition.value == conditionMaternalDeath + if (form.isDeath == true) { + form.isDeathValue = "Maternal Death" + } + } + + /** Index of Maternal Complications element (for showing intensive PNC / hysterectomy alerts). */ + fun getIndexOfMaternalComplications() = getIndexById(maternalComplications.id) + + /** True if Mother's Condition is Complication (specify) or Critical/ICU. */ + fun isComplicationOrCritical(): Boolean { + val v = motherCondition.value ?: return false + val arr = resources.getStringArray(R.array.do_mother_condition_array) + return v == arr.getOrNull(1) || v == arr.getOrNull(2) + } + + /** True if Mother's Condition is Maternal Death. */ + fun isMaternalDeath(): Boolean { + val v = motherCondition.value ?: return false + return v == resources.getStringArray(R.array.do_mother_condition_array).getOrNull(3) + } + + /** True if selected complications include PPH or Uterine rupture (intensive PNC alert). */ + fun hasPphOrUterineRupture(): Boolean { + val raw = maternalComplications.value ?: return false + val pph = resources.getStringArray(R.array.do_maternal_complications_array).getOrNull(0) ?: "Post-Partum Hemorrhage (PPH)" + val uterine = resources.getStringArray(R.array.do_maternal_complications_array).getOrNull(4) ?: "Uterine rupture" + return raw.contains(pph) || raw.contains(uterine) + } + + /** True if Hysterectomy performed is selected (family planning / EC update). */ + fun hasHysterectomy(): Boolean { + val raw = maternalComplications.value ?: return false + val hyst = resources.getStringArray(R.array.do_maternal_complications_array).getOrNull(8) ?: "Hysterectomy performed" + return raw.contains(hyst) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/EarDiagnosisDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/EarDiagnosisDataset.kt new file mode 100644 index 000000000..6a842c7e5 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/EarDiagnosisDataset.kt @@ -0,0 +1,262 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.EarDiagnosisAssessment +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType + +class EarDiagnosisDataset( + private val context: Context, + currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + private lateinit var cache: EarDiagnosisAssessment + + var onShowAlert: ((String) -> Unit)? = null + + private val optionYes = context.getString(R.string.yes_option) + private val optionNo = context.getString(R.string.no_option) + + private val difficultyHearing = FormElement( + id = 1, + inputType = InputType.RADIO, + title = context.getString(R.string.ear_difficulty_hearing), + entries = arrayOf(optionYes, optionNo), + required = true, + hasDependants = true + ) + + private val whisperTestResponse = FormElement( + id = 2, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.ear_whisper_test_response), + entries = arrayOf( + context.getString(R.string.ear_whisper_correct), + context.getString(R.string.ear_whisper_incorrect) + ), + required = true + ) + + private val hearingTestOutcome = FormElement( + id = 3, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.ear_hearing_test_outcome), + entries = arrayOf( + context.getString(R.string.ear_hearing_normal), + context.getString(R.string.ear_hearing_slight_loss), + context.getString(R.string.ear_hearing_moderate), + context.getString(R.string.ear_hearing_severe), + context.getString(R.string.ear_hearing_deaf) + ), + required = true, + hasAlertError = true + ) + + private val earPain = FormElement( + id = 4, + inputType = InputType.RADIO, + title = context.getString(R.string.ear_pain), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val earDischarge = FormElement( + id = 5, + inputType = InputType.RADIO, + title = context.getString(R.string.ear_discharge_present), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val optionForeignBodySuperficial = context.getString(R.string.ear_foreign_body_superficial) + private val optionForeignBodyDeep = context.getString(R.string.ear_foreign_body_deep) + + private val foreignBody = FormElement( + id = 6, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.ear_foreign_body), + entries = arrayOf(optionForeignBodySuperficial, optionForeignBodyDeep, optionNo), + required = false, + hasAlertError = true + ) + + private val earConditionType = FormElement( + id = 7, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.ear_condition_type), + entries = arrayOf( + context.getString(R.string.ear_otomycosis), + context.getString(R.string.ear_otitis_externa), + context.getString(R.string.ear_acute_discharge), + context.getString(R.string.ear_chronic_discharge), + context.getString(R.string.ear_wax) + ), + required = false + ) + + private val congenitalMalformation = FormElement( + id = 8, + inputType = InputType.RADIO, + title = context.getString(R.string.ear_congenital_malformation), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + + suspend fun setUpPage(savedRecord: EarDiagnosisAssessment?) { + cache = savedRecord ?: createDefaultCache() + + populateFromCache(cache) + + val list = mutableListOf() + list.add(difficultyHearing) + + if (difficultyHearing.value == optionYes) { + list.add(whisperTestResponse) + list.add(hearingTestOutcome) + } + + list.addAll( + listOf( + earPain, + earDischarge, + foreignBody, + earConditionType, + congenitalMalformation + ) + ) + + setUpPage(list) + } + + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + + difficultyHearing.id -> { + if (index == 0) { + triggerDependants( + source = difficultyHearing, + addItems = listOf(whisperTestResponse, hearingTestOutcome), + removeItems = emptyList() + ) + } else { + whisperTestResponse.value = null + hearingTestOutcome.value = null + triggerDependants( + source = difficultyHearing, + addItems = emptyList(), + removeItems = listOf(whisperTestResponse, hearingTestOutcome) + ) + } + difficultyHearing.id + } + + hearingTestOutcome.id -> { + val normalOption = context.getString(R.string.ear_hearing_normal) + if (hearingTestOutcome.value != normalOption) { + onShowAlert?.invoke( + context.getString(R.string.ear_alert_abnormal_hearing) + ) + } + -1 + } + foreignBody.id -> { + if (foreignBody.value == optionForeignBodyDeep) { + onShowAlert?.invoke( + context.getString(R.string.ear_alert_deep_foreign_body) + ) + } + -1 + } + congenitalMalformation.id -> { + if (congenitalMalformation.value == optionYes) { + onShowAlert?.invoke( + context.getString(R.string.ear_alert_congenital_malformation) + ) + } + -1 + } + else -> -1 + } + } + + private fun createDefaultCache(): EarDiagnosisAssessment { + return EarDiagnosisAssessment( + patientId = "", + benVisitNo = null + ) + } + + private fun populateFromCache(cache: EarDiagnosisAssessment) { + difficultyHearing.value = when (cache.difficultyHearing) { + true -> optionYes + false -> optionNo + else -> null + } + + whisperTestResponse.value = cache.whisperTestResponse + hearingTestOutcome.value = cache.hearingTestOutcome + earPain.value = when (cache.earPain) { + true -> optionYes + false -> optionNo + else -> null + } + + earDischarge.value = when (cache.earDischargePresent) { + true -> optionYes + false -> optionNo + else -> null + } + + foreignBody.value = cache.foreignBodyInEar + earConditionType.value = cache.earConditionType + + congenitalMalformation.value = when (cache.congenitalEarMalformation) { + true -> optionYes + false -> optionNo + else -> null + } + + } + + + + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as EarDiagnosisAssessment).let { + + it.difficultyHearing = when (difficultyHearing.value) { + optionYes -> true + optionNo -> false + else -> null + } + + it.whisperTestResponse = whisperTestResponse.value + it.hearingTestOutcome = hearingTestOutcome.value + it.earPain = when (earPain.value) { + optionYes -> true + optionNo -> false + else -> null + } + it.earDischargePresent = when (earDischarge.value) { + optionYes -> true + optionNo -> false + else -> null + } + + it.foreignBodyInEar = foreignBody.value + it.earConditionType = earConditionType.value + + it.congenitalEarMalformation = when (congenitalMalformation.value) { + optionYes -> true + optionNo -> false + else -> null + } + + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/ElderlyHealthAssessmentDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/ElderlyHealthAssessmentDataset.kt new file mode 100644 index 000000000..e265cef68 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/ElderlyHealthAssessmentDataset.kt @@ -0,0 +1,724 @@ +package org.piramalswasthya.cho.configuration +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.ElderlyHealthAssessment +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType + + +class ElderlyHealthAssessmentDataset( + private val context: Context, + currentLanguage: Languages +) : ReferralFollowUpDataset(context, currentLanguage) { + + private lateinit var cache: ElderlyHealthAssessment + + private var patientAge: Int? = null + + var onShowAlert: ((String) -> Unit)? = null + + private val optionYes = context.getString(R.string.yes_option) + private val optionNo = context.getString(R.string.no_option) + private val optionIndependent = context.getString(R.string.elderly_independent) + private val optionDependent = context.getString(R.string.elderly_dependent) + + private val optionNoDecline = context.getString(R.string.elderly_status_no_decline) + private val optionPartialDependence = context.getString(R.string.elderly_status_partial_dependence) + private val optionFunctionalDependence = context.getString(R.string.elderly_status_functional_dependence) + private val optionHighlyDependent = context.getString(R.string.elderly_status_highly_dependent) + private val optionUnknown = context.getString(R.string.elderly_status_unknown) + + private val optionSuspected = context.getString(R.string.elderly_outcome_suspected) + private val optionNotSuspected = context.getString(R.string.elderly_outcome_not_suspected) + + private val geriatricComplaints = FormElement( + id = 1, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_geriatric_complaints), + entries = arrayOf(optionYes, optionNo), + required = true + ) + + private val multipleChronicConditions = FormElement( + id = 2, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_multiple_chronic), + entries = arrayOf(optionYes), + required = false + ) + + private val recentFalls = FormElement( + id = 3, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_recent_falls), + entries = arrayOf(optionYes), + required = false + ) + + private val difficultyWalkingBalance = FormElement( + id = 4, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_difficulty_walking), + entries = arrayOf(optionYes), + required = false + ) + + private val visualHearingDifficulty = FormElement( + id = 5, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_visual_hearing), + entries = arrayOf(optionYes), + required = false + ) + + private val functionalDecline = FormElement( + id = 6, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_functional_decline), + entries = arrayOf(optionYes, optionNo), + required = true, + hasAlertError = true, + hasDependants = true + ) + + // ADL Assessment Fields + private val functionalassesmentHeadline = FormElement( + id = 32, + inputType = InputType.HEADLINE, + title = context.getString(R.string.elderly_functional_assessment_headline), + required = false + ) + private var bathing = FormElement( + id = 7, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_bathing), + entries = arrayOf(optionIndependent, optionDependent), + required = true, + hasDependants = true + ) + + private var dressing = FormElement( + id = 8, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_dressing), + entries = arrayOf(optionIndependent, optionDependent), + required = true, + hasDependants = true + ) + + private var toileting = FormElement( + id = 9, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_toileting), + entries = arrayOf(optionIndependent, optionDependent), + required = true, + hasDependants = true + ) + + private var transferring = FormElement( + id = 10, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_transferring), + entries = arrayOf(optionIndependent, optionDependent), + required = true, + hasDependants = true + ) + + private var continence = FormElement( + id = 11, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_continence), + entries = arrayOf(optionIndependent, optionDependent), + required = true, + hasDependants = true + ) + + private var feeding = FormElement( + id = 12, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_feeding), + entries = arrayOf(optionIndependent, optionDependent), + required = true, + hasDependants = true + ) + + private var totalScore = FormElement( + id = 13, + inputType = InputType.TEXT_VIEW, + title = context.getString(R.string.elderly_total_score), + required = false + ) + + private var functionalStatus = FormElement( + id = 14, + inputType = InputType.TEXT_VIEW, + title = context.getString(R.string.elderly_functional_status), + required = false + ) + + private var functionalDeclineFlag = FormElement( + id = 15, + inputType = InputType.TEXT_VIEW, + title = context.getString(R.string.elderly_functional_decline_flag), + required = false + ) + + private val memoryLoss = FormElement( + id = 16, + inputType = InputType.RADIO, + title = context.getString(R.string.elderly_memory_loss), + entries = arrayOf(optionYes, optionNo), + required = true, + hasAlertError = true, + hasDependants = true + ) + + // ---- Section B: Dementia Screening Checklist ---- + + private val dementiaSectionHeadline = FormElement( + id = 17, + inputType = InputType.HEADLINE, + title = context.getString(R.string.elderly_dementia_headline), + required = false + ) + + private val dementiaMemoryLoss = FormElement( + id = 18, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_dementia_memory_loss), + entries = arrayOf(optionYes), + required = false, + hasDependants = true + ) + + private val dementiaDisorientation = FormElement( + id = 19, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_dementia_disorientation), + entries = arrayOf(optionYes), + required = false, + hasDependants = true + ) + + private val dementiaBehaviouralChanges = FormElement( + id = 20, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_dementia_behavioural), + entries = arrayOf(optionYes), + required = false, + hasDependants = true + ) + + private val dementiaSelfCareDecline = FormElement( + id = 21, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.elderly_dementia_self_care), + entries = arrayOf(optionYes), + required = false, + hasDependants = true + ) + + private var dementiaScreeningOutcome = FormElement( + id = 22, + inputType = InputType.TEXT_VIEW, + title = context.getString(R.string.elderly_screening_outcome), + required = false + ) + + private var dementiaReferralRequired = FormElement( + id = 23, + inputType = InputType.TEXT_VIEW, + title = context.getString(R.string.elderly_referral_required), + required = false + ) + + + suspend fun setUpPage( + savedRecord: ElderlyHealthAssessment?, + patientAge: Int? = null + ) { + this.patientAge = patientAge + cache = savedRecord ?: createDefaultCache() + + val list = mutableListOf() + + populateFromCache(cache) + + list.add(geriatricComplaints) + list.add(multipleChronicConditions) + list.add(recentFalls) + list.add(difficultyWalkingBalance) + list.add(visualHearingDifficulty) + list.add(functionalDecline) + + if (functionalDecline.value == optionYes) { + list.addAll(getADLFields()) + computeADLScore() + } + + list.add(memoryLoss) + + // Section B: Dementia Screening – enabled only if Memory loss = Yes AND age >= 60 + if (memoryLoss.value == optionYes && patientAge != null && patientAge >= 60) { + list.addAll(getDementiaSectionFields()) + computeDementiaOutcome() + } + addReferralFollowUpElements(list) + + setUpPage(list) + } + + private fun getOptionalComplaintFields(): List { + return listOf( + multipleChronicConditions, + recentFalls, + difficultyWalkingBalance, + visualHearingDifficulty + ) + } + + private fun getADLFields(): List { + return listOf( + functionalassesmentHeadline, + bathing, + dressing, + toileting, + transferring, + continence, + feeding, + totalScore, + functionalStatus, + functionalDeclineFlag + ) + } + + private fun getADLAnswerFields(): List { + return listOf( + bathing, + dressing, + toileting, + transferring, + continence, + feeding + ) + } + + private fun getDementiaCheckboxFields(): List { + return listOf( + dementiaMemoryLoss, + dementiaDisorientation, + dementiaBehaviouralChanges, + dementiaSelfCareDecline + ) + } + + private fun getDementiaSectionFields(): List { + return listOf( + dementiaSectionHeadline, + dementiaMemoryLoss, + dementiaDisorientation, + dementiaBehaviouralChanges, + dementiaSelfCareDecline, + dementiaScreeningOutcome, + dementiaReferralRequired + ) + } + override val referralRequired = createReferralRequired(24) + override val referralLevel = createReferralLevel(25) + override val reasonForReferral = createReasonForReferral(26) + override val followUpRequired = createFollowUpRequired(27) + override val followUpDate = createFollowUpDate(28) + override val caseStatus = createCaseStatus(29) + override val dateOfDeath = createDateOfDeath(30) + override val remarks = createRemarks(31) + + private suspend fun computeDementiaOutcome() { + val anySelected = listOf( + dementiaMemoryLoss, + dementiaDisorientation, + dementiaBehaviouralChanges, + dementiaSelfCareDecline + ).any { it.value == optionYes } + + val newOutcomeValue = if (anySelected) optionSuspected else optionNotSuspected + val newReferralValue = if (anySelected) optionYes else optionNo + + // Find existing indices in the actual list BEFORE updating the references + val oldOutcomeIndex = getIndexById(dementiaScreeningOutcome.id) + val oldReferralIndex = getIndexById(dementiaReferralRequired.id) + + dementiaScreeningOutcome = dementiaScreeningOutcome.copy(value = newOutcomeValue) + dementiaReferralRequired = dementiaReferralRequired.copy(value = newReferralValue) + + // If these elements are currently visible in the list, update them in place + // This ensures DiffUtil detects the new objects without adding duplicates + if (oldOutcomeIndex != -1) { + setUpPage( + mList = getFormList().toMutableList().apply { + this[oldOutcomeIndex] = dementiaScreeningOutcome + if (oldReferralIndex != -1) { + this[oldReferralIndex] = dementiaReferralRequired + } + } + ) + } + } + + private suspend fun computeADLScore() { + // Find existing indices in the actual list BEFORE updating the references + val oldTotalIndex = getIndexById(totalScore.id) + val oldStatusIndex = getIndexById(functionalStatus.id) + val oldFlagIndex = getIndexById(functionalDeclineFlag.id) + + val adlFields = getADLAnswerFields() + val allAnswered = adlFields.all { !it.value.isNullOrBlank() } + + if (!allAnswered) { + totalScore = totalScore.copy(value = null) + functionalStatus = functionalStatus.copy(value = null) + functionalDeclineFlag = functionalDeclineFlag.copy(value = null) + } else { + val scores = adlFields.map { field -> + when (field.value) { + optionIndependent -> 1 + optionDependent -> 0 + else -> 0 + } + } + val total = scores.sum() + + val status = when (total) { + 6 -> optionNoDecline + in 4..5 -> optionPartialDependence + in 2..3 -> optionFunctionalDependence + in 0..1 -> optionHighlyDependent + else -> optionUnknown + } + + val flag = if (total <= 5) optionYes else optionNo + + totalScore = totalScore.copy(value = total.toString()) + functionalStatus = functionalStatus.copy(value = status) + functionalDeclineFlag = functionalDeclineFlag.copy(value = flag) + } + + if (oldTotalIndex != -1) { + setUpPage( + mList = getFormList().toMutableList().apply { + this[oldTotalIndex] = totalScore + if (oldStatusIndex != -1) { + this[oldStatusIndex] = functionalStatus + } + if (oldFlagIndex != -1) { + this[oldFlagIndex] = functionalDeclineFlag + } + } + ) + } + } + + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + val referralFollowUpResult = handleReferralFollowUpChange(formId, index) + if (referralFollowUpResult != -1) return referralFollowUpResult + return when (formId) { + + functionalDecline.id -> { + if (index == 0) { + onShowAlert?.invoke(context.getString(R.string.elderly_alert_functional_decline)) + } + handleFunctionalDeclineChange(index) + } + + bathing.id, dressing.id, toileting.id, transferring.id, continence.id, feeding.id -> { + computeADLScore() + getIndexById(totalScore.id) + } + + memoryLoss.id -> handleMemoryLossChange(index) + + dementiaMemoryLoss.id, + dementiaDisorientation.id, + dementiaBehaviouralChanges.id, + dementiaSelfCareDecline.id -> { + computeDementiaOutcome() + getIndexById(dementiaScreeningOutcome.id) + } + + else -> -1 + } + } + + + + private suspend fun handleMemoryLossChange(index: Int): Int { + if (index == 0) { + onShowAlert?.invoke(context.getString(R.string.elderly_alert_memory_loss)) + } + + val isEligible = patientAge != null && patientAge!! >= 60 + + return if (index == 0 && isEligible) { + // Memory loss = Yes and age >= 60 → show Section B + triggerDependants( + source = memoryLoss, + addItems = getDementiaSectionFields(), + removeItems = emptyList() + ) + computeDementiaOutcome() + memoryLoss.id + } else { + // Memory loss = No or age < 60 → hide Section B and clear values + getDementiaSectionFields().forEach { + it.value = null + } + triggerDependants( + source = memoryLoss, + addItems = emptyList(), + removeItems = getDementiaSectionFields() + ) + memoryLoss.id + } + } + + private suspend fun handleFunctionalDeclineChange(index: Int): Int { + return if (index == 0) { + // Functional decline = Yes → show ADL fields + triggerDependants( + source = functionalDecline, + addItems = getADLFields(), + removeItems = emptyList() + ) + computeADLScore() + functionalDecline.id + } else { + + getADLFields().forEach { + it.value = null + } + triggerDependants( + source = functionalDecline, + addItems = emptyList(), + removeItems = getADLFields() + ) + functionalDecline.id + } + } + + private fun createDefaultCache(): ElderlyHealthAssessment { + return ElderlyHealthAssessment( + patientId = "", + benVisitNo = 0, + geriatricComplaints = null, + multipleChronicConditions = null, + recentFalls = null, + difficultyWalkingBalance = null, + visualHearingDifficulty = null, + functionalDecline = null, + bathing = null, + dressing = null, + toileting = null, + transferring = null, + continence = null, + feeding = null, + totalScore = null, + functionalStatus = null, + functionalDeclineFlag = null, + memoryLoss = null, + dementiaMemoryLoss = null, + dementiaDisorientation = null, + dementiaBehaviouralChanges = null, + dementiaSelfCareDecline = null, + dementiaScreeningOutcome = null, + dementiaReferralRequired = null + ) + } + + private fun populateFromCache(cache: ElderlyHealthAssessment) { + geriatricComplaints.value = when(cache.geriatricComplaints) { + true -> optionYes + false -> optionNo + else -> null + } + + multipleChronicConditions.value = + if (cache.multipleChronicConditions == true) optionYes else null + + recentFalls.value = + if (cache.recentFalls == true) optionYes else null + + difficultyWalkingBalance.value = + if (cache.difficultyWalkingBalance == true) optionYes else null + + visualHearingDifficulty.value = + if (cache.visualHearingDifficulty == true) optionYes else null + + functionalDecline.value = when(cache.functionalDecline) { + true -> optionYes + false -> optionNo + else -> null + } + + bathing.value = when(cache.bathing) { + 1 -> optionIndependent + 0 -> optionDependent + else -> null + } + + dressing.value = when(cache.dressing) { + 1 -> optionIndependent + 0 -> optionDependent + else -> null + } + + toileting.value = when(cache.toileting) { + 1 -> optionIndependent + 0 -> optionDependent + else -> null + } + + transferring.value = when(cache.transferring) { + 1 -> optionIndependent + 0 -> optionDependent + else -> null + } + + continence.value = when(cache.continence) { + 1 -> optionIndependent + 0 -> optionDependent + else -> null + } + + feeding.value = when(cache.feeding) { + 1 -> optionIndependent + 0 -> optionDependent + else -> null + } + + totalScore.value = cache.totalScore?.toString() + functionalStatus.value = cache.functionalStatus + functionalDeclineFlag.value = when(cache.functionalDeclineFlag) { + true -> optionYes + false -> optionNo + else -> null + } + + memoryLoss.value = when(cache.memoryLoss) { + true -> optionYes + false -> optionNo + else -> null + } + + // Dementia fields + dementiaMemoryLoss.value = + if (cache.dementiaMemoryLoss == true) optionYes else null + + dementiaDisorientation.value = + if (cache.dementiaDisorientation == true) optionYes else null + + dementiaBehaviouralChanges.value = + if (cache.dementiaBehaviouralChanges == true) optionYes else null + + dementiaSelfCareDecline.value = + if (cache.dementiaSelfCareDecline == true) optionYes else null + + dementiaScreeningOutcome.value = cache.dementiaScreeningOutcome + dementiaReferralRequired.value = when(cache.dementiaReferralRequired) { + true -> optionYes + false -> optionNo + else -> null + } + populateReferralFollowUpFromCache(cache) + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as ElderlyHealthAssessment).let { + + it.geriatricComplaints = + geriatricComplaints.value == optionYes + + it.multipleChronicConditions = + multipleChronicConditions.value == optionYes + + it.recentFalls = + recentFalls.value == optionYes + + it.difficultyWalkingBalance = + difficultyWalkingBalance.value == optionYes + + it.visualHearingDifficulty = + visualHearingDifficulty.value == optionYes + + it.functionalDecline = + functionalDecline.value == optionYes + + it.bathing = when(bathing.value) { + optionIndependent -> 1 + optionDependent -> 0 + else -> null + } + + it.dressing = when(dressing.value) { + optionIndependent -> 1 + optionDependent -> 0 + else -> null + } + + it.toileting = when(toileting.value) { + optionIndependent -> 1 + optionDependent -> 0 + else -> null + } + + it.transferring = when(transferring.value) { + optionIndependent -> 1 + optionDependent -> 0 + else -> null + } + + it.continence = when(continence.value) { + optionIndependent -> 1 + optionDependent -> 0 + else -> null + } + + it.feeding = when(feeding.value) { + optionIndependent -> 1 + optionDependent -> 0 + else -> null + } + + it.totalScore = totalScore.value?.toIntOrNull() + it.functionalStatus = functionalStatus.value + it.functionalDeclineFlag = when (functionalDeclineFlag.value) { + optionYes -> true + optionNo -> false + else -> null + } + + it.memoryLoss = + memoryLoss.value == optionYes + + // Dementia fields + it.dementiaMemoryLoss = + dementiaMemoryLoss.value == optionYes + + it.dementiaDisorientation = + dementiaDisorientation.value == optionYes + + it.dementiaBehaviouralChanges = + dementiaBehaviouralChanges.value == optionYes + + it.dementiaSelfCareDecline = + dementiaSelfCareDecline.value == optionYes + + it.dementiaScreeningOutcome = + dementiaScreeningOutcome.value + + it.dementiaReferralRequired = + dementiaReferralRequired.value == optionYes + mapReferralFollowUpValues(it) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/EligibleCoupleTrackingDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/EligibleCoupleTrackingDataset.kt index da28d3d29..a41c32b19 100644 --- a/app/src/main/java/org/piramalswasthya/cho/configuration/EligibleCoupleTrackingDataset.kt +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/EligibleCoupleTrackingDataset.kt @@ -2,14 +2,11 @@ package org.piramalswasthya.cho.configuration import android.content.Context import org.piramalswasthya.cho.R -import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.helpers.Languages import org.piramalswasthya.cho.helpers.setToStartOfTheDay -//import org.piramalswasthya.cho.model.BenRegCache import org.piramalswasthya.cho.model.EligibleCoupleTrackingCache import org.piramalswasthya.cho.model.FormElement import org.piramalswasthya.cho.model.InputType -import org.piramalswasthya.cho.model.Patient import org.piramalswasthya.cho.model.PatientDisplay import java.util.Calendar @@ -17,15 +14,42 @@ class EligibleCoupleTrackingDataset( context: Context, currentLanguage: Languages ) : Dataset(context, currentLanguage) { + companion object { + // Method of Contraception indices + const val INDEX_SELF = 0 + const val INDEX_ANTRA = 1 + const val INDEX_COPPER_T = 2 + const val INDEX_CONDOM = 3 + const val INDEX_MALA_N = 4 + const val INDEX_CHAYA = 5 + const val INDEX_ECP = 6 + const val INDEX_MALE_STERILIZATION = 7 + const val INDEX_FEMALE_STERILIZATION = 8 + const val INDEX_MINILAP = 9 + const val INDEX_ANY_OTHER = 10 + + // ANTRA constants + const val ANTRA_INJECTION = "ANTRA Injection" + const val MALE_STERILIZATION_VAL = "Male Sterilization" + const val FEMALE_STERILIZATION_VAL = "Female Sterilization" + const val MINILAP_VAL = "MiniLap" + const val ANTRA_MIN_GAP_DAYS = 75 + const val ANTRA_MAX_GAP_DAYS = 120 + + // Backdating limits in days + const val LMP_BACKDATE_LIMIT_DAYS = 180 // 6 months + const val STERILIZATION_BACKDATE_LIMIT_DAYS = 60 // 2 months + const val ANTRA_BACKDATE_LIMIT_DAYS = 60 // 2 months + } + private var dateOfVisit = FormElement( id = 1, inputType = InputType.DATE_PICKER, - title = context.getString(R.string.tracking_date), + title = resources.getString(R.string.tracking_date), arrayId = -1, required = true, max = System.currentTimeMillis(), hasDependants = true - ) private val financialYear = FormElement( @@ -41,67 +65,200 @@ class EligibleCoupleTrackingDataset( title = "Month", arrayId = R.array.visit_months, entries = resources.getStringArray(R.array.visit_months), - required = false + required = false, ) - private val isPregnancyTestDone = FormElement( + private val lmpDate = FormElement( id = 4, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.lmp_date), + required = true, + max = System.currentTimeMillis(), + min = Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, -LMP_BACKDATE_LIMIT_DAYS) + }.timeInMillis + ) + + private val isPregnancyTestDone = FormElement( + id = 5, inputType = InputType.RADIO, - title = "Is Pregnancy Test done?", - entries = arrayOf("Yes", "No", "Don't Know"), + title = resources.getString(R.string.is_pregnancy_test_done), + entries = arrayOf("Yes", "No"), required = false, hasDependants = true ) private val pregnancyTestResult = FormElement( - id = 5, + id = 6, inputType = InputType.RADIO, - title = "Pregnancy Test Result", + title = resources.getString(R.string.pregnancy_test_result), entries = arrayOf("Positive", "Negative"), required = true, hasDependants = true ) private val isPregnant = FormElement( - id = 6, + id = 7, inputType = InputType.RADIO, - title = "Is the woman pregnant?", - entries = arrayOf("Yes", "No", "Don't Know"), + title = resources.getString(R.string.is_woman_pregnant), + entries = arrayOf("Yes", "No"), required = false, hasDependants = true ) private val usingFamilyPlanning = FormElement( - id = 7, + id = 8, inputType = InputType.RADIO, - title = "Are you using Family Planning Method?", + title = resources.getString(R.string.using_family_planning), entries = arrayOf("Yes", "No"), required = false, hasDependants = true ) private val methodOfContraception = FormElement( - id = 8, + id = 9, inputType = InputType.DROPDOWN, - title = "Method of Contraception", + title = resources.getString(R.string.method_of_contraception_label), arrayId = R.array.method_of_contraception, entries = resources.getStringArray(R.array.method_of_contraception), - required = false, + required = true, hasDependants = true - ) private val anyOtherMethod = FormElement( - id = 9, + id = 10, inputType = InputType.EDIT_TEXT, - title = "Any Other Method", + title = resources.getString(R.string.any_other_method), required = true, etInputType = android.text.InputType.TYPE_CLASS_TEXT, etMaxLength = 50 ) + private val antraDose = FormElement( + id = 11, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.antra_dose), + arrayId = R.array.antra_doses, + entries = resources.getStringArray(R.array.antra_doses), + required = true, + hasDependants = true + ) + + private val antraInjectionDate = FormElement( + id = 12, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.antra_injection_date), + required = true, + max = System.currentTimeMillis(), + min = Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, -ANTRA_BACKDATE_LIMIT_DAYS) + }.timeInMillis, + hasDependants = true + ) + + private val antraDueDate = FormElement( + id = 13, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.antra_due_date), + required = false + ) + + private val dateOfSterilization = FormElement( + id = 14, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.date_of_sterilization), + required = true, + max = System.currentTimeMillis(), + min = Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, -STERILIZATION_BACKDATE_LIMIT_DAYS) + }.timeInMillis + ) + + // Store number of children to filter sterilization options + private var numberOfChildren: Int = 0 + + // Store last ANTRA injection info for dose calculation + private var lastAntraInjectionDate: Long? = null + private var lastAntraDose: String? = null fun getIndexOfIsPregnant() = getIndexById(isPregnant.id) + fun getContraceptionMethodId() = methodOfContraception.id + + fun setNumberOfChildren(count: Int) { + numberOfChildren = count + updateContraceptionOptions() + } + + fun setLastAntraInfo(injectionDate: Long?, dose: String?) { + lastAntraInjectionDate = injectionDate + lastAntraDose = dose + } + + private fun clearFamilyPlanningFields() { + usingFamilyPlanning.value = null + methodOfContraception.value = null + anyOtherMethod.value = null + antraDose.value = null + antraInjectionDate.value = null + antraDueDate.value = null + dateOfSterilization.value = null + } + + private fun clearPregnancyFields() { + pregnancyTestResult.value = null + isPregnant.value = null + isPregnant.isEnabled = true + } + + private fun updateContraceptionOptions() { + val allOptions = resources.getStringArray(R.array.method_of_contraception).toMutableList() + + // If No. of Children = 0, hide sterilization options + if (numberOfChildren == 0) { + allOptions.removeAt(INDEX_MINILAP) + allOptions.removeAt(INDEX_FEMALE_STERILIZATION) + allOptions.removeAt(INDEX_MALE_STERILIZATION) + } + + methodOfContraception.entries = allOptions.toTypedArray() + } + + private fun calculateNextAntraDose(visitDate: Long): String { + if (lastAntraInjectionDate == null || lastAntraDose == null) { + return resources.getStringArray(R.array.antra_doses)[0] // 1st Dose + } + + val daysSinceLastInjection = ((visitDate - lastAntraInjectionDate!!) / (1000 * 60 * 60 * 24)).toInt() + + // If gap is more than 120 days between any 2 doses then restart from "Dose-1" + if (daysSinceLastInjection > ANTRA_MAX_GAP_DAYS) { + return resources.getStringArray(R.array.antra_doses)[0] + } + + // Get next dose + val doses = resources.getStringArray(R.array.antra_doses) + val currentDoseIndex = doses.indexOf(lastAntraDose) + return if (currentDoseIndex >= 0 && currentDoseIndex < doses.size - 1) { + doses[currentDoseIndex + 1] + } else { + // After 10th dose, stay on 10th or loop? Usually stays/repeats. + doses.last() + } + } + + private fun calculateAntraDueDateRange(injectionDate: Long): String { + val cal = Calendar.getInstance() + cal.timeInMillis = injectionDate + + cal.add(Calendar.DAY_OF_YEAR, 76) + val startDate = getDateFromLong(cal.timeInMillis) + + cal.timeInMillis = injectionDate + cal.add(Calendar.DAY_OF_YEAR, 120) + val endDate = getDateFromLong(cal.timeInMillis) + + return "$startDate to $endDate" + } suspend fun setUpPage( ben: PatientDisplay?, @@ -113,19 +270,14 @@ class EligibleCoupleTrackingDataset( dateOfVisit, financialYear, month, + lmpDate, isPregnancyTestDone, - isPregnant, -// usingFamilyPlanning, ) - if (saved == null) { - dateOfVisit.value = getDateFromLong(System.currentTimeMillis()) - dateOfVisit.value?.let { - financialYear.value = getFinancialYear(it) - month.value = - resources.getStringArray(R.array.visit_months)[Companion.getMonth(it)!!] - } - dateOfVisit.min = lastTrack?.let { + val now = System.currentTimeMillis() + if (saved == null) { + // Set minimum date for visit (next month after last visit or registration date) + val minDate = lastTrack?.let { Calendar.getInstance().apply { timeInMillis = it.visitDate val currentMonth = get(Calendar.MONTH) @@ -139,131 +291,424 @@ class EligibleCoupleTrackingDataset( setToStartOfTheDay() }.timeInMillis } ?: dateOfReg + + dateOfVisit.min = minDate + // Ensure max is never less than min to avoid DatePicker crash + dateOfVisit.max = if (now < minDate) minDate else now + + // Set initial value within [min, max] range + val initialValue = if (now < minDate) minDate else now + dateOfVisit.value = getDateFromLong(initialValue) + dateOfVisit.value?.let { + financialYear.value = getFinancialYear(it) + month.value = resources.getStringArray(R.array.visit_months)[getMonth(it)!!] + } + + // ANTRA dose restart alert is handled in ViewModel/Fragment level } else { + // Restore saved values dateOfVisit.value = getDateFromLong(saved.visitDate) - financialYear.value = getFinancialYear(dateString = dateOfVisit.value) - month.value = - resources.getStringArray(R.array.visit_months)[Companion.getMonth(dateOfVisit.value)!!] + financialYear.value = saved.financialYear ?: getFinancialYear(dateOfVisit.value) + month.value = saved.visitMonth ?: resources.getStringArray(R.array.visit_months)[getMonth(dateOfVisit.value)!!] + + // Still refresh max to avoid stale current time + dateOfVisit.max = if (now < (dateOfVisit.min ?: 0L)) dateOfVisit.min else now + + saved.lmpDate?.let { + lmpDate.value = getDateFromLong(it) + } + isPregnancyTestDone.value = saved.isPregnancyTestDone if (isPregnancyTestDone.value == "Yes") { list.add(list.indexOf(isPregnancyTestDone) + 1, pregnancyTestResult) pregnancyTestResult.value = saved.pregnancyTestResult + + if (pregnancyTestResult.value == "Positive") { + list.add(isPregnant) + isPregnant.value = "Yes" + isPregnant.isEnabled = false + } } - isPregnant.value = saved.isPregnant - if (isPregnant.value == "No") { - list.add(usingFamilyPlanning) + + if (isPregnancyTestDone.value == "No" || pregnancyTestResult.value == "Negative") { + if (!list.contains(usingFamilyPlanning)) { + list.add(usingFamilyPlanning) + } saved.usingFamilyPlanning?.let { usingFamilyPlanning.value = if (it) "Yes" else "No" } - usingFamilyPlanning.value = if (saved.usingFamilyPlanning == true) "Yes" else "No" - if (saved.usingFamilyPlanning == true) { + + if (usingFamilyPlanning.value == "Yes") { list.add(methodOfContraception) - if (saved.methodOfContraception in resources.getStringArray(R.array.method_of_contraception)) { - methodOfContraception.value = saved.methodOfContraception - } else if (saved.methodOfContraception != null) { - methodOfContraception.value = - resources.getStringArray(R.array.method_of_contraception).last() + methodOfContraception.value = saved.methodOfContraception + + // Handle ANTRA fields + if (methodOfContraception.value == ANTRA_INJECTION) { + list.add(antraDose) + list.add(antraInjectionDate) + list.add(antraDueDate) + antraDose.value = saved.antraDose + saved.antraInjectionDate?.let { + antraInjectionDate.value = getDateFromLong(it) + antraDueDate.value = calculateAntraDueDateRange(it) + } + } + + // Handle Sterilization fields + if (methodOfContraception.value in listOf(MALE_STERILIZATION_VAL, FEMALE_STERILIZATION_VAL, MINILAP_VAL)) { + list.add(dateOfSterilization) + saved.dateOfSterilization?.let { + dateOfSterilization.value = getDateFromLong(it) + } + } + + // Handle Any Other Method + val contraceptionOptions = resources.getStringArray(R.array.method_of_contraception) + if (methodOfContraception.value != null && methodOfContraception.value !in contraceptionOptions && methodOfContraception.value != ANTRA_INJECTION) { + methodOfContraception.value = contraceptionOptions.last() + list.add(anyOtherMethod) + anyOtherMethod.value = saved.anyOtherMethod ?: saved.methodOfContraception + } else if (methodOfContraception.value == contraceptionOptions.last()) { list.add(anyOtherMethod) - anyOtherMethod.value = saved.methodOfContraception + anyOtherMethod.value = saved.anyOtherMethod } } } - } - setUpPage(list) + setUpPage(list) } override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { return when (formId) { - dateOfVisit.id -> { - financialYear.value = Companion.getFinancialYear(dateOfVisit.value) - month.value = - resources.getStringArray(R.array.visit_months)[Companion.getMonth(dateOfVisit.value)!!] - -1 + dateOfVisit.id -> handleDateOfVisitChange() + isPregnancyTestDone.id -> handleIsPregnancyTestDoneChange() + pregnancyTestResult.id -> handlePregnancyTestResultChange() + isPregnant.id -> handleIsPregnantChange(index) + usingFamilyPlanning.id -> handleUsingFamilyPlanningChange(index) + methodOfContraception.id -> handleMethodOfContraceptionChange() + antraInjectionDate.id -> handleAntraInjectionDateChange() + anyOtherMethod.id -> { + validateAllAlphabetsSpaceOnEditText(anyOtherMethod) } - isPregnancyTestDone.id -> { - isPregnant.isEnabled = true + else -> -1 + } + } + + private suspend fun handleDateOfVisitChange(): Int { + financialYear.value = getFinancialYear(dateOfVisit.value) + month.value = + resources.getStringArray(R.array.visit_months)[getMonth(dateOfVisit.value)!!] + return triggerDependants( + source = dateOfVisit, + removeItems = emptyList(), + addItems = listOf(financialYear, month) + ) + } + + private suspend fun handleIsPregnancyTestDoneChange(): Int { + return when (isPregnancyTestDone.value) { + "Yes" -> { + clearFamilyPlanningFields() triggerDependants( source = isPregnancyTestDone, - passedIndex = index, - triggerIndex = 0, - target = pregnancyTestResult + removeItems = listOf( + usingFamilyPlanning, + methodOfContraception, + anyOtherMethod, + antraDose, + antraInjectionDate, + antraDueDate, + dateOfSterilization + ), + addItems = listOf(pregnancyTestResult) ) } - pregnancyTestResult.id -> { - if (pregnancyTestResult.value == "Positive") { - isPregnant.value = "Yes" - isPregnant.isEnabled = false - } else { - isPregnant.value = null - isPregnant.isEnabled = true - } - handleListOnValueChanged(isPregnant.id, 0) + "No" -> { + clearPregnancyFields() + triggerDependants( + source = isPregnancyTestDone, + removeItems = listOf(pregnancyTestResult, isPregnant), + addItems = listOf(usingFamilyPlanning) + ) + } + else -> -1 + } + } + + private suspend fun handlePregnancyTestResultChange(): Int { + return when (pregnancyTestResult.value) { + "Positive" -> { + isPregnant.value = "Yes" + isPregnant.isEnabled = false + clearFamilyPlanningFields() + triggerDependants( + source = pregnancyTestResult, + removeItems = listOf( + usingFamilyPlanning, + methodOfContraception, + anyOtherMethod, + antraDose, + antraInjectionDate, + antraDueDate, + dateOfSterilization + ), + addItems = listOf(isPregnant) + ) } - isPregnant.id -> { + "Negative" -> { + isPregnant.value = "No" + clearFamilyPlanningFields() + triggerDependants( + source = pregnancyTestResult, + removeItems = listOf(isPregnant), + addItems = listOf(usingFamilyPlanning) + ) + } + + else -> -1 + } + } + + private suspend fun handleIsPregnantChange(index: Int): Int { + return when (isPregnant.value) { + "Yes" -> { + clearFamilyPlanningFields() + triggerDependants( + source = isPregnant, + removeItems = listOf( + usingFamilyPlanning, + methodOfContraception, + anyOtherMethod, + antraDose, + antraInjectionDate, + antraDueDate, + dateOfSterilization + ), + addItems = emptyList() + ) + } + + "No" -> { triggerDependants( source = isPregnant, passedIndex = index, triggerIndex = 1, target = usingFamilyPlanning, - targetSideEffect = listOf(methodOfContraception, anyOtherMethod) + targetSideEffect = listOf( + methodOfContraception, + anyOtherMethod, + antraDose, + antraInjectionDate, + antraDueDate, + dateOfSterilization + ) + ) + } + + else -> -1 + } + } + + private suspend fun handleUsingFamilyPlanningChange(index: Int): Int { + return triggerDependants( + source = usingFamilyPlanning, + passedIndex = index, + triggerIndex = 0, + target = methodOfContraception, + targetSideEffect = listOf( + anyOtherMethod, + antraDose, + antraInjectionDate, + antraDueDate, + dateOfSterilization + ) + ) + } + + private suspend fun handleMethodOfContraceptionChange(): Int { + return when (methodOfContraception.value) { + ANTRA_INJECTION -> { + anyOtherMethod.value = null + dateOfSterilization.value = null + antraDose.value = calculateNextAntraDose(getLongFromDate(dateOfVisit.value)) + + val now = System.currentTimeMillis() + if (antraDose.value == resources.getStringArray(R.array.antra_doses)[0]) { + antraInjectionDate.min = Calendar.getInstance().apply { + add(Calendar.DAY_OF_YEAR, -ANTRA_BACKDATE_LIMIT_DAYS) + }.timeInMillis + } else { + antraInjectionDate.min = lastAntraInjectionDate ?: 0L + } + antraInjectionDate.max = + if (now < (antraInjectionDate.min ?: 0L)) antraInjectionDate.min else now + + triggerDependants( + source = methodOfContraception, + removeItems = listOf(anyOtherMethod, dateOfSterilization), + addItems = listOf(antraDose, antraInjectionDate, antraDueDate) ) } - usingFamilyPlanning.id -> { + MALE_STERILIZATION_VAL, FEMALE_STERILIZATION_VAL, MINILAP_VAL -> { + anyOtherMethod.value = null + antraDose.value = null + antraInjectionDate.value = null + antraDueDate.value = null + + val now = System.currentTimeMillis() + dateOfSterilization.max = + if (now < (dateOfSterilization.min ?: 0L)) dateOfSterilization.min else now + triggerDependants( - source = usingFamilyPlanning, - passedIndex = index, - triggerIndex = 0, - target = methodOfContraception, - targetSideEffect = listOf(anyOtherMethod) + source = methodOfContraception, + removeItems = listOf( + anyOtherMethod, + antraDose, + antraInjectionDate, + antraDueDate + ), + addItems = listOf(dateOfSterilization) ) } - methodOfContraception.id -> { + "Any Other Method" -> { + antraDose.value = null + antraInjectionDate.value = null + antraDueDate.value = null + dateOfSterilization.value = null triggerDependants( source = methodOfContraception, - passedIndex = index, - triggerIndex = methodOfContraception.entries!!.lastIndex, - target = anyOtherMethod + removeItems = listOf( + antraDose, + antraInjectionDate, + antraDueDate, + dateOfSterilization + ), + addItems = listOf(anyOtherMethod) ) } - anyOtherMethod.id -> { - validateAllAlphabetsSpaceOnEditText(anyOtherMethod) + else -> { + anyOtherMethod.value = null + antraDose.value = null + antraInjectionDate.value = null + antraDueDate.value = null + dateOfSterilization.value = null + triggerDependants( + source = methodOfContraception, + removeItems = listOf( + anyOtherMethod, + antraDose, + antraInjectionDate, + antraDueDate, + dateOfSterilization + ), + addItems = emptyList() + ) } + } + } - else -> -1 + private suspend fun handleAntraInjectionDateChange(): Int { + antraInjectionDate.value?.let { + val injectionDateLong = getLongFromDate(it) + antraDueDate.value = calculateAntraDueDateRange(injectionDateLong) + + if (antraDose.value != resources.getStringArray(R.array.antra_doses)[0] && lastAntraInjectionDate != null) { + val gap = + ((injectionDateLong - lastAntraInjectionDate!!) / (1000 * 60 * 60 * 24)).toInt() + if (gap < 75 || gap > 120) { + antraInjectionDate.errorText = "Gap between doses should be 75-120 days" + } else { + antraInjectionDate.errorText = null + } + } } + return triggerDependants( + source = antraInjectionDate, + removeItems = emptyList(), + addItems = listOf(antraDueDate) + ) } override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { (cacheModel as EligibleCoupleTrackingCache).let { form -> form.visitDate = getLongFromDate(dateOfVisit.value) + form.financialYear = financialYear.value + form.visitMonth = month.value + form.lmpDate = lmpDate.value?.let { getLongFromDate(it) } form.isPregnancyTestDone = isPregnancyTestDone.value form.pregnancyTestResult = pregnancyTestResult.value - form.isPregnant = isPregnant.value + + // Explicitly set isPregnant based on test status since it might be removed from the UI and cleared + form.isPregnant = if (pregnancyTestResult.value == "Negative" || isPregnancyTestDone.value == "No") { + "No" + } else { + isPregnant.value + } + form.usingFamilyPlanning = usingFamilyPlanning.value?.let { it == "Yes" } + + // Handle method of contraception if (methodOfContraception.value == "Any Other Method") { form.methodOfContraception = anyOtherMethod.value + form.anyOtherMethod = anyOtherMethod.value } else { form.methodOfContraception = methodOfContraception.value + form.anyOtherMethod = null + } + + // Handle ANTRA fields + if (methodOfContraception.value == ANTRA_INJECTION) { + form.antraDose = antraDose.value + form.antraInjectionDate = antraInjectionDate.value?.let { getLongFromDate(it) } + // Save the first date of the range to database + form.antraDueDate = antraDueDate.value?.let { + val firstDateStr = it.split(" to ")[0] + getLongFromDate(firstDateStr) + } + } else { + form.antraDose = null + form.antraInjectionDate = null + form.antraDueDate = null + } + + // Handle Sterilization fields + if (methodOfContraception.value in listOf(MALE_STERILIZATION_VAL, FEMALE_STERILIZATION_VAL, MINILAP_VAL)) { + form.dateOfSterilization = dateOfSterilization.value?.let { getLongFromDate(it) } + } else { + form.dateOfSterilization = null } } } -// fun updateBen(benRegCache: BenRegCache) { -// benRegCache.genDetails?.let { -// it.reproductiveStatus = -// englishResources.getStringArray(R.array.nbr_reproductive_status_array)[1] -// it.reproductiveStatusId = 2 -// } -// if (benRegCache.processed != "N") benRegCache.processed = "U" -// benRegCache.syncState = SyncState.UNSYNCED -// } + fun isPregnancyPositive(): Boolean { + return isPregnant.value == "Yes" || pregnancyTestResult.value == "Positive" + } + + fun shouldOpenPregnantWomanRegistration(): Boolean { + return pregnancyTestResult.value == "Positive" && isPregnant.value == "Yes" + } + + fun isSterilizationSelected(): Boolean { + return methodOfContraception.value in listOf(MALE_STERILIZATION_VAL, FEMALE_STERILIZATION_VAL, MINILAP_VAL) + } + + fun isAntraSelected(): Boolean { + return methodOfContraception.value == ANTRA_INJECTION + } + + fun getAntraDueDate(): Long? { + return antraDueDate.value?.let { getLongFromDate(it) } + } + + fun getSterilizationMethod(): String? { + return if (isSterilizationSelected()) methodOfContraception.value else null + } } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/MentalHealthScreeningDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/MentalHealthScreeningDataset.kt new file mode 100644 index 000000000..abdcf9498 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/MentalHealthScreeningDataset.kt @@ -0,0 +1,1865 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.MentalHealthScreeningCache +import org.piramalswasthya.cho.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +class MentalHealthScreeningDataset( + private val context: Context, + currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + private lateinit var phq9Options: Array + private lateinit var substanceFrequencyOptions: Array + private lateinit var suicideRiskOptions: Array + private lateinit var epilepsyDurationOptions: Array + private lateinit var yesNoOptions: Array + private lateinit var alcoholSystemActionOptions: Array + + private lateinit var phq9SeverityOptions: Array + private lateinit var phq9SystemActionOptions: Array + private lateinit var tobaccoOutcomeOptions: Array + private lateinit var tobaccoSystemActionOptions: Array + private lateinit var alcoholClassificationOptions: Array + private lateinit var mhEdOutcomeOptions: Array + private lateinit var mhEdReasonNeurological: String + private lateinit var mhSuspected: String + private lateinit var mhDeAddiction: String + private lateinit var mhDeAddictionCentre: String + private lateinit var mhDateFormatPattern: String + + private lateinit var referralLevelDmhpOptions: Array + private lateinit var referralLevelAlcoholOptions: Array + private lateinit var referralLevelRbskOptions: Array + private lateinit var referralLevelChildOptions: Array + private lateinit var referralLevelAdultOptions: Array + private lateinit var referralLevelStandardMhOptions: Array + + private lateinit var cache: MentalHealthScreeningCache + private var lastPhq9AlertLevel = 0 + private var lastSuicideRiskLevel = "" + private var genderID: Int? = null + private var age: Int? = null + private var wasAutoReferralForced = false + private val _phq9AlertMessageFlow = MutableStateFlow(null) + val phq9AlertMessageFlow = _phq9AlertMessageFlow.asStateFlow() + + init { + loadResourceStrings() + } + + fun resetPhq9AlertMessageFlow() { + _phq9AlertMessageFlow.value = null + } + + private fun loadResourceStrings() { + context.resources.apply { + phq9Options = getStringArray(R.array.phq9_options) + substanceFrequencyOptions = getStringArray(R.array.substance_frequency_options) + suicideRiskOptions = getStringArray(R.array.suicide_risk_options) + epilepsyDurationOptions = getStringArray(R.array.epilepsy_duration_options) + yesNoOptions = getStringArray(R.array.yes_no_options) + alcoholSystemActionOptions = getStringArray(R.array.alcohol_system_action_options) + + phq9SeverityOptions = getStringArray(R.array.phq9_severity_options) + phq9SystemActionOptions = getStringArray(R.array.phq9_system_action_options) + tobaccoOutcomeOptions = getStringArray(R.array.mh_tobacco_outcome_options) + tobaccoSystemActionOptions = getStringArray(R.array.mh_tobacco_system_action_options) + alcoholClassificationOptions = getStringArray(R.array.mh_alcohol_classification_options) + mhEdOutcomeOptions = getStringArray(R.array.mh_ed_outcome_options) + mhEdReasonNeurological = getString(R.string.mh_ed_reason_neurological) + + referralLevelDmhpOptions = getStringArray(R.array.mh_referral_level_dmhp_options) + referralLevelAlcoholOptions = getStringArray(R.array.mh_referral_level_alcohol_options) + referralLevelRbskOptions = getStringArray(R.array.mh_referral_level_rbsk_options) + referralLevelChildOptions = getStringArray(R.array.mh_referral_level_child_options) + referralLevelAdultOptions = getStringArray(R.array.mh_referral_level_adult_options) + referralLevelStandardMhOptions = getStringArray(R.array.mh_referral_level_standard_mh_options) + + mhSuspected = getString(R.string.mh_suspected) + mhDeAddiction = getString(R.string.mh_de_addiction) + mhDeAddictionCentre = getString(R.string.mh_de_addiction_centre) + mhDateFormatPattern = getString(R.string.mh_date_format_pattern) + } + } + + + + private fun createYesNoRadioWithDependantsElement(elementId: Int, titleResId: Int): FormElement { + return FormElement( + id = elementId, + inputType = InputType.RADIO, + title = context.getString(titleResId), + entries = yesNoOptions, + required = true, + hasDependants = true + ) + } + + private val emotionalBehaviouralConcerns: FormElement by lazy { + createYesNoRadioWithDependantsElement(101, R.string.emotional_behavioural_concerns) + } + + private val substanceUseConcerns: FormElement by lazy { + createYesNoRadioWithDependantsElement(102, R.string.substance_use_concerns) + } + + private val selfHarmSuicideThoughts: FormElement by lazy { + createYesNoRadioWithDependantsElement(103, R.string.self_harm_suicide_thoughts) + } + + // Q4 + private val memoryLossConfusion: FormElement by lazy { + createYesNoRadioWithDependantsElement(104, R.string.memory_loss_confusion) + } + + private val seizuresFitsLoc: FormElement by lazy { + createYesNoRadioWithDependantsElement(105, R.string.seizures_fits_loc) + } + + + private val isPostpartum: FormElement by lazy { + FormElement( + id = 106, + inputType = InputType.RADIO, + title = context.getString(R.string.postpartum_woman), + entries = yesNoOptions, + required = false, + hasDependants = true + ) + } + + private fun createHeadlineElement(elementId: Int, titleResId: Int): FormElement { + return FormElement( + id = elementId, + inputType = InputType.HEADLINE, + title = context.getString(titleResId), + required = false + ) + } + + private fun createTextViewElement(elementId: Int, titleStr: String): FormElement { + return FormElement( + id = elementId, + inputType = InputType.TEXT_VIEW, + title = titleStr, + required = false + ) + } + + private fun createDropDownElement( + elementId: Int, + titleStr: String, + options: Array, + hasDependants: Boolean = false + ): FormElement { + return FormElement( + id = elementId, + inputType = InputType.DROPDOWN, + title = titleStr, + entries = options, + required = false, + hasDependants = hasDependants + ) + } + private val mhReferralRequired: FormElement by lazy { + FormElement( + id = 107, + inputType = InputType.RADIO, + title = context.getString(R.string.mh_referral_required_title), + entries = yesNoOptions, + required = true, + hasDependants = true + ) + } + + private val mhReferralLevel: FormElement by lazy { + FormElement( + id = 108, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.mh_referral_level_title), + entries = context.resources.getStringArray(R.array.mh_referral_level_options), + required = true + ) + } + + private val mhReasonForReferral: FormElement by lazy { + FormElement( + id = 109, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.mh_reason_for_referral_title), + entries = context.resources.getStringArray(R.array.mh_reason_for_referral_options), + required = true + ) + } + + private var mhReferralDate = FormElement( + id = 110, + inputType = InputType.TEXT_VIEW, + title = context.getString(R.string.mh_referral_date_title), + required = false + ) + + // ── Follow-up & Closure fields ─────────────────────────────────── + private val mhFollowUpRequired: FormElement by lazy { + FormElement( + id = 111, + inputType = InputType.RADIO, + title = context.getString(R.string.mh_follow_up_required_title), + entries = yesNoOptions, + required = true, + hasDependants = true + ) + } + + private val mhFollowUpDate: FormElement by lazy { + FormElement( + id = 112, + inputType = InputType.DATE_PICKER, + title = context.getString(R.string.mh_follow_up_date_title), + required = true + ) + } + + private val mhImprovementNoted: FormElement by lazy { + FormElement( + id = 113, + inputType = InputType.RADIO, + title = context.getString(R.string.mh_improvement_noted_title), + entries = yesNoOptions, + required = true + ) + } + + private val mhAdherenceToAdvice: FormElement by lazy { + FormElement( + id = 117, + inputType = InputType.RADIO, + title = context.getString(R.string.mh_adherence_to_advice_title), + entries = yesNoOptions, + required = true + ) + } + + private val mhRepeatPhq9Header: FormElement by lazy { + FormElement( + id = 114, + inputType = InputType.HEADLINE, + title = context.getString(R.string.mh_repeat_phq9_title), + required = false + ) + } + + private val mhReferralEscalation: FormElement by lazy { + FormElement( + id = 115, + inputType = InputType.RADIO, + title = context.getString(R.string.mh_referral_escalation_title), + entries = yesNoOptions, + required = true + ) + } + private val mhCaseClosureReason: FormElement by lazy { + FormElement( + id = 116, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.mh_case_closure_reason_title), + entries = context.resources.getStringArray(R.array.mh_case_closure_reason_options), + required = true + ) + } + + private val phq9Header: FormElement by lazy { + createHeadlineElement(200, R.string.phq9_section_title) + } + + private fun createPhq9RadioElement(elementId: Int, titleResId: Int): FormElement { + return FormElement( + id = elementId, + inputType = InputType.RADIO, + title = context.getString(titleResId), + entries = phq9Options, + required = true, + hasDependants = true + ) + } + + private val phq9LittleInterest: FormElement by lazy { + createPhq9RadioElement(201, R.string.phq9_little_interest) + } + + private val phq9FeelingDown: FormElement by lazy { + createPhq9RadioElement(202, R.string.phq9_feeling_down) + } + + private val phq9SleepTrouble: FormElement by lazy { + createPhq9RadioElement(203, R.string.phq9_sleep_trouble) + } + + private val phq9FeelingTired: FormElement by lazy { + createPhq9RadioElement(204, R.string.phq9_feeling_tired) + } + + private val phq9Appetite: FormElement by lazy { + createPhq9RadioElement(205, R.string.phq9_appetite) + } + + private val phq9FeelingBad: FormElement by lazy { + createPhq9RadioElement(206, R.string.phq9_feeling_bad) + } + + private val phq9Concentration: FormElement by lazy { + createPhq9RadioElement(207, R.string.phq9_concentration) + } + + private val phq9MovingSlowly: FormElement by lazy { + createPhq9RadioElement(208, R.string.phq9_moving_slowly) + } + + private val phq9SelfHarmThoughts: FormElement by lazy { + createPhq9RadioElement(209, R.string.phq9_self_harm_thoughts) + } + + private var phq9TotalScore = createTextViewElement(210, context.getString(R.string.phq9_total_score)) + + private var phq9DepressionSeverity = createTextViewElement(211, context.getString(R.string.mh_depression_severity)) + + private var phq9SystemAction = createTextViewElement(212, context.getString(R.string.mh_system_action)) + + private val substanceHeader: FormElement by lazy { + createHeadlineElement(300, R.string.substance_section_title) + } + + private val substanceTobaccoHeader: FormElement by lazy { + createHeadlineElement(312, R.string.substance_tobacco_title) + } + + private val substanceAlcoholHeader: FormElement by lazy { + createHeadlineElement(313, R.string.substance_alcohol_title) + } + private val substanceCurrentTobaccoUse = FormElement( + id = 307, + inputType = InputType.RADIO, + title = context.getString(R.string.mh_current_tobacco_use), + entries = yesNoOptions, + required = true, + hasDependants = true + ) + + private val substanceTobaccoType = FormElement( + id = 308, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.mh_tobacco_type), + entries = arrayOf( + context.getString(R.string.mh_tobacco_smoking), + context.getString(R.string.mh_tobacco_smokeless), + context.getString(R.string.mh_tobacco_both) + ), + required = true + ) + + private val substanceTobaccoFrequency = FormElement( + id = 309, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.mh_tobacco_frequency), + entries = arrayOf( + context.getString(R.string.mh_tobacco_occasional), + context.getString(R.string.mh_tobacco_daily) + ), + required = true + ) + + private var substanceTobaccoOutcome = createTextViewElement(310, context.getString(R.string.mh_tobacco_outcome)) + + private var substanceSystemAction = createTextViewElement(311, context.getString(R.string.mh_tobacco_system_action)) + + private val substanceAlcoholUse: FormElement by lazy { + FormElement( + id = 301, + inputType = InputType.RADIO, + title = context.getString(R.string.substance_alcohol_use), + entries = yesNoOptions, + required = true, + hasDependants = true + ) + } + + private val substanceTobaccoUse: FormElement by lazy { + FormElement( + id = 302, + inputType = InputType.RADIO, + title = context.getString(R.string.substance_tobacco_use), + entries = yesNoOptions, + required = true + ) + } + + private val substance_alcohol_loss: FormElement by lazy { + createYesNoRadioWithDependantsElement(303, R.string.substance_alcohol_loss) + } + + private val substanceAlcoholImpact: FormElement by lazy { + createYesNoRadioWithDependantsElement(314, R.string.substance_alcohol_impact) + } + + private val substanceAlcoholWithdrawal: FormElement by lazy { + createYesNoRadioWithDependantsElement(315, R.string.substance_alcohol_withdrawal) + } + + private val substanceAlcoholProblematic: FormElement by lazy { + createYesNoRadioWithDependantsElement(316, R.string.substance_alcohol_problematic) + } + + private val substanceOtherSpecify: FormElement by lazy { + FormElement( + id = 304, + inputType = InputType.EDIT_TEXT, + title = context.getString(R.string.substance_other_specify), + required = true, + etMaxLength = 200, + etInputType = android.text.InputType.TYPE_CLASS_TEXT + ) + } + + private val substanceAlcoholClassification = createTextViewElement(317, context.getString(R.string.substance_alcohol_classification)) + + // Auto-filled by the system based on the alcohol classification (see computeAlcoholClassification) + private val substanceAlcoholSystemAction = createTextViewElement(318, context.getString(R.string.substance_alcohol_system_action)) + + private val substance_alcohol_frequency: FormElement by lazy { + FormElement( + id = 305, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.substance_alcohol_frequency), + entries = substanceFrequencyOptions, + required = true + ) + } + + private val briefInterventionGiven: FormElement by lazy { + FormElement( + id = 306, + inputType = InputType.RADIO, + title = context.getString(R.string.brief_intervention_given), + entries = yesNoOptions, + required = true + ) + } + + + + private val suicideHeader: FormElement by lazy { + createHeadlineElement(400, R.string.suicide_section_title) + } + + private val suicideCurrentThoughts: FormElement by lazy { + createYesNoRadioWithDependantsElement(401, R.string.suicide_current_thoughts) + } + + private val suicidePlan: FormElement by lazy { + createYesNoRadioWithDependantsElement(402, R.string.suicide_plan) + } + + private val suicidePreviousAttempt: FormElement by lazy { + createYesNoRadioWithDependantsElement(403, R.string.suicide_previous_attempt) + } + + private val suicideHopelessness: FormElement by lazy { + createYesNoRadioWithDependantsElement(404, R.string.suicide_hopelessness) + } + + private var suicideRiskLevel = createTextViewElement(405, context.getString(R.string.suicide_risk_level)) + + private val suicideImmediateAssess: FormElement by lazy { + createYesNoRadioWithDependantsElement(406, R.string.suicide_immediate_assess) + } + + + + private val dementiaHeader: FormElement by lazy { + createHeadlineElement(500, R.string.dementia_section_title) + } + + private fun createEpilepsyDementiaRadioElement(elementId: Int, titleResId: Int): FormElement { + return FormElement( + id = elementId, + inputType = InputType.RADIO, + title = context.getString(titleResId), + entries = yesNoOptions, + required = true + ) + } + + private val dementiaProgressiveMemoryLoss: FormElement by lazy { + createEpilepsyDementiaRadioElement(501, R.string.dementia_progressive_memory_loss) + } + + private val dementiaForgettingRecent: FormElement by lazy { + createEpilepsyDementiaRadioElement(502, R.string.dementia_forgetting_recent) + } + + private val dementiaDisorientation: FormElement by lazy { + createEpilepsyDementiaRadioElement(503, R.string.dementia_disorientation) + } + + private val dementiaDailyActivities: FormElement by lazy { + createEpilepsyDementiaRadioElement(504, R.string.dementia_daily_activities) + } + + private val dementiaBehaviouralChanges: FormElement by lazy { + createEpilepsyDementiaRadioElement(505, R.string.dementia_behavioural_changes) + } + + + + private val epilepsyHeader: FormElement by lazy { + createHeadlineElement(600, R.string.epilepsy_section_title) + } + + private val epilepsyRecurrentSeizures: FormElement by lazy { + createEpilepsyDementiaRadioElement(601, R.string.epilepsy_recurrent_seizures) + } + + private val epilepsyJerkyMovements: FormElement by lazy { + createEpilepsyDementiaRadioElement(602, R.string.epilepsy_jerky_movements) + } + + private val epilepsyTongueBite: FormElement by lazy { + createEpilepsyDementiaRadioElement(603, R.string.epilepsy_tongue_bite) + } + + private val epilepsyConfusionAfter: FormElement by lazy { + createEpilepsyDementiaRadioElement(604, R.string.epilepsy_confusion_after) + } + + private val epilepsyLocDuration: FormElement by lazy { + FormElement( + id = 605, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.epilepsy_loc_duration), + entries = epilepsyDurationOptions, + required = true + ) + } + private val edChecklistHeader: FormElement by lazy { + createHeadlineElement(700, R.string.epilepsy_dementia_checklist_title) + } + + + private fun createEdCheckboxElement(elementId: Int, titleResId: Int): FormElement { + return FormElement( + id = elementId, + inputType = InputType.CHECKBOXES, + title = context.getString(titleResId), + entries = arrayOf(yesNoOptions[0]), + required = false + ) + } + + private val edRecurrentJerkyMovements: FormElement by lazy { + createEdCheckboxElement(701, R.string.ed_recurrent_jerky_movements) + } + + private val edProgressiveMemoryLoss: FormElement by lazy { + createEdCheckboxElement(702, R.string.ed_progressive_memory_loss) + } + + private val edConfusionDisorientation: FormElement by lazy { + createEdCheckboxElement(703, R.string.ed_confusion_disorientation) + } + + private val edFunctionalDecline: FormElement by lazy { + createEdCheckboxElement(704, R.string.ed_functional_decline) + } + + private val edAge60Years: FormElement by lazy { + FormElement( + id = 714, + inputType = InputType.TEXT_VIEW, + title = context.getString(R.string.ed_age_60_years), + entries = yesNoOptions, + required = false, + isEnabled = false + + ) + } + + private var edScreeningOutcome = createTextViewElement(705, context.getString(R.string.ed_screening_outcome)) + + private val edReferralRequired: FormElement by lazy { + FormElement( + id = 706, + inputType = InputType.RADIO, + title = context.getString(R.string.ed_referral_required), + entries = yesNoOptions, + required = false + ) + } + private var edReason = createTextViewElement(715, context.getString(R.string.ed_reason)) + private val edRecurrentEpisodeloss: FormElement by lazy { + createEdCheckboxElement(707, R.string.ed_edRecurrentEpisodeloss) + } + private val edConfusionordrowsiness: FormElement by lazy { + createEdCheckboxElement(713, R.string.ed_edConfusionordrowsiness) + } + + private val edPsychosocialIntervention: FormElement by lazy { + FormElement( + id = 708, + inputType = InputType.RADIO, + title = context.getString(R.string.ed_psychosocial_intervention_provided), + entries = yesNoOptions, + required = true, + hasDependants = true + ) + } + + private val edInterventionType: FormElement by lazy { + FormElement( + id = 709, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.ed_intervention_type), + entries = arrayOf( + context.getString(R.string.mh_intervention_psychoeducation), + context.getString(R.string.mh_intervention_counselling), + context.getString(R.string.mh_intervention_stress_mgmt), + context.getString(R.string.mh_intervention_family_counselling) + ), + required = true + ) + } + + private val edSessionDate: FormElement by lazy { + FormElement( + id = 710, + inputType = InputType.DATE_PICKER, + title = context.getString(R.string.ed_session_date), + required = true + ) + } + + private val edDurationMinutes: FormElement by lazy { + FormElement( + id = 711, + inputType = InputType.EDIT_TEXT, + title = context.getString(R.string.ed_duration_minutes), + required = true, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + min = 10, + max = 60 + ) + } + + private val edRemarks: FormElement by lazy { + FormElement( + id = 712, + inputType = InputType.EDIT_TEXT, + title = context.getString(R.string.ed_remarks), + required = false, + etMaxLength = 250 + ) + } + + + + + // ── Setup Page ─────────────────────────────────────────────────── + + suspend fun setUpPage( + savedRecord: MentalHealthScreeningCache?, + isPostpartumFromRmncha: Boolean? = null, + genderID: Int? = null, + age: Int? = null + ) { + this.genderID = genderID + this.age = age + cache = savedRecord ?: MentalHealthScreeningCache( + patientId = "", + benVisitNo = null + ) + + if (cache.isPostpartum == null && isPostpartumFromRmncha != null && genderID == 2 && age in 15..49) { + cache.isPostpartum = isPostpartumFromRmncha + } + + populateFromCache(cache) + + setUpPage(buildFormElementList()) + } + + private fun shouldShowPhq9(): Boolean { + return isYes(emotionalBehaviouralConcerns.value) || + isYes(selfHarmSuicideThoughts.value) || + isYes(isPostpartum.value) + } + private fun todayDateString(): String = + SimpleDateFormat(mhDateFormatPattern, Locale.ENGLISH).format(Date()) + + private fun shouldAutoSuggestReferral(): Boolean { + return dementiaDailyActivities.value == yesNoOptions[0] || + substanceAlcoholClassification.value == alcoholClassificationOptions.getOrNull(1) + } + + private fun shouldShowReferralRequiredField(): Boolean { + return isYes(emotionalBehaviouralConcerns.value) || + isYes(substanceUseConcerns.value) || + isYes(selfHarmSuicideThoughts.value) || + isYes(memoryLossConfusion.value) || + isYes(seizuresFitsLoc.value) || + isYes(isPostpartum.value) || + shouldAutoReferFromPhq9() || + shouldAutoReferFromHighSuicideRisk() + } + + private fun shouldAutoReferUnder11(): Boolean { + val isUnder11 = (age ?: Int.MAX_VALUE) < 11 + if (!isUnder11) return false + + return isYes(emotionalBehaviouralConcerns.value) || + isYes(substanceUseConcerns.value) || + isYes(selfHarmSuicideThoughts.value) || + isYes(memoryLossConfusion.value) || + isYes(seizuresFitsLoc.value) + } + + private fun shouldAutoReferFromPhq9(): Boolean { + return (phq9TotalScore.value?.toIntOrNull() ?: 0) >= 15 + } + + private fun shouldAutoReferFromHighSuicideRisk(): Boolean { + val highRiskLabel = suicideRiskOptions.getOrNull(2) + return isYes(selfHarmSuicideThoughts.value) && + (suicideRiskLevel.value == highRiskLabel) + } + + private fun shouldAutoReferFromEdSuspectedOutcome(): Boolean { + val suspectedEpilepsy = mhEdOutcomeOptions.getOrNull(0) + val suspectedDementia = mhEdOutcomeOptions.getOrNull(1) + val suspected = mhEdOutcomeOptions.getOrNull(2) + return edScreeningOutcome.value in setOf( + suspected, + suspectedDementia, + suspectedEpilepsy + ) + } + + private fun shouldAutoReferFromProblematicAlcohol(): Boolean { + return substanceAlcoholClassification.value == alcoholClassificationOptions.getOrNull(1) + } + + private fun isLowSuicideRisk(): Boolean { + val lowRiskLabel = suicideRiskOptions.getOrNull(0) + return isYes(selfHarmSuicideThoughts.value) && + (suicideRiskLevel.value == lowRiskLabel) + } + + private fun isAge11OrBelow(): Boolean { + val currentAge = age + return currentAge != null && currentAge <= 11 + } + + private fun isAge11PlusSuicideContext(): Boolean { + val currentAge = age + return currentAge != null && currentAge > 11 && isYes(selfHarmSuicideThoughts.value) + } + + private fun isAge11OrBelowSuicideContext(): Boolean { + val currentAge = age + return currentAge != null && currentAge <= 11 && isYes(selfHarmSuicideThoughts.value) + } + + private fun shouldForceAutoReferralRequired(): Boolean { + // For low suicide risk: + // - age <= 11: keep referral locked to Yes + // - age > 11: keep referral manual-select + if (isLowSuicideRisk() && !isAge11OrBelow() && !shouldAutoReferFromProblematicAlcohol()) { + return false + } + + return shouldAutoReferUnder11() || + shouldAutoReferFromPhq9() || + shouldAutoReferFromHighSuicideRisk() || + shouldAutoReferFromEdSuspectedOutcome() || + shouldAutoReferFromProblematicAlcohol() + } + + private fun applyAutoReferralRules() { + if (isLowSuicideRisk() && !isAge11OrBelow()) { + // Low suicide risk must remain manual. If the field was previously auto-forced, + // clear forced values so the user can re-select Yes/No. + if (wasAutoReferralForced || (mhReferralRequired.entries?.size ?: 0) == 1) { + mhReferralRequired.value = null + mhReferralLevel.value = null + mhReasonForReferral.value = null + mhReferralDate.value = null + } + wasAutoReferralForced = false + return + } + + val isAutoReferralForced = shouldForceAutoReferralRequired() + if (isAutoReferralForced) { + mhReferralRequired.value = yesNoOptions[0] + if (mhReferralDate.value.isNullOrEmpty()) { + mhReferralDate.value = todayDateString() + } + } else if (wasAutoReferralForced) { + // Auto-force removed (e.g. suicide risk High -> Medium): clear stale forced values + // so UI updates immediately without requiring manual scroll/rebind. + mhReferralRequired.value = null + mhReferralLevel.value = null + mhReasonForReferral.value = null + mhReferralDate.value = null + } + wasAutoReferralForced = isAutoReferralForced + } + + private fun updateReferralRequiredOptions() { + mhReferralRequired.entries = if (shouldForceAutoReferralRequired()) { + arrayOf(yesNoOptions[0]) + } else { + yesNoOptions + } + } + + private fun getReferralLevelOptionsByAge(): Array { + val currentAge = age + return when { + currentAge != null && currentAge >= 11 && isAge11PlusSuicideContext() -> + referralLevelDmhpOptions + isAlcoholReferralContext() -> referralLevelAlcoholOptions + currentAge != null && currentAge < 6 -> referralLevelRbskOptions + currentAge != null && currentAge in 6..10 -> referralLevelChildOptions + currentAge != null && currentAge >= 11 -> referralLevelAdultOptions + else -> context.resources.getStringArray(R.array.referral_level_options) + } + } + + private fun isAlcoholReferralContext(): Boolean { + return isYes(substanceUseConcerns.value) + } + + private fun mentalHealthReferralContext(): Boolean { + return isYes(emotionalBehaviouralConcerns.value) || + isYes(selfHarmSuicideThoughts.value) || + isYes(memoryLossConfusion.value) || + isYes(seizuresFitsLoc.value) || + isYes(isPostpartum.value) + } + + private fun updateReferralLevelOptions() { + val currentAge = age + val options = if (currentAge != null && currentAge < 6) { + referralLevelRbskOptions + } else if (currentAge != null && currentAge in 6..10) { + referralLevelChildOptions + } else if (isAlcoholReferralContext()) { + referralLevelAlcoholOptions + } else if (mentalHealthReferralContext()) { + referralLevelStandardMhOptions + } else { + getReferralLevelOptionsByAge() + } + mhReferralLevel.entries = options + + val deAddictionLabel = referralLevelAdultOptions.getOrNull(4) + if ((mhReferralLevel.value == mhDeAddictionCentre || mhReferralLevel.value == mhDeAddiction) && deAddictionLabel != null && options.contains(deAddictionLabel)) { + mhReferralLevel.value = deAddictionLabel + } + + if (mhReferralLevel.value != null && !options.contains(mhReferralLevel.value)) { + mhReferralLevel.value = null + } + } + + private fun updateReferralReasonOptions() { + val options = if ((age ?: Int.MAX_VALUE) < 11) { + context.resources.getStringArray(R.array.mh_reason_for_referral_options_under_11) + } else { + context.resources.getStringArray(R.array.mh_reason_for_referral_options) + } + mhReasonForReferral.entries = options + if (mhReasonForReferral.value != null && !options.contains(mhReasonForReferral.value)) { + mhReasonForReferral.value = null + } + } + + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + emotionalBehaviouralConcerns.id -> { + rebuildConditionalSections() + formId + } + + substanceUseConcerns.id -> { + if (!isYes(substanceUseConcerns.value)) { + clearSubstanceValues() + } + rebuildConditionalSections() + formId + } + + selfHarmSuicideThoughts.id -> { + if (!isYes(selfHarmSuicideThoughts.value)) { + clearSuicideValues() + } + rebuildConditionalSections() + formId + } + + memoryLossConfusion.id -> { + if (!isYes(memoryLossConfusion.value)) { + clearDementiaValues() + if (!isYes(seizuresFitsLoc.value)) clearEdChecklistValues() + } + rebuildConditionalSections() + formId + } + + seizuresFitsLoc.id -> { + if (!isYes(seizuresFitsLoc.value)) { + clearEpilepsyValues() + if (!isYes(memoryLossConfusion.value)) clearEdChecklistValues() + } + rebuildConditionalSections() + formId + } + isPostpartum.id -> { + rebuildConditionalSections() + formId + } + mhReferralRequired.id -> { + if (mhReferralRequired.value != yesNoOptions[0]) { + mhReferralLevel.value = null + mhReasonForReferral.value = null + mhReferralDate.value = null + } + rebuildConditionalSections() + formId + } + + mhFollowUpRequired.id -> { + if (mhFollowUpRequired.value != yesNoOptions[0]) { + mhFollowUpDate.value = null + mhImprovementNoted.value = null + mhReferralEscalation.value = null + mhCaseClosureReason.value = null + } + rebuildConditionalSections() + formId + } + + substanceTobaccoUse.id -> { + rebuildConditionalSections() + formId + } + + substanceCurrentTobaccoUse.id, substanceTobaccoType.id, + substanceTobaccoFrequency.id -> { + rebuildConditionalSections() + formId + } + + substanceAlcoholUse.id, substance_alcohol_loss.id, + substanceAlcoholImpact.id, substanceAlcoholWithdrawal.id, + substanceAlcoholProblematic.id, substance_alcohol_frequency.id -> { + rebuildConditionalSections() + formId + } + + phq9LittleInterest.id, phq9FeelingDown.id, phq9SleepTrouble.id, + phq9FeelingTired.id, phq9Appetite.id, phq9FeelingBad.id, + phq9Concentration.id, phq9MovingSlowly.id, phq9SelfHarmThoughts.id -> { + rebuildConditionalSections() + formId + } + + suicidePreviousAttempt.id, suicidePlan.id, + suicideCurrentThoughts.id, suicideHopelessness.id, + suicideImmediateAssess.id -> { + rebuildConditionalSections() + formId + } + + edRecurrentEpisodeloss.id, edRecurrentJerkyMovements.id, edConfusionordrowsiness.id, edProgressiveMemoryLoss.id, + edConfusionDisorientation.id, edFunctionalDecline.id -> { + rebuildConditionalSections() + formId + } + edPsychosocialIntervention.id -> { + rebuildConditionalSections() + formId + } + + edDurationMinutes.id -> { + validateIntMinMax(edDurationMinutes) + } + + edSessionDate.id, edRemarks.id -> { + formId + } + + else -> { + -1 + } + } + } + + + private fun buildFormElementList(): MutableList { + val list = mutableListOf() + updateEdDerivedFields() + applyAutoReferralRules() + updateReferralRequiredOptions() + updateReferralLevelOptions() + updateReferralReasonOptions() + + // Initial screening questions + list.add(emotionalBehaviouralConcerns) + list.add(substanceUseConcerns) + list.add(selfHarmSuicideThoughts) + list.add(memoryLossConfusion) + list.add(seizuresFitsLoc) + if (genderID == 2 && age in 15..49) { + list.add(isPostpartum) + } + + + // PHQ-9 section + if (shouldShowPhq9()) { + list.addAll(listOf( + phq9Header, phq9LittleInterest, phq9FeelingDown, phq9SleepTrouble, + phq9FeelingTired, phq9Appetite, phq9FeelingBad, phq9Concentration, + phq9MovingSlowly, phq9SelfHarmThoughts, + phq9TotalScore.copy(), phq9DepressionSeverity.copy(), phq9SystemAction.copy() + )) + } else { + clearPhq9Values() + } + + // Substance use section + if (isYes(substanceUseConcerns.value)) { + list.addAll(listOf( + substanceHeader, substanceTobaccoHeader, substanceCurrentTobaccoUse + )) + if (isYes(substanceCurrentTobaccoUse.value)) { + list.add(substanceTobaccoType) + list.add(substanceTobaccoFrequency) + // Reveal the system action only after both tobacco type and frequency are selected. + if (substanceTobaccoType.value != null && substanceTobaccoFrequency.value != null) { + list.add(substanceSystemAction.copy()) + } + } + list.addAll(listOf(substanceAlcoholHeader, substanceAlcoholUse)) + if (isYes(substanceAlcoholUse.value)) { + list.add(substance_alcohol_frequency) + list.add(substance_alcohol_loss) + list.add(substanceAlcoholImpact) + list.add(substanceAlcoholWithdrawal) + list.add(substanceAlcoholProblematic) + val allAlcoholInputsFilled = + substance_alcohol_frequency.value != null && + substance_alcohol_loss.value != null && + substanceAlcoholImpact.value != null && + substanceAlcoholWithdrawal.value != null && + substanceAlcoholProblematic.value != null + if (allAlcoholInputsFilled) { + list.add(substanceAlcoholClassification.copy()) + list.add(substanceAlcoholSystemAction) + } + } else { + clearAlcoholSubFields() + list.add(substanceAlcoholProblematic) + if (substanceAlcoholProblematic.value != null) { + list.add(substanceAlcoholClassification.copy()) + // Hide the system action when there is no alcohol use and no problematic use reported. + val hideAlcoholSystemAction = + isNo(substanceAlcoholUse.value) && isNo(substanceAlcoholProblematic.value) + if (!hideAlcoholSystemAction) { + list.add(substanceAlcoholSystemAction) + } + } + } + } + + // Suicide risk section + if (isYes(selfHarmSuicideThoughts.value)) { + list.addAll(listOf( + suicideHeader, suicidePreviousAttempt, suicidePlan, + suicideHopelessness, suicideImmediateAssess, suicideRiskLevel.copy() + )) + } + + // Epilepsy & Dementia checklist + if (isYes(memoryLossConfusion.value) || isYes(seizuresFitsLoc.value)) { + list.addAll( + listOf( + edChecklistHeader, + edRecurrentEpisodeloss, + edRecurrentJerkyMovements, + edConfusionordrowsiness, + edProgressiveMemoryLoss, + edConfusionDisorientation, + edFunctionalDecline, + edAge60Years, + edScreeningOutcome.copy() + ) + ) + } + + // Psychosocial intervention + val lockPsychosocialToNo = isYes(substanceUseConcerns.value) && + isNo(substanceAlcoholUse.value) && isNo(substanceAlcoholProblematic.value) + if (lockPsychosocialToNo) { + edPsychosocialIntervention.value = yesNoOptions[1] + edPsychosocialIntervention.isEnabled = false + list.add(edPsychosocialIntervention) + clearEdPsychosocialDependants() + } else if (list.contains(substanceAlcoholSystemAction) && + isBriefIntervention(substanceAlcoholSystemAction.value)) { + edPsychosocialIntervention.isEnabled = true + list.add(edPsychosocialIntervention) + if (isYes(edPsychosocialIntervention.value)) { + list.add(edInterventionType) + edSessionDate.max = System.currentTimeMillis() + list.add(edSessionDate) + list.add(edDurationMinutes) + list.add(edRemarks) + } else { + clearEdPsychosocialDependants() + } + } else { + edPsychosocialIntervention.isEnabled = true + clearEdPsychosocialValues() + } + + if (shouldShowReferralRequiredField()) { + if (mhReferralRequired.value == null && shouldAutoSuggestReferral()) { + mhReferralRequired.value = yesNoOptions[0] + } + list.add(mhReferralRequired) + if (mhReferralRequired.value == yesNoOptions[0]) { + list.add(mhReferralLevel) + list.add(mhReasonForReferral) + if (mhReferralDate.value.isNullOrEmpty()) { + mhReferralDate.value = todayDateString() + } + list.add(mhReferralDate) + + mhFollowUpDate.min = System.currentTimeMillis() + mhFollowUpDate.max = System.currentTimeMillis() + java.util.concurrent.TimeUnit.DAYS.toMillis(365) + list.add(mhFollowUpDate) + list.add(mhImprovementNoted) + list.add(mhAdherenceToAdvice) + list.add(mhReferralEscalation) + } else { + mhFollowUpRequired.value = null + mhFollowUpDate.value = null + mhImprovementNoted.value = null + mhAdherenceToAdvice.value = null + mhReferralEscalation.value = null + mhCaseClosureReason.value = null + } + } else { + mhReferralRequired.value = null + mhReferralLevel.value = null + mhReasonForReferral.value = null + mhReferralDate.value = null + + mhFollowUpRequired.value = null + mhFollowUpDate.value = null + mhImprovementNoted.value = null + mhAdherenceToAdvice.value = null + mhReferralEscalation.value = null + mhCaseClosureReason.value = null + } +// +// // Follow-up & Closure +// list.add(mhFollowUpRequired) +// if (mhFollowUpRequired.value == yesNoOptions[0]) { +// mhFollowUpDate.min = System.currentTimeMillis() +// mhFollowUpDate.max = System.currentTimeMillis() + java.util.concurrent.TimeUnit.DAYS.toMillis(365) +// list.add(mhFollowUpDate) +// } +// list.add(mhImprovementNoted) +// if (shouldShowPhq9()) { +// list.add(mhRepeatPhq9Header) +// } +// list.add(mhReferralEscalation) +// list.add(mhCaseClosureReason) + + + return list + } + + + private suspend fun rebuildConditionalSections() { + + updatePhq9Outcome() + updateTobaccoOutcome() + computeSuicideRiskLevel() + computeAlcoholClassification() + computeEdScreeningOutcome() + + setUpPage(buildFormElementList()) + } + + + private suspend fun updatePhq9Outcome() { + val score = listOfNotNull( + extractPhq9Score(phq9LittleInterest.value), + extractPhq9Score(phq9FeelingDown.value), + extractPhq9Score(phq9SleepTrouble.value), + extractPhq9Score(phq9FeelingTired.value), + extractPhq9Score(phq9Appetite.value), + extractPhq9Score(phq9FeelingBad.value), + extractPhq9Score(phq9Concentration.value), + extractPhq9Score(phq9MovingSlowly.value), + extractPhq9Score(phq9SelfHarmThoughts.value) + ).sum() + + // Alert Logic + val currentLevel = when { + score >= 20 -> 3 + score >= 15 -> 2 + score >= 10 -> 1 + else -> 0 + } + + if (currentLevel > 0 && currentLevel != lastPhq9AlertLevel) { + val alertMsg = when (currentLevel) { + 3 -> R.string.phq9_alert_emergency + 2 -> R.string.phq9_alert_urgent + else -> R.string.phq9_alert_referral_mo_phc + } + _phq9AlertMessageFlow.value = resources.getText(alertMsg) + } + lastPhq9AlertLevel = currentLevel + + phq9TotalScore.value = score.toString() + phq9DepressionSeverity.value = when (score) { + in 0..4 -> phq9SeverityOptions.getOrNull(0) + in 5..9 -> phq9SeverityOptions.getOrNull(1) + in 10..14 -> phq9SeverityOptions.getOrNull(2) + in 15..19 -> phq9SeverityOptions.getOrNull(3) + else -> phq9SeverityOptions.getOrNull(4) + } + phq9SystemAction.value = when (score) { + in 0..9 -> phq9SystemActionOptions.getOrNull(0) + in 10..14 -> phq9SystemActionOptions.getOrNull(1) + in 15..19 -> phq9SystemActionOptions.getOrNull(2) + else -> phq9SystemActionOptions.getOrNull(3) + } + phq9SystemAction.errorText = when { + score >= 20 -> context.getString(R.string.phq9_alert_emergency) + score >= 15 -> context.getString(R.string.phq9_alert_urgent) + score >= 10 -> context.getString(R.string.phq9_alert_referral_mo_phc) + else -> null + } + + } + + private fun updateTobaccoOutcome() { + if (!isYes(substanceCurrentTobaccoUse.value)) { + substanceTobaccoType.value = null + substanceTobaccoFrequency.value = null + } + val currentUse = substanceCurrentTobaccoUse.value + + substanceTobaccoOutcome.value = when { + currentUse == yesNoOptions[0] -> tobaccoOutcomeOptions.getOrNull(0) + currentUse == yesNoOptions[1] -> tobaccoOutcomeOptions.getOrNull(1) + else -> null + } + + substanceSystemAction.value = when (substanceTobaccoOutcome.value) { + tobaccoOutcomeOptions.getOrNull(0) -> tobaccoSystemActionOptions.getOrNull(0) + tobaccoOutcomeOptions.getOrNull(1) -> tobaccoSystemActionOptions.getOrNull(1) + else -> null + } + } + + private fun computeAlcoholClassification() { + val use = substanceAlcoholUse.value + val frequency = substance_alcohol_frequency.value + val loss = substance_alcohol_loss.value + val impact = substanceAlcoholImpact.value + val withdrawal = substanceAlcoholWithdrawal.value + val problematic = substanceAlcoholProblematic.value + + if (use == null && frequency == null && loss == null && impact == null && withdrawal == null && problematic == null) { + substanceAlcoholClassification.value = null + substanceAlcoholSystemAction.value = null + return + } + + val hasRiskFactor = isYes(loss) || + isYes(impact) || + isYes(withdrawal) || + isYes(problematic) || + frequency == substanceFrequencyOptions.getOrNull(1) || // Regular + frequency == substanceFrequencyOptions.getOrNull(2) // Daily + + val noAlcoholUse = isNo(use) + val occasionalWithNoRisks = (frequency == substanceFrequencyOptions.getOrNull(0)) && // Occasionally + !isYes(loss) && + !isYes(impact) && + !isYes(withdrawal) + + + if (noAlcoholUse) { + if (isYes(problematic)) { + substanceAlcoholClassification.value = alcoholClassificationOptions.getOrNull(1) + substanceAlcoholSystemAction.value = alcoholSystemActionOptions.getOrNull(0) + } else { + substanceAlcoholClassification.value = alcoholClassificationOptions.getOrNull(0) + substanceAlcoholSystemAction.value = null + } + return + } + + substanceAlcoholClassification.value = when { + hasRiskFactor -> alcoholClassificationOptions.getOrNull(1) + occasionalWithNoRisks -> alcoholClassificationOptions.getOrNull(0) + else -> null + } + + // System action is auto-filled from the classification: + // "No problematic alcohol use identified" -> "Brief intervention" + // "Problematic alcohol use suspected" -> "Referral" + substanceAlcoholSystemAction.value = when (substanceAlcoholClassification.value) { + alcoholClassificationOptions.getOrNull(0) -> alcoholSystemActionOptions.getOrNull(0) + alcoholClassificationOptions.getOrNull(1) -> alcoholSystemActionOptions.getOrNull(1) + else -> null + } + } + + private fun computeEdScreeningOutcome() { + if (!isYes(memoryLossConfusion.value) && !isYes(seizuresFitsLoc.value)) { + edScreeningOutcome.value = null + edReferralRequired.value = null + edReason.value = null + return + } + + val suspectedEpilepsy = + isYes(edRecurrentJerkyMovements.value) || isYes(edRecurrentEpisodeloss.value) + + val age60OrMore = (age ?: 0) >= 60 + val memoryOrFunctionalSelected = + isYes(edProgressiveMemoryLoss.value) || isYes(edFunctionalDecline.value) + val suspectedDementia = age60OrMore && memoryOrFunctionalSelected + val anyChecklistSelected = + isYes(edRecurrentEpisodeloss.value) || + isYes(edRecurrentJerkyMovements.value) || + isYes(edConfusionordrowsiness.value) || + isYes(edProgressiveMemoryLoss.value) || + isYes(edConfusionDisorientation.value) || + isYes(edFunctionalDecline.value) + + edScreeningOutcome.value = when { + suspectedEpilepsy -> mhEdOutcomeOptions.getOrNull(0) + suspectedDementia -> mhEdOutcomeOptions.getOrNull(1) + anyChecklistSelected -> mhEdOutcomeOptions.getOrNull(2) + else -> mhEdOutcomeOptions.getOrNull(3) + } + + val suspectedLabelPrefix = mhSuspected + val isSuspected = edScreeningOutcome.value?.contains(suspectedLabelPrefix) == true + edReferralRequired.value = if (isSuspected) yesNoOptions[0] else yesNoOptions[1] + edReason.value = if (isSuspected) mhEdReasonNeurological else null + } + private suspend fun computeSuicideRiskLevel() { + val thoughtsSelfHarm = selfHarmSuicideThoughts.value + val previousAttempt = suicidePreviousAttempt.value + val currentIntentPlan = suicidePlan.value + val accessToMeans = suicideHopelessness.value + val choImmediateRisk = suicideImmediateAssess.value + + val fields = listOf( + thoughtsSelfHarm, + previousAttempt, + currentIntentPlan, + accessToMeans, + choImmediateRisk + ) + + if (fields.all { it == null }) { + suicideRiskLevel.value = null + suicideRiskLevel.hasAlertError = false + suicideRiskLevel.errorText = null + return + } + + val low = suicideRiskOptions.getOrNull(0) + val moderate = suicideRiskOptions.getOrNull(1) + val high = suicideRiskOptions.getOrNull(2) + + val isThoughtsYes = isYes(thoughtsSelfHarm) + val isThoughtsNo = isNo(thoughtsSelfHarm) + val isPreviousYes = isYes(previousAttempt) + val isPreviousNo = isNo(previousAttempt) + val isIntentYes = isYes(currentIntentPlan) + val isIntentNo = isNo(currentIntentPlan) + val isMeansNo = isNo(accessToMeans) + val isImmediateYes = isYes(choImmediateRisk) + val isImmediateNo = isNo(choImmediateRisk) + + suicideRiskLevel.value = when { + + isImmediateYes -> high + + isThoughtsYes && isIntentYes -> high + + isThoughtsYes && isPreviousYes && isIntentNo && isMeansNo && isImmediateNo -> moderate + + isThoughtsYes && isPreviousNo && isIntentNo && isMeansNo && isImmediateNo -> low + + isThoughtsNo && isPreviousNo && isIntentNo && isMeansNo && isImmediateNo -> low + + else -> low + } + + val riskValue = suicideRiskLevel.value ?: "" + + when (riskValue) { + high -> { + suicideRiskLevel.hasAlertError = true + suicideRiskLevel.errorText = context.getString(R.string.suicide_risk_alert_high) + if (lastSuicideRiskLevel != high) { + emitAlertErrorMessage(R.string.suicide_risk_alert_high) + } + } + moderate -> { + suicideRiskLevel.hasAlertError = true + suicideRiskLevel.errorText = context.getString(R.string.suicide_risk_alert_moderate) + if (lastSuicideRiskLevel != moderate) { + emitAlertErrorMessage(R.string.suicide_risk_alert_moderate) + } + } + else -> { + suicideRiskLevel.hasAlertError = false + suicideRiskLevel.errorText = null + } + } + lastSuicideRiskLevel = riskValue + } + + private fun clearPhq9Values() { + phq9LittleInterest.value = null + phq9FeelingDown.value = null + phq9SleepTrouble.value = null + phq9FeelingTired.value = null + phq9Appetite.value = null + phq9FeelingBad.value = null + phq9Concentration.value = null + phq9MovingSlowly.value = null + phq9SelfHarmThoughts.value = null + phq9TotalScore.value = null + phq9DepressionSeverity.value = null + phq9SystemAction.value = null + } + + private fun clearSubstanceValues() { + substanceCurrentTobaccoUse.value = null + substanceTobaccoType.value = null + substanceTobaccoFrequency.value = null + substanceTobaccoOutcome.value = null + substanceSystemAction.value = null + substanceAlcoholUse.value = null + substanceTobaccoUse.value = null + clearAlcoholSubFields() + substanceAlcoholSystemAction.value = null + briefInterventionGiven.value = null + } + private fun clearEdPsychosocialValues() { + edPsychosocialIntervention.value = null + clearEdPsychosocialDependants() + } + + private fun clearEdPsychosocialDependants() { + edInterventionType.value = null + edSessionDate.value = null + edDurationMinutes.value = null + edRemarks.value = null + } + + private fun clearAlcoholSubFields() { + substance_alcohol_frequency.value = null + substance_alcohol_loss.value = null + substanceAlcoholImpact.value = null + substanceAlcoholWithdrawal.value = null + } + + private fun clearSuicideValues() { + suicideCurrentThoughts.value = null + suicidePlan.value = null + suicidePreviousAttempt.value = null + suicideHopelessness.value = null + suicideImmediateAssess.value =null + suicideRiskLevel.value = null + + } + + private fun clearDementiaValues() { + dementiaProgressiveMemoryLoss.value = null + dementiaForgettingRecent.value = null + dementiaDisorientation.value = null + dementiaDailyActivities.value = null + dementiaBehaviouralChanges.value = null + } + + private fun clearEpilepsyValues() { + epilepsyRecurrentSeizures.value = null + epilepsyJerkyMovements.value = null + epilepsyTongueBite.value = null + epilepsyConfusionAfter.value = null + epilepsyLocDuration.value = null + } + + private fun clearEdChecklistValues() { + edRecurrentEpisodeloss.value = null + edRecurrentJerkyMovements.value = null + edConfusionordrowsiness.value = null + edProgressiveMemoryLoss.value = null + edConfusionDisorientation.value = null + edFunctionalDecline.value = null + edAge60Years.value = null + edScreeningOutcome.value = null + edReferralRequired.value = null + edReason.value = null + } + + private fun updateEdDerivedFields() { + edAge60Years.value = if ((age ?: 0) >= 60) yesNoOptions[0] else yesNoOptions[1] + } + + + // ── Populate from Cache ────────────────────────────────────────── + + private fun populateFromCache(cache: MentalHealthScreeningCache) { + // Initial screening questions + emotionalBehaviouralConcerns.value = boolToYesNo(cache.emotionalBehaviouralConcerns) + substanceUseConcerns.value = boolToYesNo(cache.substanceUseConcerns) + selfHarmSuicideThoughts.value = boolToYesNo(cache.selfHarmSuicideThoughts) + memoryLossConfusion.value = boolToYesNo(cache.memoryLossConfusion) + seizuresFitsLoc.value = boolToYesNo(cache.seizuresFitsLoc) + isPostpartum.value = boolToYesNo(cache.isPostpartum) + // Referral + mhReferralRequired.value = + cache.referralRequired?.let { if (it) yesNoOptions[0] else yesNoOptions[1] } + mhReferralLevel.value = cache.referralLevel + mhReasonForReferral.value = cache.reasonForReferral + + if (cache.referralRequired == true) { + mhReferralDate.value = cache.referralDate ?: todayDateString() + } else { + mhReferralDate.value = null + } + + // Follow-up & Closure + mhFollowUpRequired.value = + cache.followUpRequired?.let { if (it) yesNoOptions[0] else yesNoOptions[1] } + mhFollowUpDate.value = cache.followUpDate + mhImprovementNoted.value = cache.improvementNoted + mhAdherenceToAdvice.value = cache.adherenceToAdvice + mhReferralEscalation.value = + cache.referralEscalationRequired?.let { if (it) yesNoOptions[0] else yesNoOptions[1] } + mhCaseClosureReason.value = cache.caseClosureReason + + // PHQ-9 + phq9LittleInterest.value = cache.phq9LittleInterest?.let { phq9Options.getOrNull(it) } + phq9FeelingDown.value = cache.phq9FeelingDown?.let { phq9Options.getOrNull(it) } + phq9SleepTrouble.value = cache.phq9SleepTrouble?.let { phq9Options.getOrNull(it) } + phq9FeelingTired.value = cache.phq9FeelingTired?.let { phq9Options.getOrNull(it) } + phq9Appetite.value = cache.phq9Appetite?.let { phq9Options.getOrNull(it) } + phq9FeelingBad.value = cache.phq9FeelingBad?.let { phq9Options.getOrNull(it) } + phq9Concentration.value = cache.phq9Concentration?.let { phq9Options.getOrNull(it) } + phq9MovingSlowly.value = cache.phq9MovingSlowly?.let { phq9Options.getOrNull(it) } + phq9SelfHarmThoughts.value = cache.phq9SelfHarmThoughts?.let { phq9Options.getOrNull(it) } + phq9TotalScore.value = cache.phq9TotalScore?.toString() + phq9DepressionSeverity.value = cache.phq9DepressionSeverity + phq9SystemAction.value = cache.phq9SystemAction + + // Substance Use + substanceCurrentTobaccoUse.value = boolToYesNo(cache.substanceCurrentTobaccoUse) + substanceTobaccoType.value = cache.substanceTobaccoType + substanceTobaccoFrequency.value = cache.substanceTobaccoFrequency + substanceTobaccoOutcome.value = cache.substanceTobaccoOutcome + substanceSystemAction.value = cache.substanceSystemAction + substanceAlcoholUse.value = boolToYesNo(cache.substanceAlcoholUse) + substanceTobaccoUse.value = boolToYesNo(cache.substanceTobaccoUse) + substance_alcohol_loss.value = boolToYesNo(cache.substance_alcohol_loss) + substanceAlcoholImpact.value = boolToYesNo(cache.substanceAlcoholImpact) + substanceAlcoholWithdrawal.value = boolToYesNo(cache.substanceAlcoholWithdrawal) + substanceAlcoholProblematic.value = boolToYesNo(cache.substanceAlcoholProblematic) + substanceAlcoholClassification.value = cache.substanceAlcoholClassification + substanceAlcoholSystemAction.value = cache.substanceAlcoholSystemAction + substanceOtherSpecify.value = cache.substanceOtherSpecify + substance_alcohol_frequency.value = cache.substance_alcohol_frequency + + // Suicide Risk + suicideCurrentThoughts.value = boolToYesNo(cache.suicideCurrentThoughts) + suicidePlan.value = boolToYesNo(cache.suicidePlan) + suicidePreviousAttempt.value = boolToYesNo(cache.suicidePreviousAttempt) + suicideHopelessness.value = boolToYesNo(cache.suicideHopelessness) + suicideImmediateAssess.value = boolToYesNo(cache.suicideImmediateAssess) + suicideRiskLevel.value = cache.suicideRiskLevel + + // Dementia + dementiaProgressiveMemoryLoss.value = boolToYesNo(cache.dementiaProgressiveMemoryLoss) + dementiaForgettingRecent.value = boolToYesNo(cache.dementiaForgettingRecent) + dementiaDisorientation.value = boolToYesNo(cache.dementiaDisorientation) + dementiaDailyActivities.value = boolToYesNo(cache.dementiaDailyActivities) + dementiaBehaviouralChanges.value = boolToYesNo(cache.dementiaBehaviouralChanges) + + // Epilepsy + epilepsyRecurrentSeizures.value = boolToYesNo(cache.epilepsyRecurrentSeizures) + epilepsyJerkyMovements.value = boolToYesNo(cache.epilepsyJerkyMovements) + epilepsyTongueBite.value = boolToYesNo(cache.epilepsyTongueBite) + epilepsyConfusionAfter.value = boolToYesNo(cache.epilepsyConfusionAfter) + epilepsyLocDuration.value = cache.epilepsyLocDuration + + // Epilepsy & Dementia Checklist + edRecurrentEpisodeloss.value = boolToChecked(cache.edRecurrentEpisodeloss) + edRecurrentJerkyMovements.value = boolToChecked(cache.edRecurrentJerkyMovements) + edConfusionordrowsiness.value = boolToChecked(cache.edConfusionordrowsiness) + edProgressiveMemoryLoss.value = boolToChecked(cache.edProgressiveMemoryLoss) + edConfusionDisorientation.value = boolToChecked(cache.edConfusionDisorientation) + edFunctionalDecline.value = boolToChecked(cache.edFunctionalDecline) + edScreeningOutcome.value = cache.edScreeningOutcome + edReferralRequired.value = cache.edReferralRequired + edReason.value = cache.edReason + edPsychosocialIntervention.value = boolToYesNo(cache.edPsychosocialInterventionProvided) + edInterventionType.value = cache.edInterventionType + edSessionDate.value = cache.edSessionDate + edDurationMinutes.value = cache.edDurationMinutes?.toString() + edRemarks.value = cache.edRemarks + + + } + + // ── Map Values ─────────────────────────────────────────────────── + + private fun extractPhq9Score(value: String?): Int? { + return value?.firstOrNull()?.digitToIntOrNull() + } + + private fun isYes(value: String?): Boolean { + return value == yesNoOptions[0] + } + private fun isBriefIntervention(value: String?): Boolean { + return value == alcoholSystemActionOptions.getOrNull(0) + } + + private fun isNo(value: String?): Boolean = + value == yesNoOptions.getOrNull(1) + + private fun yesNoToBoolean(value: String?): Boolean? = + when { + value == yesNoOptions.getOrNull(0) -> true + value == yesNoOptions.getOrNull(1) -> false + else -> null + } + + private fun boolToYesNo(value: Boolean?): String? = + value?.let { if (it) yesNoOptions[0] else yesNoOptions[1] } + + private fun boolToChecked(value: Boolean?): String? = + value?.let { if (it) yesNoOptions[0] else null } + + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as MentalHealthScreeningCache).let { + // Initial screening + it.emotionalBehaviouralConcerns = yesNoToBoolean(emotionalBehaviouralConcerns.value) + it.substanceUseConcerns = yesNoToBoolean(substanceUseConcerns.value) + it.selfHarmSuicideThoughts = yesNoToBoolean(selfHarmSuicideThoughts.value) + it.memoryLossConfusion = yesNoToBoolean(memoryLossConfusion.value) + it.seizuresFitsLoc = yesNoToBoolean(seizuresFitsLoc.value) + it.isPostpartum = yesNoToBoolean(isPostpartum.value) + + // PHQ-9 + if (shouldShowPhq9()) { + it.phq9LittleInterest = extractPhq9Score(phq9LittleInterest.value) + it.phq9FeelingDown = extractPhq9Score(phq9FeelingDown.value) + it.phq9SleepTrouble = extractPhq9Score(phq9SleepTrouble.value) + it.phq9FeelingTired = extractPhq9Score(phq9FeelingTired.value) + it.phq9Appetite = extractPhq9Score(phq9Appetite.value) + it.phq9FeelingBad = extractPhq9Score(phq9FeelingBad.value) + it.phq9Concentration = extractPhq9Score(phq9Concentration.value) + it.phq9MovingSlowly = extractPhq9Score(phq9MovingSlowly.value) + it.phq9SelfHarmThoughts = extractPhq9Score(phq9SelfHarmThoughts.value) + + // Calculate total PHQ-9 score + it.phq9TotalScore = listOfNotNull( + it.phq9LittleInterest, it.phq9FeelingDown, it.phq9SleepTrouble, + it.phq9FeelingTired, it.phq9Appetite, it.phq9FeelingBad, + it.phq9Concentration, it.phq9MovingSlowly, it.phq9SelfHarmThoughts + ).sum() + it.phq9DepressionSeverity = phq9DepressionSeverity.value + it.phq9SystemAction = phq9SystemAction.value + } else { + it.phq9LittleInterest = null + it.phq9FeelingDown = null + it.phq9SleepTrouble = null + it.phq9FeelingTired = null + it.phq9Appetite = null + it.phq9FeelingBad = null + it.phq9Concentration = null + it.phq9MovingSlowly = null + it.phq9SelfHarmThoughts = null + it.phq9TotalScore = null + } + + + // Substance Use + if (isYes(substanceUseConcerns.value)) { + it.substanceCurrentTobaccoUse = isYes(substanceCurrentTobaccoUse.value) + it.substanceTobaccoType = if (it.substanceCurrentTobaccoUse == true) substanceTobaccoType.value else null + it.substanceTobaccoFrequency = if (it.substanceCurrentTobaccoUse == true) substanceTobaccoFrequency.value else null + it.substanceTobaccoOutcome = substanceTobaccoOutcome.value + it.substanceSystemAction = substanceSystemAction.value + it.substanceAlcoholUse = isYes(substanceAlcoholUse.value) + // substanceTobaccoUse is a legacy hidden field; derive from the visible replacement + it.substanceTobaccoUse = yesNoToBoolean(substanceCurrentTobaccoUse.value) + it.substance_alcohol_loss = isYes(substance_alcohol_loss.value) + it.substanceAlcoholImpact = isYes(substanceAlcoholImpact.value) + it.substanceAlcoholWithdrawal = isYes(substanceAlcoholWithdrawal.value) + it.substanceAlcoholProblematic = isYes(substanceAlcoholProblematic.value) + it.substanceAlcoholClassification = substanceAlcoholClassification.value + it.substanceAlcoholSystemAction = substanceAlcoholSystemAction.value + it.substance_alcohol_frequency = substance_alcohol_frequency.value + // briefInterventionGiven is a legacy hidden field with no visible replacement; omit + it.briefInterventionGiven = null + } else { + it.substanceCurrentTobaccoUse = null + it.substanceTobaccoType = null + it.substanceTobaccoFrequency = null + it.substanceTobaccoOutcome = null + it.substanceSystemAction = null + it.substanceAlcoholUse = null + it.substanceTobaccoUse = null + it.substance_alcohol_loss = null + it.substanceAlcoholImpact = null + it.substanceAlcoholWithdrawal = null + it.substanceAlcoholProblematic = null + it.substanceAlcoholClassification = null + it.substanceAlcoholSystemAction = null + it.substanceOtherSpecify = null + it.substance_alcohol_frequency = null + it.briefInterventionGiven = null + } + + + // Suicide Risk + if (isYes(selfHarmSuicideThoughts.value)) { + it.suicideCurrentThoughts = isYes(suicideCurrentThoughts.value) + it.suicidePlan = isYes(suicidePlan.value) + it.suicidePreviousAttempt = isYes(suicidePreviousAttempt.value) + it.suicideHopelessness = isYes(suicideHopelessness.value) + it.suicideImmediateAssess = isYes(suicideImmediateAssess.value) + it.suicideRiskLevel = suicideRiskLevel.value + } else { + it.suicideCurrentThoughts = null + it.suicidePlan = null + it.suicidePreviousAttempt = null + it.suicideHopelessness = null + it.suicideImmediateAssess = null + it.suicideRiskLevel = null + } + + if (isYes(memoryLossConfusion.value)) { + it.dementiaProgressiveMemoryLoss = isYes(dementiaProgressiveMemoryLoss.value) + it.dementiaForgettingRecent = isYes(dementiaForgettingRecent.value) + it.dementiaDisorientation = isYes(dementiaDisorientation.value) + it.dementiaDailyActivities = isYes(dementiaDailyActivities.value) + it.dementiaBehaviouralChanges = isYes(dementiaBehaviouralChanges.value) + } else { + it.dementiaProgressiveMemoryLoss = null + it.dementiaForgettingRecent = null + it.dementiaDisorientation = null + it.dementiaDailyActivities = null + it.dementiaBehaviouralChanges = null + } + + if (isYes(seizuresFitsLoc.value)) { + it.epilepsyRecurrentSeizures = isYes(epilepsyRecurrentSeizures.value) + it.epilepsyJerkyMovements = isYes(epilepsyJerkyMovements.value) + it.epilepsyTongueBite = isYes(epilepsyTongueBite.value) + it.epilepsyConfusionAfter = isYes(epilepsyConfusionAfter.value) + it.epilepsyLocDuration = epilepsyLocDuration.value + } else { + it.epilepsyRecurrentSeizures = null + it.epilepsyJerkyMovements = null + it.epilepsyTongueBite = null + it.epilepsyConfusionAfter = null + it.epilepsyLocDuration = null + } + + it.edRecurrentEpisodeloss = isYes(edRecurrentEpisodeloss.value) + it.edRecurrentJerkyMovements = isYes(edRecurrentJerkyMovements.value) + it.edConfusionordrowsiness = isYes(edConfusionordrowsiness.value) + it.edProgressiveMemoryLoss = isYes(edProgressiveMemoryLoss.value) + it.edConfusionDisorientation = isYes(edConfusionDisorientation.value) + it.edFunctionalDecline = isYes(edFunctionalDecline.value) + it.edScreeningOutcome = edScreeningOutcome.value + val suspectedEpilepsy = mhEdOutcomeOptions.getOrNull(0) + val suspectedDementia = mhEdOutcomeOptions.getOrNull(1) + val suspected = mhEdOutcomeOptions.getOrNull(2) + val isEdSuspectedOutcome = it.edScreeningOutcome in setOf( + suspected, + suspectedDementia, + suspectedEpilepsy + ) + it.edReferralRequired = + if (isEdSuspectedOutcome) yesNoOptions[0] else edReferralRequired.value + it.edReason = + if (isEdSuspectedOutcome) mhEdReasonNeurological else null + it.edPsychosocialInterventionProvided = yesNoToBoolean(edPsychosocialIntervention.value) + it.edInterventionType = if (it.edPsychosocialInterventionProvided == true) edInterventionType.value else null + it.edSessionDate = if (it.edPsychosocialInterventionProvided == true) edSessionDate.value else null + it.edDurationMinutes = if (it.edPsychosocialInterventionProvided == true) edDurationMinutes.value?.toIntOrNull() else null + it.edRemarks = edRemarks.value + it.referralRequired = if (shouldForceAutoReferralRequired()) { + true + } else { + yesNoToBoolean(mhReferralRequired.value) + } + if (it.referralRequired == true) { + it.referralLevel = mhReferralLevel.value + it.reasonForReferral = mhReasonForReferral.value + it.referralDate = mhReferralDate.value ?: todayDateString() + } else { + it.referralLevel = null + it.reasonForReferral = null + it.referralDate = null + } + + it.followUpRequired = if (it.referralRequired == true) true else null + if (it.referralRequired == true) { + it.followUpDate = mhFollowUpDate.value + it.improvementNoted = mhImprovementNoted.value + it.adherenceToAdvice = mhAdherenceToAdvice.value + it.referralEscalationRequired = yesNoToBoolean(mhReferralEscalation.value) + it.caseClosureReason = mhCaseClosureReason.value + } else { + it.followUpDate = null + it.improvementNoted = null + it.adherenceToAdvice = null + it.referralEscalationRequired = null + it.caseClosureReason = null + } + } + } +} + + diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/NeonatalOutcomeDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/NeonatalOutcomeDataset.kt new file mode 100644 index 000000000..58a172c6e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/NeonatalOutcomeDataset.kt @@ -0,0 +1,509 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.configuration.FormDataModel +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.NeonatalOutcomeCache + +/** + * Dataset for Neonatal Outcome form per MHWC-200 requirements + * Handles all fields for newborn registration including complications, + * congenital anomalies, birth doses, and current status. + */ +class NeonatalOutcomeDataset( + context: Context, currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + companion object { + private const val WEIGHT_MIN = 500 + private const val WEIGHT_MAX = 6000 + } + + // Q2: Outcome at Birth + private val outcomeAtBirth = FormElement( + id = 1, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_outcome_at_birth), + arrayId = R.array.no_outcome_at_birth_array, + entries = resources.getStringArray(R.array.no_outcome_at_birth_array), + required = true, + hasDependants = true + ) + + // Q3: Sex + private val sex = FormElement( + id = 2, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_sex), + arrayId = R.array.no_sex_array, + entries = resources.getStringArray(R.array.no_sex_array), + required = true, + hasDependants = true + ) + + // Q4: Cried immediately after birth? + private val criedImmediately = FormElement( + id = 3, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_cried_immediately), + arrayId = R.array.no_cried_immediately_array, + entries = resources.getStringArray(R.array.no_cried_immediately_array), + required = true, + hasDependants = true + ) + + // Q5: Type of resuscitation + private val typeOfResuscitation = FormElement( + id = 4, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_type_of_resuscitation), + arrayId = R.array.no_type_of_resuscitation_array, + entries = resources.getStringArray(R.array.no_type_of_resuscitation_array), + required = false, + hasDependants = false + ) + + // Q6: Birth Weight + private val birthWeight = FormElement( + id = 5, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_birth_weight), + required = true, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + etMaxLength = 4, + min = WEIGHT_MIN.toLong(), + max = WEIGHT_MAX.toLong(), + hasDependants = false + ) + + // Q7: Any congenital anomaly detected? + private val congenitalAnomalyDetected = FormElement( + id = 6, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_congenital_anomaly_detected), + arrayId = R.array.no_congenital_anomaly_array, + entries = resources.getStringArray(R.array.no_congenital_anomaly_array), + required = true, + hasDependants = true + ) + + // Q8: Type of congenital anomaly + private val typeOfCongenitalAnomaly = FormElement( + id = 7, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_type_of_congenital_anomaly), + arrayId = R.array.no_type_of_congenital_anomaly_array, + entries = resources.getStringArray(R.array.no_type_of_congenital_anomaly_array), + required = false, + hasDependants = true + ) + + // Q9: Other congenital anomaly + private val otherCongenitalAnomaly = FormElement( + id = 8, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_other_congenital_anomaly), + required = false, + hasDependants = false, + etMaxLength = 300 + ) + + // Q10: Newborn Complications + private val newbornComplications = FormElement( + id = 9, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_newborn_complications), + arrayId = R.array.no_newborn_complications_array, + entries = resources.getStringArray(R.array.no_newborn_complications_array), + required = false, + hasDependants = true + ) + + // Q11: Current Status of Baby + private val currentStatusOfBaby = FormElement( + id = 10, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_current_status_of_baby), + arrayId = R.array.no_current_status_array, + entries = resources.getStringArray(R.array.no_current_status_array), + required = true, + hasDependants = true + ) + + // Q12: Cause of Death + private val causeOfDeath = FormElement( + id = 11, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_cause_of_death), + arrayId = R.array.no_cause_of_death_array, + entries = resources.getStringArray(R.array.no_cause_of_death_array), + required = false, + hasDependants = true + ) + + // Q13: Other cause of death + private val otherCauseOfDeath = FormElement( + id = 12, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_other_cause_of_death), + required = false, + hasDependants = false, + etMaxLength = 300 + ) + + // Q14: Birth dose vaccines given + private val birthDoseVaccinesGiven = FormElement( + id = 13, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.no_birth_dose_vaccines_given), + arrayId = R.array.no_birth_dose_vaccines_array, + entries = resources.getStringArray(R.array.no_birth_dose_vaccines_array), + required = false, + hasDependants = true + ) + + // Q15: Reason for no vaccines + private val reasonForNoVaccines = FormElement( + id = 14, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_reason_for_no_vaccines), + required = false, + hasDependants = false, + etMaxLength = 200 + ) + + // Q16: Vitamin K injection given? + private val vitaminKInjectionGiven = FormElement( + id = 15, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_vitamin_k_injection_given), + entries = arrayOf(resources.getString(R.string.yes), resources.getString(R.string.no)), + required = true, + hasDependants = true + ) + + // Q17: Reason for no Vitamin K + private val reasonForNoVitaminK = FormElement( + id = 16, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.no_reason_for_no_vitamin_k), + required = false, + hasDependants = false, + etMaxLength = 200 + ) + + // Q18: Birth Certificate issued? + private val birthCertificateIssued = FormElement( + id = 17, + inputType = InputType.RADIO, + title = resources.getString(R.string.no_birth_certificate_issued), + entries = resources.getStringArray(R.array.no_birth_certificate_array), + required = true, + hasDependants = false, + hasAlertError = true + ) + + // ─── Helper: restore conditional (dependant) fields into the list ── + private fun restoreConditionalFields( + saved: NeonatalOutcomeCache, + formElements: MutableList + ) { + // saved.* fields are persisted in English; compare against the English array + // so this works regardless of the user's current UI locale. + val englishCried = englishResources.getStringArray(R.array.no_cried_immediately_array) + val englishAnomaly = englishResources.getStringArray(R.array.no_congenital_anomaly_array) + val englishStatus = englishResources.getStringArray(R.array.no_current_status_array) + + if (saved.criedImmediately == englishCried.getOrNull(1) && saved.typeOfResuscitation != null) { + val criedIdx = formElements.indexOf(criedImmediately) + if (criedIdx >= 0) { + formElements.add(criedIdx + 1, typeOfResuscitation) + } + } + if (saved.congenitalAnomalyDetected == englishAnomaly.getOrNull(0)) { // Yes + formElements.add(formElements.indexOf(congenitalAnomalyDetected) + 1, typeOfCongenitalAnomaly) + if (saved.typeOfCongenitalAnomaly?.contains("Other") == true) { + formElements.add(formElements.indexOf(typeOfCongenitalAnomaly) + 1, otherCongenitalAnomaly) + } + } + if (saved.currentStatusOfBaby == englishStatus.getOrNull(3)) { // Died + formElements.add(formElements.indexOf(currentStatusOfBaby) + 1, causeOfDeath) + if (saved.causeOfDeath?.contains("Other") == true) { + formElements.add(formElements.indexOf(causeOfDeath) + 1, otherCauseOfDeath) + } + } + if (saved.birthDoseVaccinesGiven?.contains("None") == true) { + formElements.add(formElements.indexOf(birthDoseVaccinesGiven) + 1, reasonForNoVaccines) + } + if (saved.vitaminKInjectionGiven == false) { + formElements.add(formElements.indexOf(vitaminKInjectionGiven) + 1, reasonForNoVitaminK) + } + } + + suspend fun setUpPage(saved: NeonatalOutcomeCache?) { + val formElements = mutableListOf( + outcomeAtBirth, + sex, + criedImmediately, + birthWeight, + congenitalAnomalyDetected, + newbornComplications, + currentStatusOfBaby, + birthDoseVaccinesGiven, + vitaminKInjectionGiven, + birthCertificateIssued + ) + + saved?.let { + // saved.* fields are stored in English; re-localize for display so the + // user always sees the form in their current UI language regardless of + // which language the data was originally entered in. + outcomeAtBirth.value = getLocalValueInArray(R.array.no_outcome_at_birth_array, saved.outcomeAtBirth) + sex.value = getLocalValueInArray(R.array.no_sex_array, saved.sex) + criedImmediately.value = getLocalValueInArray(R.array.no_cried_immediately_array, saved.criedImmediately) + birthWeight.value = saved.birthWeight?.toString() + congenitalAnomalyDetected.value = getLocalValueInArray(R.array.no_congenital_anomaly_array, saved.congenitalAnomalyDetected) + typeOfCongenitalAnomaly.value = getLocalValuesInArray(R.array.no_type_of_congenital_anomaly_array, saved.typeOfCongenitalAnomaly) + otherCongenitalAnomaly.value = saved.otherCongenitalAnomaly + newbornComplications.value = getLocalValuesInArray(R.array.no_newborn_complications_array, saved.newbornComplications) + currentStatusOfBaby.value = getLocalValueInArray(R.array.no_current_status_array, saved.currentStatusOfBaby) + causeOfDeath.value = getLocalValuesInArray(R.array.no_cause_of_death_array, saved.causeOfDeath) + otherCauseOfDeath.value = saved.otherCauseOfDeath + birthDoseVaccinesGiven.value = getLocalValuesInArray(R.array.no_birth_dose_vaccines_array, saved.birthDoseVaccinesGiven) + reasonForNoVaccines.value = saved.reasonForNoVaccines + vitaminKInjectionGiven.value = when (saved.vitaminKInjectionGiven) { + true -> resources.getString(R.string.yes) + false -> resources.getString(R.string.no) + else -> null + } + reasonForNoVitaminK.value = saved.reasonForNoVitaminK + birthCertificateIssued.value = getLocalValueInArray(R.array.no_birth_certificate_array, saved.birthCertificateIssued) + + restoreConditionalFields(saved, formElements) + } + + setUpPage(formElements) + } + + // ─── Helper: toggle dependant fields based on a condition ────────── + private fun toggleDependant( + source: FormElement, + condition: Boolean, + showItems: List, + hideItems: List = emptyList() + ): Int { + return if (condition) { + triggerDependants(source = source, removeItems = hideItems, addItems = showItems) + } else { + triggerDependants(source = source, removeItems = showItems + hideItems, addItems = emptyList()) + } + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + sex.id -> { + val selectedValue = sex.entries?.getOrNull(index) + sex.value = selectedValue + if (selectedValue == sex.entries?.getOrNull(2)) { + emitAlertErrorMessage(R.string.no_alert_ambiguous_sex) + } + -1 + } + criedImmediately.id -> { + val selectedValue = criedImmediately.entries?.getOrNull(index) + criedImmediately.value = selectedValue + // "Cried after resuscitation" is index 1 — show typeOfResuscitation + toggleDependant( + source = criedImmediately, + condition = selectedValue == resources.getStringArray(R.array.no_cried_immediately_array).getOrNull(1), + showItems = listOf(typeOfResuscitation) + ) + } + congenitalAnomalyDetected.id -> { + val selectedValue = congenitalAnomalyDetected.entries?.getOrNull(index) + congenitalAnomalyDetected.value = selectedValue + toggleDependant( + source = congenitalAnomalyDetected, + condition = selectedValue == resources.getStringArray(R.array.no_congenital_anomaly_array)[0], // Yes + showItems = listOf(typeOfCongenitalAnomaly), + hideItems = listOf(otherCongenitalAnomaly) + ) + } + typeOfCongenitalAnomaly.id -> { + // handle multi-select and "Other" — convert to English first so the + // check works in every locale, not just when UI is English. + val englishValues = getEnglishValuesInArray( + R.array.no_type_of_congenital_anomaly_array, + typeOfCongenitalAnomaly.value + ) + toggleDependant( + source = typeOfCongenitalAnomaly, + condition = englishValues?.contains("Other") == true, + showItems = listOf(otherCongenitalAnomaly) + ) + } + currentStatusOfBaby.id -> { + val selectedValue = currentStatusOfBaby.entries?.getOrNull(index) + currentStatusOfBaby.value = selectedValue + toggleDependant( + source = currentStatusOfBaby, + condition = selectedValue == resources.getStringArray(R.array.no_current_status_array)[3], // Died + showItems = listOf(causeOfDeath), + hideItems = listOf(otherCauseOfDeath) + ) + } + causeOfDeath.id -> { + val englishValues = getEnglishValuesInArray( + R.array.no_cause_of_death_array, + causeOfDeath.value + ) + toggleDependant( + source = causeOfDeath, + condition = englishValues?.contains("Other") == true, + showItems = listOf(otherCauseOfDeath) + ) + } + newbornComplications.id -> { + val realIndex = (if (index < 0) -index else index) - 1 + val localizedEntries = newbornComplications.entries + // Identify "None" by its position in the English array so the + // logic works in every locale (Hindi, Assamese, etc.). + val noneIndex = englishResources + .getStringArray(newbornComplications.arrayId) + .indexOf("None") + val noneLocalized = localizedEntries?.getOrNull(noneIndex) + val clickedOption = localizedEntries?.getOrNull(realIndex) ?: return -1 + val isNoneOption = noneIndex >= 0 && realIndex == noneIndex + val isChecked = index > 0 + + if (isChecked) { + if (isNoneOption) { + // "None" just checked — clear every other selection. + newbornComplications.value = clickedOption + } else { + // A real complication was checked — drop "None" if it was set. + val parts = (newbornComplications.value ?: "") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() && it != noneLocalized } + newbornComplications.value = + if (parts.isEmpty()) null else parts.joinToString(",") + } + forceRefreshId(newbornComplications.id) + } + -1 + } + birthDoseVaccinesGiven.id -> { + val realIndex = (if (index < 0) -index else index) - 1 + val localizedEntries = birthDoseVaccinesGiven.entries + // Locale-neutral "None" identification. + val noneIndex = englishResources + .getStringArray(birthDoseVaccinesGiven.arrayId) + .indexOf("None") + val noneLocalized = localizedEntries?.getOrNull(noneIndex) + val clickedOption = localizedEntries?.getOrNull(realIndex) + val isNoneOption = noneIndex >= 0 && realIndex == noneIndex + val isChecked = index > 0 + + if (clickedOption != null && isChecked) { + if (isNoneOption) { + birthDoseVaccinesGiven.value = clickedOption + } else { + val parts = (birthDoseVaccinesGiven.value ?: "") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() && it != noneLocalized } + birthDoseVaccinesGiven.value = + if (parts.isEmpty()) null else parts.joinToString(",") + } + forceRefreshId(birthDoseVaccinesGiven.id) + } + + val noneIsSelected = noneLocalized != null && (birthDoseVaccinesGiven.value ?: "") + .split(",") + .map { it.trim() } + .any { it == noneLocalized } + toggleDependant( + source = birthDoseVaccinesGiven, + condition = noneIsSelected, + showItems = listOf(reasonForNoVaccines) + ) + } + vitaminKInjectionGiven.id -> { + val selectedValue = vitaminKInjectionGiven.entries?.getOrNull(index) + vitaminKInjectionGiven.value = selectedValue + toggleDependant( + source = vitaminKInjectionGiven, + condition = selectedValue == resources.getString(R.string.no), + showItems = listOf(reasonForNoVitaminK) + ) + } + birthWeight.id -> { + validateIntMinMax(birthWeight) + if (birthWeight.errorText == null) { + birthWeight.value?.toIntOrNull()?.let { weightInGm -> + when { + weightInGm < 1000 -> emitAlertErrorMessage(R.string.no_alert_elbw) + weightInGm < 1500 -> emitAlertErrorMessage(R.string.no_alert_vlbw) + weightInGm < 2500 -> emitAlertErrorMessage(R.string.no_alert_lbw) + weightInGm >= 4000 -> emitAlertErrorMessage(R.string.no_alert_macrosomia) + } + } + } + -1 + } + birthCertificateIssued.id -> { + val selected = birthCertificateIssued.entries?.getOrNull(index) + birthCertificateIssued.value = selected + if (selected == birthCertificateIssued.entries?.getOrNull(2)) { // "No (Not applied)" + emitAlertErrorMessage(R.string.no_alert_birth_certificate_legal) + } + -1 + } + else -> -1 + } + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as NeonatalOutcomeCache).let { form -> + // Persist every dropdown / radio / checkbox value in its English canonical + // form so the DB is locale-neutral. Display-time localization is handled + // in setUpPage by re-converting via getLocalValueInArray. + form.outcomeAtBirth = getEnglishValueInArray(R.array.no_outcome_at_birth_array, outcomeAtBirth.value) + form.outcomeAtBirthId = outcomeAtBirth.getPosition() + form.sex = getEnglishValueInArray(R.array.no_sex_array, sex.value) + form.sexId = sex.getPosition() + form.criedImmediately = getEnglishValueInArray(R.array.no_cried_immediately_array, criedImmediately.value) + form.criedImmediatelyId = criedImmediately.getPosition() + form.typeOfResuscitation = getEnglishValuesInArray(R.array.no_type_of_resuscitation_array, typeOfResuscitation.value) + form.birthWeight = birthWeight.value?.toIntOrNull() + form.congenitalAnomalyDetected = getEnglishValueInArray(R.array.no_congenital_anomaly_array, congenitalAnomalyDetected.value) + form.congenitalAnomalyDetectedId = congenitalAnomalyDetected.getPosition() + form.typeOfCongenitalAnomaly = getEnglishValuesInArray(R.array.no_type_of_congenital_anomaly_array, typeOfCongenitalAnomaly.value) + form.otherCongenitalAnomaly = otherCongenitalAnomaly.value + form.newbornComplications = getEnglishValuesInArray(R.array.no_newborn_complications_array, newbornComplications.value) + form.currentStatusOfBaby = getEnglishValueInArray(R.array.no_current_status_array, currentStatusOfBaby.value) + form.currentStatusOfBabyId = currentStatusOfBaby.getPosition() + form.causeOfDeath = getEnglishValuesInArray(R.array.no_cause_of_death_array, causeOfDeath.value) + form.otherCauseOfDeath = otherCauseOfDeath.value + form.birthDoseVaccinesGiven = getEnglishValuesInArray(R.array.no_birth_dose_vaccines_array, birthDoseVaccinesGiven.value) + form.reasonForNoVaccines = reasonForNoVaccines.value + form.vitaminKInjectionGiven = when (vitaminKInjectionGiven.value) { + resources.getString(R.string.yes) -> true + resources.getString(R.string.no) -> false + else -> null + } + form.reasonForNoVitaminK = reasonForNoVitaminK.value + form.birthCertificateIssued = getEnglishValueInArray(R.array.no_birth_certificate_array, birthCertificateIssued.value) + form.birthCertificateIssuedId = birthCertificateIssued.getPosition() + + // Audit flags read off the just-persisted English values so they don't + // depend on the UI locale at the moment of save. + form.isStillbirth = form.outcomeAtBirth?.contains("Still Birth") == true + form.isNeonatalDeath = form.currentStatusOfBaby == englishResources.getStringArray(R.array.no_current_status_array)[3] + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/NoseDiagnosisDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/NoseDiagnosisDataset.kt new file mode 100644 index 000000000..1419af2a9 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/NoseDiagnosisDataset.kt @@ -0,0 +1,306 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.NoseDiagnosisAssessment +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType + + +class NoseDiagnosisDataset( + private val context: Context, + currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + private lateinit var cache: NoseDiagnosisAssessment + + var onShowAlert: ((String) -> Unit)? = null + + private val optionYes = context.getString(R.string.yes) + private val optionNo = context.getString(R.string.no) + private val foreignBodyOptions = context.resources.getStringArray(R.array.nose_foreign_body_options) + + private val FOREIGN_BODY_ANTERIOR = 0 + private val FOREIGN_BODY_POSTERIOR = 1 + private val FOREIGN_BODY_NONE = 2 + + private fun foreignBodyStringToIndex(value: String?): Int? { + return when (value) { + foreignBodyOptions.getOrNull(FOREIGN_BODY_ANTERIOR) -> FOREIGN_BODY_ANTERIOR + foreignBodyOptions.getOrNull(FOREIGN_BODY_POSTERIOR) -> FOREIGN_BODY_POSTERIOR + foreignBodyOptions.getOrNull(FOREIGN_BODY_NONE) -> FOREIGN_BODY_NONE + else -> null + } + } + + private fun indexToForeignBodyString(index: Int?): String? { + return when (index) { + FOREIGN_BODY_ANTERIOR -> foreignBodyOptions.getOrNull(FOREIGN_BODY_ANTERIOR) + FOREIGN_BODY_POSTERIOR -> foreignBodyOptions.getOrNull(FOREIGN_BODY_POSTERIOR) + FOREIGN_BODY_NONE -> foreignBodyOptions.getOrNull(FOREIGN_BODY_NONE) + else -> null + } + } + + /* -------------------- FORM ELEMENTS -------------------- */ + + private val difficultyBreathing = FormElement( + id = 1, + inputType = InputType.RADIO, + title = context.getString(R.string.difficulty_in_breathing), + entries = arrayOf(optionYes, optionNo), + trueIndex = 0, + falseIndex = 1, + required = true, + hasAlertError = true + ) + + private val openMouthBreathing = FormElement( + id = 2, + inputType = InputType.RADIO, + title = context.getString(R.string.open_mouth_breathing), + entries = arrayOf(optionYes, optionNo), + trueIndex = 0, + falseIndex = 1, + required = false, + hasAlertError = true + ) + private val noseBleed = FormElement( + id = 3, + inputType = InputType.RADIO, + title = context.getString(R.string.nose_bleed_title), + entries = arrayOf(optionYes, optionNo), + required = false, + hasDependants = true + ) + + private val systolicBP = FormElement( + id = 4, + inputType = InputType.EDIT_TEXT, + title = context.getString(R.string.systolic_bp_title), + required = true, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + hasAlertError = true + + ) + + private val diastolicBP = FormElement( + id = 5, + inputType = InputType.EDIT_TEXT, + title = context.getString(R.string.diastolic_bp_title), + required = true, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + hasAlertError = true + + ) + + private val foreignBodyNose = FormElement( + id = 6, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.foreign_body_nose_title), + entries = foreignBodyOptions, + required = false, + hasAlertError = true + + ) + + private val sinusitis = FormElement( + id = 7, + inputType = InputType.RADIO, + title = context.getString(R.string.sinusitis_title), + entries = arrayOf(context.getString(R.string.sinusitis_with_pain), optionNo), + required = false, + hasAlertError = true + ) + + + + suspend fun setUpPage(savedRecord: NoseDiagnosisAssessment?) { + cache = savedRecord ?: createDefaultCache() + populateFromCache(cache) + val list = mutableListOf() + list.add(difficultyBreathing) + list.add(openMouthBreathing) + list.add(noseBleed) + + if (noseBleed.value == optionYes) { + list.add(systolicBP) + list.add(diastolicBP) + } + + list.addAll( + listOf( + foreignBodyNose, + sinusitis + ) + ) + + setUpPage(list) + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + + difficultyBreathing.id -> { + difficultyBreathing.booleanValue = when (index) { + difficultyBreathing.trueIndex -> true + difficultyBreathing.falseIndex -> false + else -> null + } + if (difficultyBreathing.booleanValue == true) { + onShowAlert?.invoke( + context.getString(R.string.difficulty_in_breathing_alert) + ) + } + -1 + } + + openMouthBreathing.id -> { + openMouthBreathing.booleanValue = when (index) { + openMouthBreathing.trueIndex -> true + openMouthBreathing.falseIndex -> false + else -> null + } + if (openMouthBreathing.booleanValue == true) { + onShowAlert?.invoke( + context.getString(R.string.open_mouth_breathing_alert) + ) + } + -1 + } + noseBleed.id -> { + if (index == 0) { + triggerDependants( + source = noseBleed, + addItems = listOf(systolicBP, diastolicBP), + removeItems = emptyList() + ) + } else { + systolicBP.value = null + diastolicBP.value = null + triggerDependants( + source = noseBleed, + addItems = emptyList(), + removeItems = listOf(systolicBP, diastolicBP) + ) + } + noseBleed.id + } + + systolicBP.id -> { + systolicBP.value?.toIntOrNull()?.let { + if (it > 120) { + onShowAlert?.invoke( + context.getString(R.string.nose_bleed_alert_systolic_bp) + ) + } + } + -1 + } + + diastolicBP.id -> { + diastolicBP.value?.toIntOrNull()?.let { + if (it > 80) { + onShowAlert?.invoke( + context.getString(R.string.nose_bleed_alert_diastolic_bp) + ) + } + } + -1 + } + + foreignBodyNose.id -> { + if (index == FOREIGN_BODY_POSTERIOR) { + onShowAlert?.invoke( + context.getString(R.string.foreign_body_posterior_alert) + ) + } + -1 + } + + sinusitis.id -> { + if (index == 0) { + onShowAlert?.invoke( + context.getString(R.string.sinusitis_alert) + ) + } + -1 + } + else -> -1 + } + } + + + private fun createDefaultCache(): NoseDiagnosisAssessment { + return NoseDiagnosisAssessment( + patientId = "", + benVisitNo = null + ) + } + + private fun populateFromCache(cache: NoseDiagnosisAssessment) { + difficultyBreathing.booleanValue = cache.difficultyBreathing + difficultyBreathing.value = when (cache.difficultyBreathing) { + true -> optionYes + false -> optionNo + else -> null + } + + openMouthBreathing.booleanValue = cache.openMouthBreathing + openMouthBreathing.value = when (cache.openMouthBreathing) { + true -> optionYes + false -> optionNo + else -> null + } + noseBleed.value = when (cache.noseBleed) { + true -> optionYes + false -> optionNo + else -> null + } + + systolicBP.value = cache.systolicBp?.toString() + diastolicBP.value = cache.diastolicBp?.toString() + + + // Convert stored index to display string for UI + val foreignBodyIndex = cache.foreignBodyNose?.toIntOrNull() + foreignBodyNose.value = indexToForeignBodyString(foreignBodyIndex) + + sinusitis.value = when (cache.sinusitis) { + true -> context.getString(R.string.sinusitis_with_pain) + false -> optionNo + else -> null + } + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as NoseDiagnosisAssessment).let { + it.difficultyBreathing = difficultyBreathing.booleanValue + it.openMouthBreathing = openMouthBreathing.booleanValue + val hasNoseBleed: Boolean? = when (noseBleed.value) { + optionYes -> true + optionNo -> false + else -> null + } + + it.noseBleed = hasNoseBleed + + it.systolicBp = if (hasNoseBleed == true) { + systolicBP.value?.toIntOrNull() + } else null + + it.diastolicBp = if (hasNoseBleed == true) { + diastolicBP.value?.toIntOrNull() + } else null + // Store stable index as string instead of localized display text + it.foreignBodyNose = foreignBodyStringToIndex(foreignBodyNose.value)?.toString() + it.sinusitis = when (sinusitis.value) { + context.getString(R.string.sinusitis_with_pain) -> true + optionNo -> false + else -> null + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/OralHealthDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/OralHealthDataset.kt new file mode 100644 index 000000000..21c574434 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/OralHealthDataset.kt @@ -0,0 +1,334 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.OralHealth + +class OralHealthDataset( + context: Context, + currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + private val optionYes = context.getString(R.string.yes_option) + private val optionNo = context.getString(R.string.no_option) + + private lateinit var cache: OralHealth + private var lastSelectedToothDecaySymptoms: Set = emptySet() + private var lastSelectedGumDiseaseSymptoms: Set = emptySet() + private var lastSelectedDentalEmergencySelections: Set = emptySet() + var onShowAlert: ((String) -> Unit)? = null + + private val toothDecayPresent = FormElement( + id = 1, + inputType = InputType.RADIO, + title = context.getString(R.string.oral_tooth_decay_present), + entries = arrayOf(optionYes, optionNo), + required = false, + hasDependants = true + ) + + private val toothDecaySymptoms = FormElement( + id = 2, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.oral_tooth_decay_symptoms), + entries = arrayOf( + context.getString(R.string.oral_symptom_black_spot), + context.getString(R.string.oral_symptom_discoloration), + context.getString(R.string.oral_symptom_hole), + context.getString(R.string.oral_symptom_sensitivity), + context.getString(R.string.oral_symptom_food_lodgment), + context.getString(R.string.oral_symptom_pain), + context.getString(R.string.oral_symptom_swelling), + context.getString(R.string.oral_symptom_pus_discharge) + ), + required = false, + hasAlertError = true + ) + + private val gumDiseasePresent = FormElement( + id = 3, + inputType = InputType.RADIO, + title = context.getString(R.string.oral_gum_disease_present), + entries = arrayOf(optionYes, optionNo), + required = false, + hasDependants = true + ) + + private val gumDiseaseSymptoms = FormElement( + id = 4, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.oral_gum_disease_symptoms), + entries = arrayOf( + context.getString(R.string.oral_gum_foul_smell), + context.getString(R.string.oral_gum_bleeding), + context.getString(R.string.oral_gum_deposits), + context.getString(R.string.oral_gum_loose_teeth), + context.getString(R.string.oral_gum_widening_gap), + context.getString(R.string.oral_gum_swollen) + ), + required = false, + hasAlertError = true + ) + + private val irregularTeethJaws = FormElement( + id = 5, + inputType = InputType.RADIO, + title = context.getString(R.string.oral_irregular_teeth_jaws), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + private val abnormalGrowthUlcer = FormElement( + id = 6, + inputType = InputType.RADIO, + title = context.getString(R.string.oral_abnormal_growth_ulcer), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + private val cleftLipPalate = FormElement( + id = 7, + inputType = InputType.RADIO, + title = context.getString(R.string.oral_cleft_lip_palate), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + private val dentalFluorosis = FormElement( + id = 8, + inputType = InputType.RADIO, + title = context.getString(R.string.oral_dental_fluorosis), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + private val dentalEmergency = FormElement( + id = 9, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.oral_dental_emergency), + entries = arrayOf( + context.getString(R.string.oral_emergency_pain), + context.getString(R.string.oral_emergency_abscess), + context.getString(R.string.oral_emergency_swelling), + context.getString(R.string.oral_emergency_tooth_injury), + context.getString(R.string.oral_emergency_avulsion), + context.getString(R.string.oral_emergency_non_healing_ulcer), + context.getString(R.string.oral_emergency_uncontrolled_bleeding), + context.getString(R.string.oral_emergency_trauma) + ), + required = false, + hasAlertError = true + ) + + suspend fun setUpPage(savedRecord: OralHealth?) { + cache = savedRecord ?: createDefaultCache() + populateFromCache(cache) + lastSelectedToothDecaySymptoms = toSelectionSet(toothDecaySymptoms.value) + lastSelectedGumDiseaseSymptoms = toSelectionSet(gumDiseaseSymptoms.value) + lastSelectedDentalEmergencySelections = toSelectionSet(dentalEmergency.value) + + val list = mutableListOf() + list.add(toothDecayPresent) + if (toothDecayPresent.value == optionYes) { + toothDecaySymptoms.required = true + list.add(toothDecaySymptoms) + } else { + toothDecaySymptoms.required = false + } + + list.add(gumDiseasePresent) + if (gumDiseasePresent.value == optionYes) { + gumDiseaseSymptoms.required = true + list.add(gumDiseaseSymptoms) + } else { + gumDiseaseSymptoms.required = false + } + + list.add(irregularTeethJaws) + list.add(abnormalGrowthUlcer) + list.add(cleftLipPalate) + list.add(dentalFluorosis) + list.add(dentalEmergency) + + setUpPage(list) + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + toothDecayPresent.id -> handleToothDecayPresent(index) + toothDecaySymptoms.id -> handleToothDecaySymptoms() + gumDiseasePresent.id -> handleGumDiseasePresent(index) + gumDiseaseSymptoms.id -> handleGumDiseaseSymptoms() + irregularTeethJaws.id, + abnormalGrowthUlcer.id, + cleftLipPalate.id, + dentalFluorosis.id -> handleSimpleAlert(index) + dentalEmergency.id -> handleDentalEmergency() + else -> -1 + } + } + + private fun handleToothDecayPresent(index: Int): Int { + return if (index == 0) { + toothDecaySymptoms.required = true + triggerDependants( + source = toothDecayPresent, + addItems = listOf(toothDecaySymptoms), + removeItems = emptyList() + ) + } else { + toothDecaySymptoms.value = null + toothDecaySymptoms.required = false + lastSelectedToothDecaySymptoms = emptySet() + triggerDependants( + source = toothDecayPresent, + addItems = emptyList(), + removeItems = listOf(toothDecaySymptoms) + ) + } + } + + private fun handleToothDecaySymptoms(): Int { + val currentSelections = toSelectionSet(toothDecaySymptoms.value) + val isNewSelection = currentSelections.size > lastSelectedToothDecaySymptoms.size + if (isNewSelection) { + onShowAlert?.invoke(resources.getString(R.string.oral_health_referral_alert)) + } + lastSelectedToothDecaySymptoms = currentSelections + return -1 + } + + private fun handleGumDiseasePresent(index: Int): Int { + return if (index == 0) { + gumDiseaseSymptoms.required = true + triggerDependants( + source = gumDiseasePresent, + addItems = listOf(gumDiseaseSymptoms), + removeItems = emptyList() + ) + } else { + gumDiseaseSymptoms.value = null + gumDiseaseSymptoms.required = false + lastSelectedGumDiseaseSymptoms = emptySet() + triggerDependants( + source = gumDiseasePresent, + addItems = emptyList(), + removeItems = listOf(gumDiseaseSymptoms) + ) + } + } + + private fun handleGumDiseaseSymptoms(): Int { + val currentSelections = toSelectionSet(gumDiseaseSymptoms.value) + val isNewSelection = currentSelections.size > lastSelectedGumDiseaseSymptoms.size + if (isNewSelection) { + onShowAlert?.invoke(resources.getString(R.string.oral_health_referral_alert)) + } + lastSelectedGumDiseaseSymptoms = currentSelections + return -1 + } + + private fun handleSimpleAlert(index: Int): Int { + if (index == 0) { + onShowAlert?.invoke(resources.getString(R.string.oral_health_referral_alert)) + } + return -1 + } + + private fun handleDentalEmergency(): Int { + val currentSelections = toSelectionSet(dentalEmergency.value) + if (currentSelections.size > lastSelectedDentalEmergencySelections.size) { + onShowAlert?.invoke(resources.getString(R.string.oral_health_referral_alert)) + } + lastSelectedDentalEmergencySelections = currentSelections + return -1 + } + + private fun createDefaultCache(): OralHealth { + return OralHealth( + patientID = "", + benVisitNo = null + ) + } + + private fun toSelectionSet(value: String?): Set { + return value + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toSet() + ?: emptySet() + } + + private fun String?.toYesNoBool(): Boolean? = when (this) { + optionYes -> true + optionNo -> false + else -> null + } + + private fun populateFromCache(cache: OralHealth) { + toothDecayPresent.value = when (cache.toothDecayPresent) { + true -> optionYes + false -> optionNo + else -> null + } + toothDecaySymptoms.value = if (cache.toothDecayPresent == true) { + cache.toothDecaySymptoms + } else { + null + } + + gumDiseasePresent.value = when (cache.gumDiseasePresent) { + true -> optionYes + false -> optionNo + else -> null + } + gumDiseaseSymptoms.value = if (cache.gumDiseasePresent == true) cache.gumDiseaseSymptoms else null + + irregularTeethJaws.value = when (cache.irregularTeethJaws) { + true -> optionYes + false -> optionNo + else -> null + } + abnormalGrowthUlcer.value = when (cache.abnormalGrowthUlcer) { + true -> optionYes + false -> optionNo + else -> null + } + cleftLipPalate.value = when (cache.cleftLipPalate) { + true -> optionYes + false -> optionNo + else -> null + } + dentalFluorosis.value = when (cache.dentalFluorosis) { + true -> optionYes + false -> optionNo + else -> null + } + dentalEmergency.value = cache.dentalEmergency + } + + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as OralHealth).let { + it.toothDecayPresent = toothDecayPresent.value.toYesNoBool() + it.toothDecaySymptoms = if (it.toothDecayPresent == true) toothDecaySymptoms.value else null + + it.gumDiseasePresent = gumDiseasePresent.value.toYesNoBool() + it.gumDiseaseSymptoms = if (it.gumDiseasePresent == true) gumDiseaseSymptoms.value else null + + it.irregularTeethJaws = irregularTeethJaws.value.toYesNoBool() + it.abnormalGrowthUlcer = abnormalGrowthUlcer.value.toYesNoBool() + it.cleftLipPalate = cleftLipPalate.value.toYesNoBool() + it.dentalFluorosis = dentalFluorosis.value.toYesNoBool() + it.dentalEmergency = dentalEmergency.value + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/PainAndSymptomAssessmentDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/PainAndSymptomAssessmentDataset.kt new file mode 100644 index 000000000..d3925a1ed --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/PainAndSymptomAssessmentDataset.kt @@ -0,0 +1,451 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.PainAndSymptomAssessment +import org.piramalswasthya.cho.R + +class PainAndSymptomAssessmentDataset( + private val context: Context, + currentLanguage: Languages +) : ReferralFollowUpDataset(context, currentLanguage) { + + private lateinit var cache: PainAndSymptomAssessment + + var onShowAlert: ((String) -> Unit)? = null + + private val optionMild = context.getString(R.string.mild) + private val optionModerate = context.getString(R.string.moderate) + private val optionSevere = context.getString(R.string.severe) + private val optionYes = context.getString(R.string.yes) + private val optionNo = context.getString(R.string.no) + + // ---------------- Pain Severity ---------------- + private val painSeverity = FormElement( + id = 1, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.pain_severity), + entries = arrayOf(optionMild, optionModerate, optionSevere), + required = true, + hasDependants = true, + hasAlertError = true + ) + + // ---------------- Pain Duration ---------------- + private val painDuration = FormElement( + id = 2, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.pain_duration), + entries = arrayOf( + context.getString(R.string.pain_duration_less_than_one_month), + context.getString(R.string.pain_duration_one_to_six_months), + context.getString(R.string.pain_duration_more_than_six_months) + ), + required = true + ) + + // ---------------- Other Symptoms Present (Removed) ---------------- +// private val symptomsPresent = FormElement( +// id = 3, +// inputType = InputType.RADIO, +// title = "Other symptoms present", +// entries = arrayOf("Yes", "No"), +// required = true, +// hasDependants = true +// ) + + // ---------------- Other Symptoms Severity ---------------- + private val otherSymptomsSeverity = FormElement( + id = 4, + inputType = InputType.DROPDOWN, + title = context.getString(R.string.other_symptoms_severity), + entries = arrayOf(optionMild, optionModerate, optionSevere), + required = false + ) + + // ---------------- Immediate Relief ---------------- + private val immediateReliefProvided = FormElement( + id = 5, + inputType = InputType.RADIO, + title = context.getString(R.string.immediate_relief_provided), + entries = arrayOf(optionYes, optionNo), + required = true + ) + private val sectionCHeadline = FormElement( + id = 11, + inputType = InputType.HEADLINE, + title = context.getString(R.string.palliative_care_identification_headline), + required = false + ) + private val sectionDHeadline = FormElement( + id = 18, + inputType = InputType.HEADLINE, + title = context.getString(R.string.pain_symptom_assessment_palliative), + required = false + ) + + private val persistentPainPresent = FormElement( + id = 12, + inputType = InputType.RADIO, + title = context.getString(R.string.persistent_pain_present), + entries = arrayOf(optionYes, optionNo), + required = true, + hasDependants = true + ) + + + + private val basicSymptoms = FormElement( + id = 24, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.symptom_assessment_basic_field_title), + entries = arrayOf( + context.getString(R.string.symptom_nausea_vomiting), + context.getString(R.string.symptom_constipation), + context.getString(R.string.symptom_anxiety_restlessness), + context.getString(R.string.symptom_sleep_disturbance) + ), + required = false + ) + + + private val basicSymptomReliefProvided = FormElement( + id = 26, + inputType = InputType.RADIO, + title = context.getString(R.string.basic_symptom_relief_provided), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val basicPsychosocialSupportProvided = FormElement( + id = 27, + inputType = InputType.RADIO, + title = context.getString(R.string.basic_psychosocial_support_provided), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val basicCaregiverCounsellingProvided = FormElement( + id = 28, + inputType = InputType.RADIO, + title = context.getString(R.string.basic_caregiver_counselling_provided), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val basicManagementRemarks = FormElement( + id = 29, + inputType = InputType.EDIT_TEXT, + title = context.getString(R.string.basic_management_remarks), + required = false, + etMaxLength = 250, + multiLine = true + ) + + private val distressingSymptoms = FormElement( + id = 14, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.distressing_symptoms_present), + entries = arrayOf( + context.getString(R.string.breathlessness), + context.getString(R.string.nausea), + context.getString(R.string.fatigue), + context.getString(R.string.weakness), + context.getString(R.string.other) + ), + required = false + ) + + private val bedriddenOrSeverelyDependent = FormElement( + id = 15, + inputType = InputType.RADIO, + title = context.getString(R.string.bedridden_or_severely_dependent), + entries = arrayOf(optionYes, optionNo), + required = true, + hasDependants = true + ) + private val lifeLimitingIllnessKnown = FormElement( + id = 16, + inputType = InputType.RADIO, + title = context.getString(R.string.life_limiting_illness_known), + entries = arrayOf(optionYes, optionNo), + required = true, + hasDependants = true + ) + + private val caregiverSupportRequired = FormElement( + id = 17, + inputType = InputType.RADIO, + title = context.getString(R.string.caregiver_support_required), + entries = arrayOf(optionYes, optionNo), + required = true, + hasDependants = true + ) + // ---------------- Section F: Referral & Follow-up ---------------- + + override val referralRequired = createReferralRequired(6) + + override val referralLevel = createReferralLevel(7) + + override val reasonForReferral = createReasonForReferral(8) + + override val followUpRequired = createFollowUpRequired(9) + + override val followUpDate = createFollowUpDate(10) + override val caseStatus = createCaseStatus(20) + override val dateOfDeath = createDateOfDeath(21) + override val remarks = createRemarks(22) + + // ---------------- Setup Page ---------------- + suspend fun setUpPage(savedRecord: PainAndSymptomAssessment?) { + cache = savedRecord ?: createDefaultCache() + populateFromCache(cache) + + val list = mutableListOf() + // Section C: Palliative Care Identification + list.add(sectionCHeadline) + list.add(persistentPainPresent) + list.add(basicSymptoms) + list.add(basicSymptomReliefProvided) + list.add(basicPsychosocialSupportProvided) + list.add(basicCaregiverCounsellingProvided) + list.add(basicManagementRemarks) + list.add(distressingSymptoms) + list.add(bedriddenOrSeverelyDependent) + list.add(lifeLimitingIllnessKnown) + list.add(caregiverSupportRequired) + + // Section D: Pain & Symptom Assessment – if any Section C field is affirmative OR migrated record has saved pain data + if (shouldShowPainAssessment() || hasSavedPainAssessment()) { + list.addAll(getPainAssessmentFields()) + } + // Section F + addReferralFollowUpElements(list) + + setUpPage(list) + } + + // ---------------- Value Change Handler ---------------- + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + val referralFollowUpResult = handleReferralFollowUpChange(formId, index) + if (referralFollowUpResult != -1) return referralFollowUpResult + + return when (formId) { + persistentPainPresent.id, + distressingSymptoms.id, + bedriddenOrSeverelyDependent.id, + lifeLimitingIllnessKnown.id, + caregiverSupportRequired.id -> { + val isShown = getFormList().any { it.id == sectionDHeadline.id } + val shouldShow = shouldShowPainAssessment() + + if (shouldShow && !isShown) { + triggerDependants( + source = caregiverSupportRequired, + addItems = getPainAssessmentFields(), + removeItems = emptyList() + ) + } else if (!shouldShow && isShown) { + painSeverity.value = null + painDuration.value = null + otherSymptomsSeverity.value = null + immediateReliefProvided.value = null + triggerDependants( + source = caregiverSupportRequired, + addItems = emptyList(), + removeItems = getPainAssessmentFields() + ) + } + formId + } + painSeverity.id -> { + if (painSeverity.value == optionSevere) { + onShowAlert?.invoke( + context.getString(R.string.severe_pain_referral_alert) + ) + } + -1 + } + + otherSymptomsSeverity.id -> { + if (otherSymptomsSeverity.value == optionSevere) { + onShowAlert?.invoke( + context.getString(R.string.severe_symptoms_referral_alert) + ) + } + -1 + } + + else -> -1 + } + } + + // ---------------- Cache Helpers ---------------- + private fun createDefaultCache(): PainAndSymptomAssessment { + return PainAndSymptomAssessment( + patientID = "", + benVisitNo = null + ) + } + + private fun populateFromCache(cache: PainAndSymptomAssessment) { + // Section C + persistentPainPresent.value = when (cache.persistentPainPresent) { + true -> optionYes + false -> optionNo + else -> null + } + basicSymptoms.value = cache.basicSymptomsSelected + basicSymptomReliefProvided.value = when (cache.basicSymptomReliefProvided) { + true -> optionYes + false -> optionNo + else -> null + } + basicPsychosocialSupportProvided.value = when (cache.basicPsychosocialSupportProvided) { + true -> optionYes + false -> optionNo + else -> null + } + basicCaregiverCounsellingProvided.value = when (cache.basicCaregiverCounsellingProvided) { + true -> optionYes + false -> optionNo + else -> null + } + basicManagementRemarks.value = cache.basicManagementRemarks + distressingSymptoms.value = cache.distressingSymptoms + bedriddenOrSeverelyDependent.value = when (cache.bedriddenOrSeverelyDependent) { + true -> optionYes + false -> optionNo + else -> null + } + lifeLimitingIllnessKnown.value = when (cache.lifeLimitingIllnessKnown) { + true -> optionYes + false -> optionNo + else -> null + } + caregiverSupportRequired.value = when (cache.caregiverSupportRequired) { + true -> optionYes + false -> optionNo + else -> null + } + painSeverity.value = cache.painSeverity + painDuration.value = cache.painDuration +// symptomsPresent.value = when (cache.symptomsPresent) { +// true -> "Yes" +// false -> "No" +// else -> null +// } + otherSymptomsSeverity.value = cache.otherSymptomsSeverity + immediateReliefProvided.value = when (cache.immediateReliefProvided) { + true -> optionYes + false -> optionNo + else -> null + } + + // Section F + populateReferralFollowUpFromCache(cache) + } + + private fun getPainAssessmentFields(): List { + return listOf(sectionDHeadline, painSeverity, painDuration, otherSymptomsSeverity, immediateReliefProvided) + } + + private fun shouldShowPainAssessment(): Boolean { + return listOf( + persistentPainPresent, + bedriddenOrSeverelyDependent, + lifeLimitingIllnessKnown, + caregiverSupportRequired + ).any { it.value == optionYes } || !distressingSymptoms.value.isNullOrBlank() + } + + /** Returns true when a migrated record has saved Section D values but NULL Section C triggers. */ + private fun hasSavedPainAssessment(): Boolean { + return listOf( + painSeverity.value, + painDuration.value, + otherSymptomsSeverity.value, + immediateReliefProvided.value + ).any { !it.isNullOrBlank() } + } + + // ---------------- Map Values ---------------- + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as PainAndSymptomAssessment).let { + // Section C – preserve null for unanswered radios (unanswered ≠ "No") + it.persistentPainPresent = when (persistentPainPresent.value) { + optionYes -> true + optionNo -> false + else -> null + } + + it.basicSymptomsSelected = basicSymptoms.value + + it.basicSymptomReliefProvided = when (basicSymptomReliefProvided.value) { + optionYes -> true + optionNo -> false + else -> null + } + it.basicPsychosocialSupportProvided = when (basicPsychosocialSupportProvided.value) { + optionYes -> true + optionNo -> false + else -> null + } + it.basicCaregiverCounsellingProvided = when (basicCaregiverCounsellingProvided.value) { + optionYes -> true + optionNo -> false + else -> null + } + it.basicManagementRemarks = basicManagementRemarks.value?.trim()?.takeIf { v -> v.isNotEmpty() } + + it.distressingSymptoms = distressingSymptoms.value + + it.bedriddenOrSeverelyDependent = when (bedriddenOrSeverelyDependent.value) { + optionYes -> true + optionNo -> false + else -> null + } + it.lifeLimitingIllnessKnown = when (lifeLimitingIllnessKnown.value) { + optionYes -> true + optionNo -> false + else -> null + } + it.caregiverSupportRequired = when (caregiverSupportRequired.value) { + optionYes -> true + optionNo -> false + else -> null + } + + // Derived Section C flags + it.painAssessmentEnabled = shouldShowPainAssessment() || hasSavedPainAssessment() + it.palliativeCareEligible = listOf( + it.persistentPainPresent, + it.bedriddenOrSeverelyDependent, + it.lifeLimitingIllnessKnown, + it.caregiverSupportRequired + ).any { flag -> flag == true } || !distressingSymptoms.value.isNullOrBlank() + + it.painSeverity = painSeverity.value + it.painDuration = painDuration.value + +// it.symptomsPresent = when (symptomsPresent.value) { +// "Yes" -> true +// "No" -> false +// else -> null +// } + + it.otherSymptomsSeverity = otherSymptomsSeverity.value + + it.immediateReliefProvided = when (immediateReliefProvided.value) { + optionYes -> true + optionNo -> false + else -> null + } + + // Section F + mapReferralFollowUpValues(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/PncFormDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/PncFormDataset.kt index 720311eba..60a751982 100644 --- a/app/src/main/java/org/piramalswasthya/cho/configuration/PncFormDataset.kt +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/PncFormDataset.kt @@ -20,6 +20,8 @@ class PncFormDataset( private var visit: Int = 0 private var dateOfDelivery: Long = 0L + private var previousPncVisitDate: Long? = null + private var previousPncPeriod: Int = 0 private val pncPeriod = FormElement( id = 1, @@ -37,6 +39,7 @@ class PncFormDataset( title = resources.getString(R.string.pnc_visit_date), arrayId = -1, required = true, + isEnabled = false, hasDependants = false ) @@ -52,6 +55,18 @@ class PncFormDataset( min = 0, ) + private val calciumSupplementation = FormElement( + id = 17, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.pnc_calcium_supplementation), + required = false, + hasDependants = false, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + etMaxLength = 3, + max = 400, + min = 0, + ) + private val anyContraceptionMethod = FormElement( id = 4, inputType = InputType.RADIO, @@ -78,6 +93,61 @@ class PncFormDataset( hasDependants = false ) + private val dateOfSterilisation = FormElement( + id = 18, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.pnc_date_of_sterilization), + arrayId = -1, + max = System.currentTimeMillis(), + required = true, + hasDependants = false + ) + + private val anyDangerSign = FormElement( + id = 19, + inputType = InputType.RADIO, + title = resources.getString(R.string.pnc_any_danger_sign), + entries = resources.getStringArray(R.array.pnc_confirmation_array), + required = false, + hasDependants = true + ) + + private val maternalSymptoms = FormElement( + id = 20, + inputType = InputType.CHECKBOXES, + title = resources.getString(R.string.pnc_maternal_symptoms), + entries = resources.getStringArray(R.array.pnc_maternal_symptoms_array), + required = true, + hasDependants = true + ) + + private val otherMaternalSymptoms = FormElement( + id = 21, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.pnc_other_maternal_symptoms), + required = true, + hasDependants = false, + etMaxLength = 50 + ) + + private val pallor = FormElement( + id = 22, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.pnc_pallor), + entries = resources.getStringArray(R.array.pnc_pallor_array), + required = false, + hasDependants = false + ) + + private val vaginalBleeding = FormElement( + id = 23, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.pnc_vaginal_bleeding), + entries = resources.getStringArray(R.array.pnc_vaginal_bleeding_array), + required = false, + hasDependants = false + ) + private val motherDangerSign = FormElement( id = 7, inputType = InputType.DROPDOWN, @@ -137,7 +207,8 @@ class PncFormDataset( inputType = InputType.EDIT_TEXT, title = resources.getString(R.string.pnc_other_death_cause), required = true, - hasDependants = false + hasDependants = false, + etMaxLength = 300 ) private val placeOfDeath = FormElement( @@ -146,7 +217,15 @@ class PncFormDataset( title = resources.getString(R.string.pnc_death_place), entries = resources.getStringArray(R.array.pnc_death_place_array), required = true, - hasDependants = false, + hasDependants = true, + ) + + private val otherPlaceOfDeath = FormElement( + id = 24, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.other_place_of_death), + required = true, + hasDependants = false ) private val remarks = FormElement( @@ -160,61 +239,127 @@ class PncFormDataset( private val deliveryDate = FormElement( id = 16, inputType = InputType.TEXT_VIEW, - title = "Delivery Date", + title = resources.getString(R.string.delivery_date), required = false, hasDependants = false ) + companion object { + fun getMinDeliveryDate(): Long { + val cal = Calendar.getInstance() + cal.add(Calendar.YEAR, -1) + return cal.timeInMillis + } + + private fun getTodayStartMillis(): Long = + Calendar.getInstance().setToStartOfTheDay().timeInMillis + } + + private val sterilisation: Array by lazy { + resources.getStringArray(R.array.sterilization_methods_array) + } + + // English-side reference used when comparing values that were just loaded + // from the DB (which are now stored in English). The locale-aware variant + // above is still used for runtime comparisons against form-element values. + private val englishSterilisation: Array by lazy { + englishResources.getStringArray(R.array.sterilization_methods_array) + } + + private val englishContraceptionLast: String by lazy { + englishResources.getStringArray(R.array.pnc_contraception_method_array).last() + } + suspend fun setUpPage( visitNumber: Int, ben: PatientDisplay?, deliveryOutcomeCache: DeliveryOutcomeCache, previousPnc: PNCVisitCache?, - saved: PNCVisitCache? + saved: PNCVisitCache?, + hasPreviousPermanentSterilization: Boolean = false, + lastSterilizationVisit: PNCVisitCache? = null ) { val list = mutableListOf( deliveryDate, pncPeriod, visitDate, + motherDeath, ifaTabsGiven, + calciumSupplementation, anyContraceptionMethod, - motherDangerSign, + anyDangerSign, + maternalSymptoms, + pallor, + vaginalBleeding, referralFacility, - motherDeath, remarks ) - dateOfDelivery = deliveryOutcomeCache.dateOfDelivery!! - deliveryDate.value = deliveryOutcomeCache.getDateStringFromLong(dateOfDelivery) - deathDate.min = dateOfDelivery + dateOfDelivery = deliveryOutcomeCache.dateOfDelivery ?: 0L + previousPncVisitDate = previousPnc?.pncDate + previousPncPeriod = previousPnc?.pncPeriod ?: 0 + + configureDeliveryDateField() + resetVisitDateToDisabled() + + if (dateOfDelivery != 0L) { + deathDate.min = dateOfDelivery + dateOfSterilisation.min = dateOfDelivery + } deathDate.max = System.currentTimeMillis() + dateOfSterilisation.max = System.currentTimeMillis() + anyDangerSign.value = anyDangerSign.entries!!.last() motherDeath.value = motherDeath.entries!!.last() - val daysSinceDeliveryMillis = Calendar.getInstance() - .setToStartOfTheDay().timeInMillis - deliveryOutcomeCache.dateOfDelivery!!.let { - val cal = Calendar.getInstance() - cal.timeInMillis = it - cal.setToStartOfTheDay() - cal.timeInMillis + + // Set default value for motherDeath to "No" + motherDeath.value = motherDeath.entries!!.last() + + updatePncPeriodEntries() + + // Handle permanent sterilization - disable contraception fields if already selected. + // lastSterilizationVisit.contraceptionMethod is stored in English (canonical); re-localize + // for display and compare against the English-side references so conditional rendering + // (dateOfSterilisation / otherPpcMethod) works in every locale. + if (hasPreviousPermanentSterilization && lastSterilizationVisit != null) { + anyContraceptionMethod.isEnabled = false + contraceptionMethod.isEnabled = false + dateOfSterilisation.isEnabled = false + otherPpcMethod.isEnabled = false + + anyContraceptionMethod.value = if (lastSterilizationVisit.anyContraceptionMethod == true) + anyContraceptionMethod.entries!!.first() else anyContraceptionMethod.entries!!.last() + + contraceptionMethod.value = getLocalValueInArray(R.array.pnc_contraception_method_array, lastSterilizationVisit.contraceptionMethod) + dateOfSterilisation.value = getDateFromLong(lastSterilizationVisit.sterilisationDate ?: System.currentTimeMillis()) + otherPpcMethod.value = lastSterilizationVisit.otherPpcMethod + + if (lastSterilizationVisit.anyContraceptionMethod == true) { + list.add(list.indexOf(anyContraceptionMethod) + 1, contraceptionMethod) + + if (lastSterilizationVisit.contraceptionMethod?.let { it in englishSterilisation } == true) { + list.add(list.indexOf(contraceptionMethod) + 1, dateOfSterilisation) + } + + if (lastSterilizationVisit.contraceptionMethod == englishContraceptionLast) { + list.add(list.indexOf(contraceptionMethod) + 1, otherPpcMethod) + } + } } - val daysSinceDelivery = TimeUnit.MILLISECONDS.toDays(daysSinceDeliveryMillis) - pncPeriod.entries = - listOf( - 1, - 3, - 7, - 14, - 21, - 28, - 42 - ).filter { if (daysSinceDelivery == 0L) it <= 1 else it <= daysSinceDelivery } - .filter { it > (previousPnc?.pncPeriod ?: 0) } - .map { "Day $it" }.toTypedArray() saved?.let { + // saved.* dropdown fields are stored in English; re-localize for display + // so the user always sees the form in their current UI language. Conditional + // rendering compares against the English side since saved values are English. + val englishMotherDangerSignLast = englishResources.getStringArray(R.array.pnc_mother_danger_sign_array).last() + val englishPlaceOfDeath = englishResources.getStringArray(R.array.pnc_death_place_array) + val englishMaternalSymptomsLast = englishResources.getStringArray(R.array.pnc_maternal_symptoms_array).last() + pncPeriod.value = "Day ${it.pncPeriod}" visitDate.value = getDateFromLong(it.pncDate) + enableVisitDateForSelection() ifaTabsGiven.value = it.ifaTabsGiven?.toString() - anyContraceptionMethod.value = it.anyContraceptionMethod?.let { - if (it) + calciumSupplementation.value = it.calciumSupplementation?.toString() + anyContraceptionMethod.value = it.anyContraceptionMethod?.let { yes -> + if (yes) anyContraceptionMethod.entries!!.first() else anyContraceptionMethod.entries!!.last() @@ -222,30 +367,75 @@ class PncFormDataset( if (it.anyContraceptionMethod == true) { list.add(list.indexOf(anyContraceptionMethod) + 1, contraceptionMethod) } - contraceptionMethod.value = it.contraceptionMethod - if (it.contraceptionMethod == contraceptionMethod.entries!!.last()) { + contraceptionMethod.value = getLocalValueInArray(R.array.pnc_contraception_method_array, it.contraceptionMethod) + dateOfSterilisation.value = getDateFromLong(it.sterilisationDate ?: System.currentTimeMillis()) + if (it.contraceptionMethod == englishContraceptionLast) { list.add(list.indexOf(contraceptionMethod) + 1, otherPpcMethod) } + if (it.contraceptionMethod?.let { method -> method in englishSterilisation } == true) { + list.add(list.indexOf(contraceptionMethod) + 1, dateOfSterilisation) + } otherPpcMethod.value = it.otherPpcMethod - motherDangerSign.value = it.motherDangerSign - if (it.motherDangerSign == motherDangerSign.entries!!.last()) { + anyDangerSign.value = getLocalValueInArray(R.array.pnc_confirmation_array, it.anyDangerSign) + anyDangerSign.value?.let { dangerSignValue -> + // anyDangerSign.value is now local; entries.first() is also local — compare same-locale. + val isDangerSignYes = dangerSignValue == anyDangerSign.entries!!.first() + referralFacility.required = isDangerSignYes + if (isDangerSignYes) { + list.add(list.indexOf(anyDangerSign) + 1, motherDangerSign) + } + } + maternalSymptoms.value = getLocalValuesInArray(R.array.pnc_maternal_symptoms_array, it.maternalSymptoms) + if (it.maternalSymptoms?.contains("Other") == true || it.maternalSymptoms?.contains(englishMaternalSymptomsLast) == true) { + list.add(list.indexOf(maternalSymptoms) + 1, otherMaternalSymptoms) + } + otherMaternalSymptoms.value = it.otherMaternalSymptoms + pallor.value = getLocalValueInArray(R.array.pnc_pallor_array, it.pallor) + // Severe pallor → referral alert. it.pallor is English so the literal compare is correct. + if (it.pallor?.equals("Severe", ignoreCase = true) == true) { + referralFacility.required = true + if (it.referralFacility.isNullOrBlank()) { + referralFacility.errorText = resources.getString(R.string.pnc_referral_alert_severe_pallor) + } + } + + vaginalBleeding.value = getLocalValueInArray(R.array.pnc_vaginal_bleeding_array, it.vaginalBleeding) + // Heavy / foul smell → referral alert. it.vaginalBleeding is English so literal compare is correct. + val vaginalBleedingValue = it.vaginalBleeding?.lowercase() ?: "" + if (vaginalBleedingValue.contains("heavy", ignoreCase = true) || + vaginalBleedingValue.contains("foul smell", ignoreCase = true)) { + referralFacility.required = true + if (it.referralFacility.isNullOrBlank()) { + referralFacility.errorText = resources.getString(R.string.pnc_referral_alert_vaginal_bleeding) + } + } + motherDangerSign.value = getLocalValueInArray(R.array.pnc_mother_danger_sign_array, it.motherDangerSign) + if (it.motherDangerSign == englishMotherDangerSignLast) { list.add(list.indexOf(motherDangerSign) + 1, otherDangerSign) } otherDangerSign.value = it.otherDangerSign - referralFacility.value = it.referralFacility + referralFacility.value = getLocalValueInArray(R.array.pnc_referral_facility_array, it.referralFacility) motherDeath.value = if (it.motherDeath) motherDeath.entries!!.first() else motherDeath.entries!!.last() if (it.motherDeath) { deathDate.value = getDateStrFromLong(it.deathDate) - causeOfDeath.value = it.causeOfDeath + causeOfDeath.value = getLocalValueInArray(R.array.pnc_death_cause_array, it.causeOfDeath) otherDeathCause.value = it.otherDeathCause - placeOfDeath.value = it.placeOfDeath + placeOfDeath.value = getLocalValueInArray(R.array.pnc_death_place_array, it.placeOfDeath) + otherPlaceOfDeath.value = it.otherPlaceOfDeath list.addAll( list.indexOf(motherDeath) + 1, listOf(deathDate, causeOfDeath, placeOfDeath) ) + // causeOfDeath.value is now local; entries.last() is local — compare same-locale. if (causeOfDeath.value == causeOfDeath.entries!!.last()) list.add(list.indexOf(causeOfDeath) + 1, otherDeathCause) + // it.placeOfDeath is English; look it up in the English array to find the index. + englishPlaceOfDeath.indexOf(it.placeOfDeath).takeIf { index -> index >= 0 }?.let { index -> + if (index == 8) { // "Other" is at index 8 + list.add(list.indexOf(placeOfDeath) + 1, otherPlaceOfDeath) + } + } } remarks.value = it.remarks } @@ -295,134 +485,355 @@ class PncFormDataset( } - override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { - return when (formId) { - pncPeriod.id -> { - visitDate.inputType = InputType.DATE_PICKER - visitDate.value = null - val today = Calendar.getInstance().setToStartOfTheDay().timeInMillis - when (val visitNumber = pncPeriod.value!!.substring(4).toInt()) { - 1 -> { - visitDate.min = minOf(today, dateOfDelivery) - visitDate.max = minOf( - today, - dateOfDelivery + TimeUnit.DAYS.toMillis(1) - ) - } + private fun configureDeliveryDateField() { + val today = getTodayStartMillis() + if (dateOfDelivery != 0L) { + deliveryDate.inputType = InputType.TEXT_VIEW + deliveryDate.isEnabled = false + deliveryDate.required = false + deliveryDate.hasDependants = false + deliveryDate.value = getDateFromLong(dateOfDelivery) + } else { + deliveryDate.inputType = InputType.DATE_PICKER + deliveryDate.isEnabled = true + deliveryDate.required = true + deliveryDate.hasDependants = true + deliveryDate.min = getMinDeliveryDate() + deliveryDate.max = today + deliveryDate.value = null + } + } - 3 -> { - visitDate.min = minOf(today, dateOfDelivery + TimeUnit.DAYS.toMillis(3)) - visitDate.max = minOf( - today, - dateOfDelivery + TimeUnit.DAYS.toMillis(3) - ) - } + private fun resetVisitDateToDisabled() { + visitDate.inputType = InputType.TEXT_VIEW + visitDate.isEnabled = false + visitDate.min = null + visitDate.max = null + visitDate.value = null + } - 7 -> { - visitDate.min = - minOf( - today, - dateOfDelivery + TimeUnit.DAYS.toMillis(7) - TimeUnit.DAYS.toMillis( - 3 - ) - ) - visitDate.max = - minOf( - System.currentTimeMillis(), - dateOfDelivery + TimeUnit.DAYS.toMillis(7) + TimeUnit.DAYS.toMillis( - 3 - ) - ) - } + private fun enableVisitDateForSelection() { + visitDate.inputType = InputType.DATE_PICKER + visitDate.isEnabled = true + visitDate.min = null + visitDate.max = null + } - 14 -> { - visitDate.min = - minOf( - today, - dateOfDelivery + TimeUnit.DAYS.toMillis(14) - TimeUnit.DAYS.toMillis( - 3 - ) - ) - visitDate.max = - minOf( - System.currentTimeMillis(), - dateOfDelivery + TimeUnit.DAYS.toMillis(14) + TimeUnit.DAYS.toMillis( - 3 - ) - ) - } + private fun updatePncPeriodEntries() { + val daysSinceDeliveryMillis = if (dateOfDelivery != 0L) { + val deliveryCal = Calendar.getInstance() + deliveryCal.timeInMillis = dateOfDelivery + deliveryCal.setToStartOfTheDay() + val deliveryMillis = deliveryCal.timeInMillis + getTodayStartMillis() - deliveryMillis + } else { + 0L + } + val daysSinceDelivery = if (daysSinceDeliveryMillis > 0) { + TimeUnit.MILLISECONDS.toDays(daysSinceDeliveryMillis) + } else 0L + + pncPeriod.entries = listOf(1, 3, 7, 14, 21, 28, 42) + .filter { if (daysSinceDelivery == 0L) it <= 1 else it <= daysSinceDelivery } + .filter { it > previousPncPeriod } + .map { "Day $it" } + .toTypedArray() + } - 21 -> { - visitDate.min = - minOf( - today, - dateOfDelivery + TimeUnit.DAYS.toMillis(21) - TimeUnit.DAYS.toMillis( - 3 - ) - ) - visitDate.max = - minOf( - System.currentTimeMillis(), - dateOfDelivery + TimeUnit.DAYS.toMillis(21) + TimeUnit.DAYS.toMillis( - 3 - ) - ) - } + fun getSelectedDeliveryDateMillis(): Long? { + val fromField = deliveryDate.value?.let { getLongFromDate(it) }?.takeIf { it > 0L } + return fromField ?: dateOfDelivery.takeIf { it > 0L } + } - 28 -> { - visitDate.min = - minOf( - today, - dateOfDelivery + TimeUnit.DAYS.toMillis(28) - TimeUnit.DAYS.toMillis( - 3 - ) - ) - visitDate.max = - minOf( - System.currentTimeMillis(), - dateOfDelivery + TimeUnit.DAYS.toMillis(28) + TimeUnit.DAYS.toMillis( - 3 - ) - ) - } + /** + * Evaluates all referral conditions and updates referralFacility.required and errorText accordingly. + * Checks: anyDangerSign, maternalSymptoms (≥2 symptoms), pallor (Severe), vaginalBleeding (Heavy/Foul smell). + * Priority: anyDangerSign > multiple symptoms > severe pallor > vaginal bleeding + */ + private fun updateReferralRequirement() { + // anyDangerSign.value and entries.first() are both in the current UI locale — compare same-locale. + val hasAnyDangerSign = anyDangerSign.value?.equals(anyDangerSign.entries!!.first(), ignoreCase = true) == true + + if (hasAnyDangerSign) { + referralFacility.required = true + referralFacility.errorText = null + return + } - 42 -> { - visitDate.min = - minOf( - today, - dateOfDelivery + TimeUnit.DAYS.toMillis(42) - TimeUnit.DAYS.toMillis( - 3 - ) - ) - visitDate.max = - minOf( - System.currentTimeMillis(), - dateOfDelivery + TimeUnit.DAYS.toMillis(42) + TimeUnit.DAYS.toMillis( - 3 - ) - ) - } + // The remaining checks key off canonical English literals ("None", "Severe", "heavy", + // "foul smell"). Since FormElement.value is in the current UI locale, canonicalize each + // value to English first so the predicates fire in Hindi / Assamese sessions too. - else -> throw IllegalStateException("Illegal PNC Date $visitNumber") - } - return -1 + // maternalSymptoms - ≥2 actual symptoms (excluding "None") + val maternalSymptomsEnglish = getEnglishValuesInArray( + R.array.pnc_maternal_symptoms_array, maternalSymptoms.value + )?.split(",")?.map { it.trim() } ?: emptyList() + val actualSymptoms = maternalSymptomsEnglish.filter { + it.isNotBlank() && !it.equals("None", ignoreCase = true) + } + + if (actualSymptoms.size >= 2) { + referralFacility.required = true + // Only show alert if no facility selected yet; clear once selected so it doesn't block submission + if (referralFacility.value.isNullOrBlank()) { + referralFacility.errorText = resources.getString(R.string.pnc_referral_alert_multiple_symptoms) + } else { + referralFacility.errorText = null } + return + } + + // pallor - Severe + val pallorEnglish = getEnglishValueInArray(R.array.pnc_pallor_array, pallor.value)?.trim() ?: "" + if (pallorEnglish.equals("Severe", ignoreCase = true)) { + referralFacility.required = true + if (referralFacility.value.isNullOrBlank()) { + referralFacility.errorText = resources.getString(R.string.pnc_referral_alert_severe_pallor) + } else { + referralFacility.errorText = null + } + return + } + + // vaginalBleeding - Heavy or Foul smell (substring match on the canonical English entry) + val vaginalBleedingEnglish = getEnglishValueInArray( + R.array.pnc_vaginal_bleeding_array, vaginalBleeding.value + )?.trim()?.lowercase() ?: "" + if (vaginalBleedingEnglish.contains("heavy") || + vaginalBleedingEnglish.contains("foul smell")) { + referralFacility.required = true + if (referralFacility.value.isNullOrBlank()) { + referralFacility.errorText = resources.getString(R.string.pnc_referral_alert_vaginal_bleeding) + } else { + referralFacility.errorText = null + } + return + } + + // No referral conditions met - clear requirement + referralFacility.required = false + referralFacility.errorText = null + } + + // ─── Helper: handle PNC period selection and compute visit date range ─ + private fun handlePncPeriodChange(): Int { + visitDate.value = null + enableVisitDateForSelection() + return getIndexById(visitDate.id) + } + + private fun handleDeliveryDateChange(): Int { + dateOfDelivery = deliveryDate.value?.let { getLongFromDate(it) } ?: 0L + if (dateOfDelivery != 0L) { + deathDate.min = dateOfDelivery + dateOfSterilisation.min = dateOfDelivery + } + updatePncPeriodEntries() + return getIndexById(pncPeriod.id) + } + + // ─── Helper: handle contraception method selection ───────────────── + private fun handleContraceptionMethodChange(index: Int): Int { + val selected = contraceptionMethod.entries?.getOrNull(index)?.trim() ?: "" + val anyOtherValue = contraceptionMethod.entries!!.last().trim() + val result1 = if (selected.equals(anyOtherValue, ignoreCase = true)) { + triggerDependants( + source = contraceptionMethod, + passedIndex = index, + triggerIndex = contraceptionMethod.entries!!.lastIndex, + target = otherPpcMethod + ) + } else { + triggerDependants( + source = contraceptionMethod, + passedIndex = -1, + triggerIndex = contraceptionMethod.entries!!.lastIndex, + target = otherPpcMethod + ) + } + + val isSterilisation = sterilisation.any { it.equals(selected, ignoreCase = true) } + val result2 = if (isSterilisation) { + dateOfSterilisation.min = dateOfDelivery + dateOfSterilisation.max = System.currentTimeMillis() + triggerDependants( + source = contraceptionMethod, + passedIndex = index, + triggerIndex = index, + target = dateOfSterilisation + ) + } else { + dateOfSterilisation.value = null + triggerDependants( + source = contraceptionMethod, + passedIndex = -1, + triggerIndex = index, + target = dateOfSterilisation + ) + } + + return if (result1 != -1) result1 else result2 + } + + // ─── Helper: handle danger sign toggle and referral requirement ──── + private fun handleDangerSignChange(index: Int): Int { + val result = triggerDependants( + source = anyDangerSign, + passedIndex = index, + triggerIndex = 0, + target = motherDangerSign, + targetSideEffect = listOf(otherDangerSign) + ) + + val oldRequiredState = referralFacility.required + if (index == 0) { + referralFacility.required = true + } else { + referralFacility.required = false + referralFacility.errorText = null + } + val referralFacilityIndex = getIndexById(referralFacility.id) + return if (oldRequiredState != referralFacility.required && referralFacilityIndex != -1) { + referralFacilityIndex + } else { + result + } + } - ifaTabsGiven.id -> validateIntMinMax(ifaTabsGiven) + // ─── Helper: handle mother death toggle ─────────────────────────── + private fun handleMotherDeathChange(index: Int): Int { + return if (index == 0) { + triggerDependants( + source = motherDeath, + removeItems = listOf( + ifaTabsGiven, + calciumSupplementation, + anyContraceptionMethod, + anyDangerSign, + maternalSymptoms, + pallor, + vaginalBleeding, + referralFacility, + remarks + ), + addItems = listOf( + deathDate, + causeOfDeath, + placeOfDeath, + otherDeathCause, + otherPlaceOfDeath + ) + ) + } else { + triggerDependants( + source = motherDeath, + removeItems = listOf( + deathDate, + causeOfDeath, + placeOfDeath, + otherDeathCause, + otherPlaceOfDeath + ), + addItems = listOf( + ifaTabsGiven, + calciumSupplementation, + anyContraceptionMethod, + anyDangerSign, + maternalSymptoms, + pallor, + vaginalBleeding, + referralFacility, + remarks + ) + ) + } + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + deliveryDate.id -> handleDeliveryDateChange() + + pncPeriod.id -> handlePncPeriodChange() + + ifaTabsGiven.id -> { + // IFA Tablets: >0 and ≤400, supports 180-day postpartum supplementation + val result = validateIntMinMax(ifaTabsGiven) + ifaTabsGiven.value?.toIntOrNull()?.let { value -> + if (value > 400) { + ifaTabsGiven.errorText = resources.getString(R.string.pnc_ifa_max_error) + return getIndexById(ifaTabsGiven.id) + } + } + result + } + + calciumSupplementation.id -> { + // Calcium: ≤400 + val result = validateIntMinMax(calciumSupplementation) + calciumSupplementation.value?.toIntOrNull()?.let { value -> + if (value > 400) { + calciumSupplementation.errorText = resources.getString(R.string.pnc_calcium_max_error) + return getIndexById(calciumSupplementation.id) + } + } + result + } + anyContraceptionMethod.id -> triggerDependants( source = anyContraceptionMethod, passedIndex = index, triggerIndex = 0, target = contraceptionMethod, - targetSideEffect = listOf(otherPpcMethod) + targetSideEffect = listOf(otherPpcMethod, dateOfSterilisation) ) - contraceptionMethod.id -> triggerDependants( - source = contraceptionMethod, - passedIndex = index, - triggerIndex = contraceptionMethod.entries!!.lastIndex, - target = otherPpcMethod, - ) + contraceptionMethod.id -> handleContraceptionMethodChange(index) + + anyDangerSign.id -> handleDangerSignChange(index) + + maternalSymptoms.id -> { + val realIndex = (if (index < 0) -index else index) - 1 + val clickedOption = maternalSymptoms.entries?.getOrNull(realIndex) ?: return -1 + val currentValue = maternalSymptoms.value ?: "" + + if (clickedOption.equals("None", ignoreCase = true)) { + if (currentValue.contains("None", ignoreCase = true)) { + maternalSymptoms.value = "None" + } + } else { + val selections = mutableSetOf() + if (currentValue.isNotEmpty()) { + selections.addAll(currentValue.split(",").map { it.trim() }.filter { it.isNotEmpty() }) + } + if (selections.contains(clickedOption)) { + val noneEntry = maternalSymptoms.entries?.find { it.equals("None", ignoreCase = true) } + if (noneEntry != null && selections.contains(noneEntry)) { + selections.remove(noneEntry) + } + maternalSymptoms.value = selections.joinToString(", ") + } + } + + // Handle "Other" field visibility + val selectedValues = maternalSymptoms.value?.split(",")?.map { it.trim() } ?: emptyList() + val hasOther = selectedValues.any { it.equals(maternalSymptoms.entries!!.last(), ignoreCase = true) } + + // Update referral logic + updateReferralRequirement() + + triggerDependants( + source = maternalSymptoms, + passedIndex = if (hasOther) maternalSymptoms.entries!!.lastIndex else -1, + triggerIndex = maternalSymptoms.entries!!.lastIndex, + target = otherMaternalSymptoms + ) + + return getIndexById(maternalSymptoms.id) + } + + otherMaternalSymptoms.id -> { + validateAllAlphabetsSpaceOnEditText(otherMaternalSymptoms) + } motherDangerSign.id -> triggerDependants( @@ -431,17 +842,28 @@ class PncFormDataset( triggerIndex = motherDangerSign.entries!!.lastIndex, target = otherDangerSign ) + + pallor.id -> { + // Update referral requirement based on all conditions + updateReferralRequirement() + val referralFacilityIndex = getIndexById(referralFacility.id) + return if (referralFacility.required && referralFacilityIndex != -1) referralFacilityIndex else -1 + } + + vaginalBleeding.id -> { + // Update referral requirement based on all conditions + updateReferralRequirement() + val referralFacilityIndex = getIndexById(referralFacility.id) + return if (referralFacility.required && referralFacilityIndex != -1) referralFacilityIndex else -1 + } - motherDeath.id -> { - triggerDependants( - source = motherDeath, - passedIndex = index, - triggerIndex = 0, - target = listOf(deathDate, causeOfDeath, placeOfDeath), - targetSideEffect = listOf(otherDeathCause) - ) + referralFacility.id -> { + updateReferralRequirement() + getIndexById(referralFacility.id) } + motherDeath.id -> handleMotherDeathChange(index) + causeOfDeath.id -> { triggerDependants( source = causeOfDeath, @@ -451,6 +873,24 @@ class PncFormDataset( ) } + otherPpcMethod.id -> { + validateAllAlphabetsSpaceOnEditText(otherPpcMethod) + } + + otherDeathCause.id -> { + validateAllAlphabetsSpaceOnEditText(otherDeathCause) + } + + placeOfDeath.id -> { + val triggerIndex = 8 // "Other" is at index 8 + return triggerDependants( + source = placeOfDeath, + passedIndex = index, + triggerIndex = triggerIndex, + target = otherPlaceOfDeath + ) + } + // else -> -1 } @@ -471,20 +911,30 @@ class PncFormDataset( form.pncPeriod = pncPeriod.value!!.substring(4).toInt() form.pncDate = getLongFromDate(visitDate.value!!) form.ifaTabsGiven = ifaTabsGiven.value?.takeIf { it.isNotEmpty() }?.toInt() + form.calciumSupplementation = calciumSupplementation.value?.takeIf { it.isNotEmpty() }?.toInt() form.anyContraceptionMethod = anyContraceptionMethod.value?.let { it == anyContraceptionMethod.entries!!.first() } - form.contraceptionMethod = contraceptionMethod.value?.takeIf { it.isNotEmpty() } + // Persist every dropdown value in its English canonical form so the + // DB stays locale-neutral. Display-time localization happens in setUpPage. + form.contraceptionMethod = getEnglishValueInArray(R.array.pnc_contraception_method_array, contraceptionMethod.value)?.takeIf { it.isNotEmpty() } + form.sterilisationDate = dateOfSterilisation.value?.let { getLongFromDate(it) } form.otherPpcMethod = otherPpcMethod.value?.takeIf { it.isNotEmpty() } - form.motherDangerSign = motherDangerSign.value?.takeIf { it.isNotEmpty() } + form.anyDangerSign = getEnglishValueInArray(R.array.pnc_confirmation_array, anyDangerSign.value)?.takeIf { it.isNotEmpty() } + form.maternalSymptoms = getEnglishValuesInArray(R.array.pnc_maternal_symptoms_array, maternalSymptoms.value)?.takeIf { it.isNotEmpty() } + form.otherMaternalSymptoms = otherMaternalSymptoms.value?.takeIf { it.isNotEmpty() } + form.pallor = getEnglishValueInArray(R.array.pnc_pallor_array, pallor.value)?.takeIf { it.isNotEmpty() } + form.vaginalBleeding = getEnglishValueInArray(R.array.pnc_vaginal_bleeding_array, vaginalBleeding.value)?.takeIf { it.isNotEmpty() } + form.motherDangerSign = getEnglishValueInArray(R.array.pnc_mother_danger_sign_array, motherDangerSign.value)?.takeIf { it.isNotEmpty() } form.otherDangerSign = otherDangerSign.value?.takeIf { it.isNotEmpty() } - form.referralFacility = referralFacility.value?.takeIf { it.isNotEmpty() } + form.referralFacility = getEnglishValueInArray(R.array.pnc_referral_facility_array, referralFacility.value)?.takeIf { it.isNotEmpty() } form.motherDeath = motherDeath.value?.let { it == motherDeath.entries!!.first() } ?: false form.deathDate = deathDate.value?.let { getLongFromDate(it) } - form.causeOfDeath = causeOfDeath.value?.takeIf { it.isNotEmpty() } + form.causeOfDeath = getEnglishValueInArray(R.array.pnc_death_cause_array, causeOfDeath.value)?.takeIf { it.isNotEmpty() } form.otherDeathCause = otherDeathCause.value?.takeIf { it.isNotEmpty() } - form.placeOfDeath = placeOfDeath.value?.takeIf { it.isNotEmpty() } + form.placeOfDeath = getEnglishValueInArray(R.array.pnc_death_place_array, placeOfDeath.value)?.takeIf { it.isNotEmpty() } + form.otherPlaceOfDeath = otherPlaceOfDeath.value?.takeIf { it.isNotEmpty() } form.remarks = remarks.value?.takeIf { it.isNotEmpty() } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanAncAbortionDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanAncAbortionDataset.kt new file mode 100644 index 000000000..24e86b03f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanAncAbortionDataset.kt @@ -0,0 +1,265 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.utils.ImgUtils +import org.piramalswasthya.cho.helpers.getCurrentGestationalAgeFormatted +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.PregnantWomanAncCache +import org.piramalswasthya.cho.model.PregnantWomanRegistrationCache +import java.util.concurrent.TimeUnit + +class PregnantWomanAncAbortionDataset( + context: Context, + currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + private val appContext = context.applicationContext + + private lateinit var registration: PregnantWomanRegistrationCache + + private val visitDate = FormElement( + id = 1, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.visit_date), + required = true, + max = System.currentTimeMillis() + ) + private val weekOfPregnancy = FormElement( + id = 2, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.weeks_of_pregnancy), + required = false + ) + private val abortionDate = FormElement( + id = 3, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.abortion_date), + required = false + ) + private val abortionType = FormElement( + id = 4, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.abortion_type), + required = false + ) + private val abortionFacility = FormElement( + id = 5, + inputType = InputType.TEXT_VIEW, + title = resources.getString(R.string.facility_place_of_abortion), + required = false + ) + private val serialNoAsPerAdmission = FormElement( + id = 6, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.serial_no_as_per_admission_evacuation_register), + required = false, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_SIGNED, + etMaxLength = 10 + ) + private val methodOfTermination = FormElement( + id = 7, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.method_of_termination), + required = true, + entries = resources.getStringArray(R.array.anc_method_of_termination) + ) + private val terminationDoneBy = FormElement( + id = 8, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.termination_done_by), + required = true, + entries = resources.getStringArray(R.array.anc_termination_done_by) + ) + private val planningHeadline = FormElement( + id = 25, + inputType = InputType.HEADLINE, + title = resources.getString(R.string.what_family_planning_method_has_been_chosen_after_the_abortion), + required = false + ) + private val isPaiucd = FormElement( + id = 9, + inputType = InputType.RADIO, + title = " ", + required = false, + hasDependants = true, + orientation = 1, + entries = resources.getStringArray(R.array.abortion_isplaning_array) + ) + private val isYesOrNo = FormElement( + id = 23, + inputType = InputType.RADIO, + title = " ", + required = true, + hasDependants = true, + orientation = 1, + entries = resources.getStringArray(R.array.anc_confirmation_array) + ) + private val dateOfSterilization = FormElement( + id = 24, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.date_of_sterilisation), + required = true, + max = System.currentTimeMillis() + ) + private val remarks = FormElement( + id = 10, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.remarks), + required = false, + etMaxLength = 100 + ) + private val abortionDischargeSummaryImg1 = FormElement( + id = 21, + inputType = InputType.FILE_UPLOAD, + title = resources.getString(R.string.abortion_discharge_summary_1), + required = false + ) + private val abortionDischargeSummaryImg2 = FormElement( + id = 22, + inputType = InputType.FILE_UPLOAD, + title = resources.getString(R.string.abortion_discharge_summary_2), + required = false + ) + + suspend fun setUpPage( + registrationRecord: PregnantWomanRegistrationCache, + abortionAnc: PregnantWomanAncCache + ) { + registration = registrationRecord + val list = mutableListOf( + visitDate, + weekOfPregnancy, + abortionDate, + abortionType, + abortionFacility, + serialNoAsPerAdmission, + methodOfTermination, + terminationDoneBy, + planningHeadline, + isPaiucd, + remarks, + abortionDischargeSummaryImg1, + abortionDischargeSummaryImg2 + ) + + val now = System.currentTimeMillis() + val oneYearMillis = TimeUnit.DAYS.toMillis(365) + abortionDate.min = registration.lmpDate + TimeUnit.DAYS.toMillis(5 * 7 + 1) + abortionDate.max = minOf(now, registration.lmpDate + TimeUnit.DAYS.toMillis(21 * 7)) + + visitDate.min = abortionAnc.abortionDate ?: (now - oneYearMillis) + dateOfSterilization.min = abortionAnc.abortionDate + + visitDate.value = abortionAnc.visitDate?.let { getDateFromLong(it) } + weekOfPregnancy.value = getCurrentGestationalAgeFormatted(registration.lmpDate) + abortionType.value = abortionAnc.abortionType + abortionFacility.value = abortionAnc.abortionFacility + abortionDate.value = abortionAnc.abortionDate?.let { getDateFromLong(it) } + serialNoAsPerAdmission.value = abortionAnc.serialNo + methodOfTermination.value = abortionAnc.methodOfTermination + terminationDoneBy.value = abortionAnc.terminationDoneBy + remarks.value = abortionAnc.remarks + abortionDischargeSummaryImg1.value = abortionAnc.abortionImg1 + abortionDischargeSummaryImg2.value = abortionAnc.abortionImg2 + isPaiucd.value = when (abortionAnc.isPaiucdId) { + 1 -> isPaiucd.entries?.getOrNull(0) + 2 -> isPaiucd.entries?.getOrNull(1) + else -> abortionAnc.isPaiucd + } + if (abortionAnc.isPaiucdId != null && abortionAnc.isPaiucdId != 0) { + list.add(list.indexOf(isPaiucd) + 1, isYesOrNo) + isYesOrNo.value = if (abortionAnc.isYesOrNo == true) { + isYesOrNo.entries?.getOrNull(0) + } else { + isYesOrNo.entries?.getOrNull(1) + } + if (abortionAnc.isPaiucdId == 2 && abortionAnc.isYesOrNo == true) { + list.add(list.indexOf(isYesOrNo) + 1, dateOfSterilization) + dateOfSterilization.value = abortionAnc.dateSterilisation?.let { getDateFromLong(it) } + } + } + + setUpPage(list) + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + visitDate.id -> -1 + + isPaiucd.id -> { + isYesOrNo.value = null + triggerDependants( + source = isPaiucd, + addItems = listOf(isYesOrNo), + removeItems = listOf(dateOfSterilization) + ) + } + + isYesOrNo.id -> { + val paiucdSecondOption = (isPaiucd.entries?.indexOf(isPaiucd.value ?: "") ?: -1) == 1 + if (isYesOrNo.value == "Yes" && paiucdSecondOption) { + triggerDependants( + source = isYesOrNo, + addItems = listOf(dateOfSterilization), + removeItems = emptyList() + ) + } else { + triggerDependants( + source = isYesOrNo, + addItems = emptyList(), + removeItems = listOf(dateOfSterilization) + ) + } + } + else -> -1 + } + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as PregnantWomanAncCache).let { cache -> + cache.visitDate = visitDate.value?.let { getLongFromDate(it) } + cache.lmpDate = registration.lmpDate + cache.serialNo = serialNoAsPerAdmission.value + cache.methodOfTermination = methodOfTermination.value + cache.methodOfTerminationId = methodOfTermination.entries?.indexOf(methodOfTermination.value ?: "")?.takeIf { it >= 0 } + cache.terminationDoneBy = terminationDoneBy.value + cache.terminationDoneById = terminationDoneBy.entries?.indexOf(terminationDoneBy.value ?: "")?.takeIf { it >= 0 } + cache.isPaiucd = isPaiucd.value + cache.isPaiucdId = (isPaiucd.entries?.indexOf(isPaiucd.value ?: "") ?: -1) + 1 + cache.isYesOrNo = isYesOrNo.entries?.indexOf(isYesOrNo.value ?: "") == 0 + cache.remarks = remarks.value + cache.dateSterilisation = dateOfSterilization.value?.let { getLongFromDate(it) } + // Persist the picked image to internal storage and save only its + // path. Base64 encoding happens at upload time so the row stays + // small enough for SQLite's 2 MB CursorWindow. + cache.abortionImg1 = abortionDischargeSummaryImg1.value?.let { + ImgUtils.saveImageToInternalStorage(appContext, it) + } + cache.abortionImg2 = abortionDischargeSummaryImg2.value?.let { + ImgUtils.saveImageToInternalStorage(appContext, it) + } + } + } + + fun getWeeksOfPregnancyIndex(): Int = getIndexById(weekOfPregnancy.id) + + fun setImageUriToFormElement(formId: Int, uri: String) { + when (formId) { + 21 -> abortionDischargeSummaryImg1.value = uri + 22 -> abortionDischargeSummaryImg2.value = uri + } + } + + fun getImageFieldIndex(formId: Int): Int = getIndexById(formId) + + fun getAbortionImageFieldValue(formId: Int): String? { + return when (formId) { + abortionDischargeSummaryImg1.id -> abortionDischargeSummaryImg1.value + abortionDischargeSummaryImg2.id -> abortionDischargeSummaryImg2.value + else -> null + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanAncVisitDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanAncVisitDataset.kt index 831c1ba5a..6ef074af3 100644 --- a/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanAncVisitDataset.kt +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanAncVisitDataset.kt @@ -5,6 +5,7 @@ import org.piramalswasthya.cho.R import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.helpers.Konstants import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.helpers.getCurrentGestationalAgeFormatted import org.piramalswasthya.cho.helpers.getWeeksOfPregnancy import org.piramalswasthya.cho.model.FormElement import org.piramalswasthya.cho.model.InputType @@ -18,6 +19,8 @@ class PregnantWomanAncVisitDataset( context: Context, currentLanguage: Languages ) : Dataset(context, currentLanguage) { + private val allTdDosesMessage: String get() = resources.getString(R.string.all_td_doses_message) + private var lastAncVisitDate: Long = 0L private lateinit var regis: PregnantWomanRegistrationCache @@ -77,52 +80,23 @@ class PregnantWomanAncVisitDataset( private val weight = FormElement( id = 8, inputType = InputType.EDIT_TEXT, - title = "Weight of PW (Kg) at time Registration", + title = "Weight of PW (Kg)", arrayId = -1, etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, etMaxLength = 3, - required = false, + required = true, min = 30, max = 200 ) private val bp = FormElement( id = 9, inputType = InputType.EDIT_TEXT, - title = "BP of PW – Systolic/ Diastolic (mm Hg) ", -// etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + title = "BP (Systolic/Diastolic) mmHg", + etInputType = android.text.InputType.TYPE_CLASS_PHONE, etMaxLength = 7, - required = false, + required = true, + hasDependants = true ) -// private val bpDiastolic = FormElement( -// id = 10, -// inputType = InputType.EDIT_TEXT, -// title = "BP of PW (mm Hg) – Diastolic", -// etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, -// etMaxLength = 3, -// required = false, -// min = 30, -// max = 200 -// ) -// private val bpSystolicReq = FormElement( -// id = 119, -// inputType = InputType.EDIT_TEXT, -// title = "BP of PW (mm Hg) – Systolic", -// etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, -// etMaxLength = 3, -// required = true, -// min = 50, -// max = 300 -// ) -// private val bpDiastolicReq = FormElement( -// id = 120, -// inputType = InputType.EDIT_TEXT, -// title = "BP of PW (mm Hg) – Diastolic", -// etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, -// etMaxLength = 3, -// required = true, -// min = 30, -// max = 200 -// ) private val pulseRate = FormElement( id = 11, @@ -141,7 +115,8 @@ class PregnantWomanAncVisitDataset( etMaxLength = 4, minDecimal = 2.0, maxDecimal = 15.0, - required = false, + required = true, + hasDependants = true ) private val fundalHeight = FormElement( @@ -154,10 +129,11 @@ class PregnantWomanAncVisitDataset( ) private val urineAlbumin = FormElement( id = 14, - inputType = InputType.RADIO, + inputType = InputType.DROPDOWN, title = "Urine Albumin", - entries = arrayOf("Absent", "Present"), + entries = arrayOf("Negative", "Trace", "+", "++", "+++"), required = false, + hasDependants = true ) private val randomBloodSugarTest = FormElement( id = 15, @@ -184,7 +160,7 @@ class PregnantWomanAncVisitDataset( private val dateOfTTOrTdBooster = FormElement( id = 18, inputType = InputType.DATE_PICKER, - title = "Date of Td TT (Boooster Dose)", + title = "Date of Td TT (Booster Dose)", required = false, hasDependants = true, @@ -235,7 +211,7 @@ class PregnantWomanAncVisitDataset( id = 23, inputType = InputType.EDIT_TEXT, title = "Any other High Risk conditions", - required = true, + required = false, ) private val highRiskReferralFacility = FormElement( id = 24, @@ -269,7 +245,8 @@ class PregnantWomanAncVisitDataset( private val maternalDeath = FormElement( id = 27, inputType = InputType.RADIO, title = "Maternal Death", entries = arrayOf( "No", "Yes", - ), required = false, hasDependants = true + ), required = false, hasDependants = true, + value = "No" ) private val maternalDeathProbableCause = FormElement( id = 28, @@ -313,6 +290,104 @@ class PregnantWomanAncVisitDataset( hasDependants = false ) + private val bloodSugarFasting = FormElement( + id = 33, + inputType = InputType.EDIT_TEXT, + title = "Blood Sugar (Fasting) mg/dL", + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + etMaxLength = 3, + required = false, + min = 40, + max = 400, + hasDependants = true + ) + + private val urineSugar = FormElement( + id = 34, + inputType = InputType.DROPDOWN, + title = "Urine Sugar", + entries = arrayOf("Negative", "Trace", "+", "++", "+++"), + required = false, + hasDependants = true + ) + + private val fetalHeartRate = FormElement( + id = 35, + inputType = InputType.EDIT_TEXT, + title = "Fetal Heart Rate (FHR) bpm", + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL, + etMaxLength = 5, + required = false, + minDecimal = 40.0, + maxDecimal = 200.0, + hasDependants = true + ) + + private val calciumGiven = FormElement( + id = 36, + inputType = InputType.EDIT_TEXT, + title = "Calcium Given", + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_NORMAL, + etMaxLength = 3, + required = false, + min = 0, + max = 400 + ) + + private val dangerSigns = FormElement( + id = 37, + inputType = InputType.CHECKBOXES, + title = "Danger Signs", + entries = arrayOf( + "None", + "Vaginal Bleeding", + "Swelling of hands, feet or face", + "Severe Headache", + "Blurred Vision", + "Convulsions/ seizures", + "Severe abdominal pain", + "Fever > 38°C", + "Painful urination/ Burning", + "Reduced fetal movement", + "Vaginal fluid leakage", + "Persistent vomiting", + "Breathlessness/ chest pain" + ), + required = false, + hasDependants = true + ) + + private val counsellingProvided = FormElement( + id = 38, + inputType = InputType.RADIO, + title = "Counselling Provided", + entries = arrayOf("No", "Yes"), + required = false, + hasDependants = true + ) + + private val counsellingTopics = FormElement( + id = 39, + inputType = InputType.CHECKBOXES, + title = "Which counselling was provided?", + entries = arrayOf( + "Nutrition", + "Birth Preparedness and Complication readiness", + "Identification of danger signs", + "Medication compliance (IFA/ Calcium/ other medications)", + "Immunization", + "Hygiene and Infection Prevention" + ), + required = false + ) + + private val nextAncVisitDate = FormElement( + id = 40, + inputType = InputType.DATE_PICKER, + title = "Next ANC Visit Date", + required = false + ) + private var toggleBp = false fun resetBpToggle() { @@ -330,57 +405,137 @@ class PregnantWomanAncVisitDataset( saved: PregnantWomanAncCache? ) { this.regis = regis - val list = mutableListOf( - lmpDate, - ancDate, - weekOfPregnancy, - ancVisit, + val ancFields = listOf( isAborted, weight, bp, pulseRate, hb, - fundalHeight, + bloodSugarFasting, +// fundalHeight, urineAlbumin, + urineSugar, + fetalHeartRate, randomBloodSugarTest, dateOfTTOrTd1, - dateOfTTOrTd2, - dateOfTTOrTdBooster, +// dateOfTTOrTd2, // Added dynamically +// dateOfTTOrTdBooster, // Added dynamically numFolicAcidTabGiven, numIfaAcidTabGiven, + calciumGiven, anyHighRisk, highRiskReferralFacility, hrpConfirm, + dangerSigns, + counsellingProvided, + nextAncVisitDate + ).toMutableList() + + val list = mutableListOf( + lmpDate, + ancDate, + weekOfPregnancy, + ancVisit, maternalDeath - ) + + if (saved?.maternalDeath != true) { + list.addAll(ancFields) + } + + // Dynamic Field Insertion based on History + val td1Index = list.indexOf(dateOfTTOrTd1) + if (td1Index != -1) { + // If Td1 is present (history or current), add Td2 + if (regis.tt1 != null || dateOfTTOrTd1.value != null) { + if (!list.contains(dateOfTTOrTd2)) { + val td1Date = regis.tt1 ?: getLongFromDate(dateOfTTOrTd1.value) + if (td1Date != null) { + val minTd2Date = td1Date + TimeUnit.DAYS.toMillis(4 * 7) + val maxTd2Date = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + // Always add, just set type based on validity + if (minTd2Date <= maxTd2Date) { + dateOfTTOrTd2.inputType = InputType.DATE_PICKER + dateOfTTOrTd2.min = minTd2Date + dateOfTTOrTd2.max = maxTd2Date + } else { + dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + } + list.add(td1Index + 1, dateOfTTOrTd2) + } + } + } + // If Td2 is present (history or current), add Booster + if (regis.tt2 != null || dateOfTTOrTd2.value != null) { + val td2Index = list.indexOf(dateOfTTOrTd2) + if (td2Index != -1) { + if (!list.contains(dateOfTTOrTdBooster)) { + val minBoosterDate = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + val maxBoosterDate = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + if (minBoosterDate <= maxBoosterDate) { + dateOfTTOrTdBooster.inputType = InputType.DATE_PICKER + dateOfTTOrTdBooster.min = minBoosterDate + dateOfTTOrTdBooster.max = maxBoosterDate + } else { + dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW + } + list.add(td2Index + 1, dateOfTTOrTdBooster) + } + } + } + } + lmpDate.value = this.regis.getDateStringFromLong(this.regis.lmpDate) - abortionDate.min = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7 + 1) - dateOfTTOrTd1.min = abortionDate.min - dateOfTTOrTdBooster.min = abortionDate.min + abortionDate.min = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + // TD Dose 1: 5 weeks from LMP to 36 weeks of LMP, ≤ today + dateOfTTOrTd1.min = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + dateOfTTOrTd1.max = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + // TT Booster: 5 weeks from LMP to 36 weeks of LMP, ≤ today + dateOfTTOrTdBooster.min = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + dateOfTTOrTdBooster.max = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + // Abortion date: 5–20 weeks from LMP, ≤ today abortionDate.max = - minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(21 * 7)) - dateOfTTOrTd1.max = abortionDate.max - dateOfTTOrTd2.max = abortionDate.max - dateOfTTOrTdBooster.max = abortionDate.max + minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(20 * 7)) + + // Next ANC Visit Date range (> today, <= EDD) + val edd = getEddFromLmp(regis.lmpDate) + val tomorrow = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1) + nextAncVisitDate.max = edd + // Allow saved next visit date when editing, but still cap at EDD and ensure >= tomorrow for new visits + saved?.let { savedAnc -> + val savedCandidate = savedAnc.nextAncVisitDate ?: (savedAnc.ancDate + TimeUnit.DAYS.toMillis(1)) + nextAncVisitDate.min = minOf(maxOf(tomorrow, savedCandidate), edd) + } ?: run { + nextAncVisitDate.min = minOf(tomorrow, edd) + } + - if (lastAnc == null) - list.remove(dateOfTTOrTd2) setUpTdX() ben?.let { ancDate.min = regis.lmpDate + TimeUnit.DAYS.toMillis(7 * Konstants.minAnc1Week.toLong() + 1) - ancVisit.entries = arrayOf("1", "2", "3", "4") + ancVisit.entries = when (visitNumber) { + 1 -> arrayOf("1", "2", "3", "4") + 2 -> arrayOf("2", "3", "4") + 3 -> arrayOf("3", "4") + else -> arrayOf(visitNumber.toString()) // Visit 4, 5, 6, etc. + } + // For visit 4 and higher, change dropdown to read-only text field + if (visitNumber >= 4) { + ancVisit.inputType = InputType.TEXT_VIEW + } lastAnc?.let { last -> ancDate.min = last.ancDate + TimeUnit.DAYS.toMillis(4 * 7) - ancVisit.entries = arrayOf(2, 3, 4).filter { - it > last.visitNumber - }.map { it.toString() }.toTypedArray() - lastAncVisitDate = last.ancDate } - ancDate.max = - minOf(getEddFromLmp(regis.lmpDate), System.currentTimeMillis()) + // ANC date max: ≤42 weeks from LMP, ≤ EDD (40 weeks), and ≤ today + ancDate.max = minOf( + regis.lmpDate + TimeUnit.DAYS.toMillis(42 * 7), // 42 weeks from LMP + getEddFromLmp(regis.lmpDate), // EDD (40 weeks) + System.currentTimeMillis() // Today + ) ancDate.value = getDateFromLong(ancDate.max!!) maternalDateOfDeath.min = maxOf(regis.lmpDate, lastAncVisitDate) + TimeUnit.DAYS.toMillis(1) @@ -390,58 +545,92 @@ class PregnantWomanAncVisitDataset( // ancDate.value = getDateFromLong(System.currentTimeMillis()) if (saved == null) { - weekOfPregnancy.value = ancDate.value?.let { + ancDate.value?.let { val long = getLongFromDate(it) val weeks = getWeeksOfPregnancy(long, regis.lmpDate) if (weeks >= Konstants.minWeekToShowDelivered) { - list.add(deliveryDone) + if (saved?.maternalDeath != true) list.add(deliveryDone) } if (weeks <= 12) { - list.remove(fundalHeight) + fundalHeight.inputType = InputType.TEXT_VIEW + fundalHeight.value = null + list.remove(numIfaAcidTabGiven) } else { + fundalHeight.inputType = InputType.EDIT_TEXT list.remove(numFolicAcidTabGiven) } - weeks.toString() + // Disable Calcium up to 14 weeks + if (weeks <= 14) { + list.remove(calciumGiven) + } + if (weeks > Konstants.maxWeekToShowAbortion) { + list.remove(isAborted) + } } + weekOfPregnancy.value = getCurrentGestationalAgeFormatted(regis.lmpDate) } - ancVisit.value = visitNumber.toString() + ancVisit.value = if (ancVisit.entries?.contains(visitNumber.toString()) == true) visitNumber.toString() else (ancVisit.entries?.firstOrNull() ?: visitNumber.toString()) saved?.let { savedAnc -> val woP = getWeeksOfPregnancy(savedAnc.ancDate, regis.lmpDate) - if (woP <= 12) { - list.remove(fundalHeight) - list.remove(numIfaAcidTabGiven) - } else { - list.remove(numFolicAcidTabGiven) - } - if (woP >= Konstants.minWeekToShowDelivered) { - if (!list.contains(deliveryDone)) list.add(deliveryDone) + + if (savedAnc.maternalDeath != true) { + if (woP <= 12) { + fundalHeight.inputType = InputType.TEXT_VIEW + fundalHeight.value = null + + list.remove(numIfaAcidTabGiven) + } else { + fundalHeight.inputType = InputType.EDIT_TEXT + list.remove(numFolicAcidTabGiven) + } + // Disable Calcium up to 14 weeks when editing saved visits too + if (woP <= 14) { + list.remove(calciumGiven) + } + if (woP >= Konstants.minWeekToShowDelivered) { + if (!list.contains(deliveryDone)) list.add(deliveryDone) + } + if (woP > Konstants.maxWeekToShowAbortion) { + list.remove(isAborted) + } } + ancDate.value = getDateFromLong(savedAnc.ancDate) - weekOfPregnancy.value = woP.toString() - isAborted.value = - if (savedAnc.isAborted) isAborted.entries!!.last() else isAborted.entries!!.first() - if (savedAnc.isAborted) { - abortionType.value = abortionType.getStringFromPosition(savedAnc.abortionTypeId) - abortionFacility.value = - abortionFacility.getStringFromPosition(savedAnc.abortionFacilityId) - abortionDate.value = savedAnc.abortionDate?.let { getDateFromLong(it) } - list.addAll( - list.indexOf(isAborted) + 1, - listOf(abortionType, abortionFacility, abortionDate) - ) + weekOfPregnancy.value = getCurrentGestationalAgeFormatted(regis.lmpDate) + + if (list.contains(isAborted)) { + isAborted.value = + if (savedAnc.isAborted) isAborted.entries!!.last() else isAborted.entries!!.first() + if (savedAnc.isAborted) { + abortionType.value = abortionType.getStringFromPosition(savedAnc.abortionTypeId) + abortionFacility.value = + abortionFacility.getStringFromPosition(savedAnc.abortionFacilityId) + abortionDate.value = savedAnc.abortionDate?.let { getDateFromLong(it) } + list.removeAll(abortionDisabledFields()) + list.addAll( + list.indexOf(isAborted) + 1, + listOf(abortionType, abortionFacility, abortionDate) + ) + } } + weight.value = savedAnc.weight?.toString() bp.value = if (savedAnc.bpSystolic == null || savedAnc.bpDiastolic == null) null else "${savedAnc.bpSystolic}/${savedAnc.bpDiastolic}" -// bpDiastolic.value = savedAnc.bpDiastolic?.toString() pulseRate.value = savedAnc.pulseRate hb.value = savedAnc.hb?.toString() fundalHeight.value = savedAnc.fundalHeight?.toString() - urineAlbumin.value = urineAlbumin.getStringFromPosition(savedAnc.urineAlbuminId) + // Map legacy "Present/Absent" values to new dropdown values + urineAlbumin.value = when (savedAnc.urineAlbumin?.trim()) { + "Present" -> "+" + "Absent" -> "Negative" + "Negative", "Trace", "+", "++", "+++" -> savedAnc.urineAlbumin + else -> urineAlbumin.getStringFromPosition(savedAnc.urineAlbuminId) + } randomBloodSugarTest.value = randomBloodSugarTest.getStringFromPosition(savedAnc.randomBloodSugarTestId) dateOfTTOrTd1.value = regis.tt1?.let { getDateFromLong(it) } @@ -449,29 +638,37 @@ class PregnantWomanAncVisitDataset( dateOfTTOrTdBooster.value = regis.ttBooster?.let { getDateFromLong(it) } numFolicAcidTabGiven.value = savedAnc.numFolicAcidTabGiven.toString() numIfaAcidTabGiven.value = savedAnc.numIfaAcidTabGiven.toString() - savedAnc.anyHighRisk?.let { - anyHighRisk.value = - if (it) anyHighRisk.entries!!.last() else anyHighRisk.entries!!.first() - if (it) { - highRiskCondition.value = - highRiskCondition.getStringFromPosition(savedAnc.highRiskId) - list.add(list.indexOf(anyHighRisk) + 1, highRiskCondition) - if (highRiskCondition.value == highRiskCondition.entries!!.last()) { - otherHighRiskCondition.value = savedAnc.otherHighRisk - list.add(list.indexOf(highRiskCondition) + 1, otherHighRiskCondition) + + if (list.contains(anyHighRisk)) { + savedAnc.anyHighRisk?.let { + anyHighRisk.value = + if (it) anyHighRisk.entries!!.last() else anyHighRisk.entries!!.first() + if (it) { + highRiskCondition.value = + highRiskCondition.getStringFromPosition(savedAnc.highRiskId) + list.add(list.indexOf(anyHighRisk) + 1, highRiskCondition) + if (highRiskCondition.value == highRiskCondition.entries!!.last()) { + otherHighRiskCondition.value = savedAnc.otherHighRisk + list.add(list.indexOf(highRiskCondition) + 1, otherHighRiskCondition) + } } } + highRiskCondition.required = + (anyHighRisk.value == anyHighRisk.entries!!.last()) } highRiskReferralFacility.value = highRiskReferralFacility.getStringFromPosition(savedAnc.referralFacilityId) - hrpConfirm.value = - savedAnc.hrpConfirmed?.let { if (it) hrpConfirm.entries!!.last() else hrpConfirm.entries!!.first() } - if (savedAnc.hrpConfirmed == true) { - hrpConfirmedBy.value = - hrpConfirmedBy.getStringFromPosition(savedAnc.hrpConfirmedById) - list.add(list.indexOf(hrpConfirm) + 1, hrpConfirmedBy) + if (list.contains(hrpConfirm)) { + hrpConfirm.value = + savedAnc.hrpConfirmed?.let { if (it) hrpConfirm.entries!!.last() else hrpConfirm.entries!!.first() } + if (savedAnc.hrpConfirmed == true) { + hrpConfirmedBy.value = + hrpConfirmedBy.getStringFromPosition(savedAnc.hrpConfirmedById) + list.add(list.indexOf(hrpConfirm) + 1, hrpConfirmedBy) + } } + savedAnc.maternalDeath?.let { maternalDeath.value = if (it) maternalDeath.entries!!.last() else maternalDeath.entries!!.first() @@ -480,6 +677,7 @@ class PregnantWomanAncVisitDataset( maternalDeathProbableCause.getStringFromPosition(savedAnc.maternalDeathProbableCauseId) maternalDateOfDeath.value = savedAnc.deathDate?.let { it1 -> getDateFromLong(it1) } + // maternalDeath is at index 4 now. list.addAll( list.indexOf(maternalDeath) + 1, listOf(maternalDeathProbableCause, maternalDateOfDeath) @@ -492,44 +690,349 @@ class PregnantWomanAncVisitDataset( ) } } - deliveryDone.value = - if (savedAnc.pregnantWomanDelivered == true) deliveryDone.entries!!.first() else deliveryDone.entries!!.last() + if (list.contains(deliveryDone)) { // Only update if visible + deliveryDone.value = + if (savedAnc.pregnantWomanDelivered == true) deliveryDone.entries!!.first() else deliveryDone.entries!!.last() + } + + // Populate new ANC fields when editing saved visits + bloodSugarFasting.value = savedAnc.bloodSugarFasting?.toString() + urineSugar.value = savedAnc.urineSugar + fetalHeartRate.value = savedAnc.fetalHeartRate?.toString() + calciumGiven.value = savedAnc.calciumGiven.takeIf { it > 0 }?.toString() + dangerSigns.value = savedAnc.dangerSigns + + if (list.contains(counsellingProvided)) { + counsellingProvided.value = savedAnc.counsellingProvided?.let { + if (it) counsellingProvided.entries!!.last() else counsellingProvided.entries!!.first() + } + if (savedAnc.counsellingProvided == true) { + counsellingTopics.value = savedAnc.counsellingTopics + list.add(list.indexOf(counsellingProvided) + 1, counsellingTopics) + } + } + nextAncVisitDate.value = savedAnc.nextAncVisitDate?.let { getDateFromLong(it) } } setUpPage(list) } private fun setUpTdX() { + val allTdMessage = resources.getString(R.string.all_td_doses_message) // Ensure this string is available, or use the property if defined + + // Reset defaults + dateOfTTOrTd1.inputType = InputType.DATE_PICKER + dateOfTTOrTd2.inputType = InputType.TEXT_VIEW // Default disabled + dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW // Default disabled + + // Clear messages initially (safeguard) + if (dateOfTTOrTd2.title.contains(allTdDosesMessage)) { + dateOfTTOrTd2.title = dateOfTTOrTd2.title.replace(allTdDosesMessage, "") + } + if (dateOfTTOrTdBooster.title.contains(allTdDosesMessage)) { + dateOfTTOrTdBooster.title = dateOfTTOrTdBooster.title.replace(allTdDosesMessage, "") + } + if (regis.ttBooster != null) { - dateOfTTOrTdBooster.value = getDateFromLong(regis.ttBooster!!) + // Booster given (implies Td1 and Td2 given or not needed due to history, but if Booster is there, we show it) + dateOfTTOrTd1.value = regis.tt1?.let { getDateFromLong(it) } dateOfTTOrTd1.inputType = InputType.TEXT_VIEW + + dateOfTTOrTd2.value = regis.tt2?.let { getDateFromLong(it) } dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + + dateOfTTOrTdBooster.value = getDateFromLong(regis.ttBooster!!) dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW - } else if (regis.tt1 == null) { + + // Add message + if (!dateOfTTOrTdBooster.title.contains(allTdDosesMessage)) { + dateOfTTOrTdBooster.title += allTdDosesMessage + } + } else if (regis.tt2 != null) { + // Td2 given (implies Td1 given) + dateOfTTOrTd1.value = regis.tt1?.let { getDateFromLong(it) } + dateOfTTOrTd1.inputType = InputType.TEXT_VIEW + + dateOfTTOrTd2.value = getDateFromLong(regis.tt2!!) dateOfTTOrTd2.inputType = InputType.TEXT_VIEW - } else { - dateOfTTOrTd1.value = getDateFromLong(regis.tt1!!) + // Message on Td2 + if (!dateOfTTOrTd2.title.contains(allTdDosesMessage)) { + dateOfTTOrTd2.title += allTdDosesMessage + } + + // Booster Disabled (since Td2 present means series complete) dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW + + } else if (regis.tt1 != null) { + // Td1 given + dateOfTTOrTd1.value = getDateFromLong(regis.tt1!!) dateOfTTOrTd1.inputType = InputType.TEXT_VIEW - if (regis.tt2 == null) { - dateOfTTOrTd2.min = regis.tt1!! + TimeUnit.DAYS.toMillis(28) - dateOfTTOrTd2.max = min(System.currentTimeMillis(), getEddFromLmp(regis.lmpDate)) + + // Td2 Enabled + dateOfTTOrTd2.inputType = InputType.DATE_PICKER + dateOfTTOrTd2.min = regis.tt1!! + TimeUnit.DAYS.toMillis(28) // 4 weeks after Td1 + dateOfTTOrTd2.max = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + // Booster Disabled (Need Td2 first) + dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW + } else { + // No Td given - Enable BOTH Td1 and Booster + dateOfTTOrTd1.inputType = InputType.DATE_PICKER + // Td1 Range: 5 weeks from LMP to 36 weeks, max Today + dateOfTTOrTd1.min = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + dateOfTTOrTd1.max = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + + // Booster Range: 5 weeks from LMP to 36 weeks, max Today + dateOfTTOrTdBooster.inputType = InputType.DATE_PICKER + dateOfTTOrTdBooster.min = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + dateOfTTOrTdBooster.max = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + } + } + + // ANC detail fields disabled when isAborted = Yes (per spec). + private fun abortionDisabledFields(): List = listOf( + weight, bp, pulseRate, hb, fundalHeight, + urineAlbumin, randomBloodSugarTest, + dateOfTTOrTd1, dateOfTTOrTd2, dateOfTTOrTdBooster, + numFolicAcidTabGiven, + anyHighRisk, highRiskCondition, highRiskReferralFacility, + hrpConfirm, hrpConfirmedBy, + maternalDeathProbableCause, otherMaternalDeathProbableCause, maternalDateOfDeath, + deliveryDone, + nextAncVisitDate + ) + + private fun buildVisibleAncFields(): MutableList { + val ancFields = mutableListOf( + isAborted, + weight, + bp, + pulseRate, + hb, + bloodSugarFasting, + urineAlbumin, + urineSugar, + fetalHeartRate, + randomBloodSugarTest, + dateOfTTOrTd1, + numFolicAcidTabGiven, + numIfaAcidTabGiven, + calciumGiven, + anyHighRisk, + dangerSigns, + highRiskReferralFacility, + hrpConfirm, + counsellingProvided, + nextAncVisitDate + ) + + ancDate.value?.let { + val long = getLongFromDate(it) + val weeks = getWeeksOfPregnancy(long, regis.lmpDate) + + if (weeks <= 12) { + fundalHeight.inputType = InputType.TEXT_VIEW + fundalHeight.value = null + ancFields.remove(numIfaAcidTabGiven) } else { - dateOfTTOrTd2.value = getDateFromLong(regis.tt2!!) - dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + fundalHeight.inputType = InputType.EDIT_TEXT + ancFields.remove(numFolicAcidTabGiven) + } + + if (weeks <= 14) { + ancFields.remove(calciumGiven) + } + + if (weeks >= Konstants.minWeekToShowDelivered) { + ancFields.add(deliveryDone) + } + + if (weeks > Konstants.maxWeekToShowAbortion) { + ancFields.remove(isAborted) } } + + if (isAborted.value == isAborted.entries!!.last()) { + ancFields.addAll(ancFields.indexOf(isAborted) + 1, listOf(abortionType, abortionFacility, abortionDate)) + } + + if (anyHighRisk.value == anyHighRisk.entries!!.last()) { + val hrIndex = ancFields.indexOf(anyHighRisk) + if (hrIndex != -1) { + ancFields.add(hrIndex + 1, highRiskCondition) + if (highRiskCondition.value == highRiskCondition.entries!!.last()) { + ancFields.add(hrIndex + 2, otherHighRiskCondition) + } + } + } + + if (hrpConfirm.value == hrpConfirm.entries!!.last()) { + ancFields.add(ancFields.indexOf(hrpConfirm) + 1, hrpConfirmedBy) + } + + if (counsellingProvided.value == counsellingProvided.entries!!.last()) { + ancFields.add(ancFields.indexOf(counsellingProvided) + 1, counsellingTopics) + } + + // Restore canonical Td values, inputTypes, ranges, and suffix from regis history. + // Without this, Yes→No leaves Booster as a DATE_PICKER even when regis.tt2 marks + // the series complete (or regis.ttBooster marks it already given). + setUpTdX() + + val td1Index = ancFields.indexOf(dateOfTTOrTd1) + if (td1Index != -1) { + if (regis.tt1 != null || dateOfTTOrTd1.value != null) { + if (!ancFields.contains(dateOfTTOrTd2)) { + ancFields.add(td1Index + 1, dateOfTTOrTd2) + } + } + val td2Index = ancFields.indexOf(dateOfTTOrTd2) + if (td2Index != -1 && (regis.tt2 != null || dateOfTTOrTd2.value != null)) { + if (!ancFields.contains(dateOfTTOrTdBooster)) { + ancFields.add(td2Index + 1, dateOfTTOrTdBooster) + } + } + } + + return ancFields } + private fun checkHrpStatus(): Int { + if (isAborted.value == isAborted.entries!!.last()) { + return -1 + } + var isHighRisk = false + var highRiskReason: String? = null + + // Check BP for high risk + checkBpForHighRisk()?.let { + isHighRisk = true + highRiskReason = it + } + + // Check HB for severe anaemia + if (!isHighRisk) { + checkHbForSevereAnaemia()?.let { + isHighRisk = true + highRiskReason = it + } + } + + // Check danger signs + if (!isHighRisk) { + checkDangerSignsForHighRisk()?.let { + isHighRisk = true + highRiskReason = it + } + } + + // Apply to UI + if (isHighRisk) { + anyHighRisk.value = "Yes" + if (highRiskReason != null) { + // Try to set the specific reason if it exists in the dropdown + if (highRiskCondition.entries?.contains(highRiskReason) == true) { + highRiskCondition.value = highRiskReason + } else { + highRiskCondition.value = "OTHER" + } + } + } + highRiskCondition.required = + (anyHighRisk.value == anyHighRisk.entries!!.last()) + // Triggers UI update + return triggerDependants( + source = anyHighRisk, + addItems = listOf(highRiskCondition, highRiskReferralFacility, hrpConfirm), + removeItems = emptyList() + ) + } + + private fun checkBpForHighRisk(): String? { + val bpValue = bp.value + if (!bpValue.isNullOrEmpty() && bpValue.contains("/")) { + val sys = bpValue.substringBefore("/").toIntOrNull() + val dia = bpValue.substringAfter("/").toIntOrNull() + if ((sys != null && sys >= 140) || (dia != null && dia >= 90)) { + return "HIGH BP (SYSTOLIC>=140 AND OR DIASTOLIC >=90mmHg)" + } + } + return null + } + + private fun checkHbForSevereAnaemia(): String? { + val hemoglobin = hb.value?.toDoubleOrNull() + if (hemoglobin != null && hemoglobin < 7.0) { + return "SEVERE ANAEMIA (HB<7 gm/dl)" + } + return null + } + + private fun checkDangerSignsForHighRisk(): String? { + val selectedDangerSigns = dangerSigns.value + if (!selectedDangerSigns.isNullOrEmpty() && !selectedDangerSigns.contains("None")) { + return "OTHER" + } + return null + } override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + val hrpTriggerIds = setOf( + bp.id, hb.id, bloodSugarFasting.id, + fetalHeartRate.id, dangerSigns.id, urineSugar.id, urineAlbumin.id + ) + + if (formId in hrpTriggerIds) { + checkHrpStatus() + } + return when (formId) { ancDate.id -> { ancDate.value?.let { val long = getLongFromDate(it) - val weeks = 2 -// val weeks = getWeeksOfPregnancy(long, regis.lmpDate) - val listChanged = if (weeks >= Konstants.minWeekToShowDelivered) { + val weeks = getWeeksOfPregnancy(long, regis.lmpDate) + + val maternalDeathSelected = + maternalDeath.value == maternalDeath.entries!!.last() + val wasAborted = isAborted.value == isAborted.entries!!.last() + + // Abortion If Any: visible when weeks <= 24 and maternal death != Yes + val abortionResult = when { + maternalDeathSelected -> -1 + weeks <= Konstants.maxWeekToShowAbortion -> { + if (!getFormList().contains(isAborted)) { + triggerDependants( + source = maternalDeath, + addItems = listOf(isAborted), + removeItems = emptyList(), + ) + } else -1 + } + else -> { + val toRemove = mutableListOf(isAborted) + if (wasAborted) { + toRemove.addAll( + listOf(abortionType, abortionFacility, abortionDate) + ) + } + triggerDependants( + source = maternalDeath, + addItems = emptyList(), + removeItems = toRemove, + ) + } + } + + // Has the pregnant woman delivered?: visible when weeks >= 23, + // hidden if abortion is selected (and still active) or maternal death = Yes + val abortionStillSelected = + wasAborted && weeks <= Konstants.maxWeekToShowAbortion + val listChanged = if (!maternalDeathSelected + && weeks >= Konstants.minWeekToShowDelivered + && !abortionStillSelected + ) { triggerDependants( source = maternalDeath, addItems = listOf(deliveryDone), @@ -542,7 +1045,8 @@ class PregnantWomanAncVisitDataset( removeItems = listOf(deliveryDone), ) } - weekOfPregnancy.value = weeks.toString() + + weekOfPregnancy.value = getCurrentGestationalAgeFormatted(regis.lmpDate) val calcVisitNumber = when (weeks) { in Konstants.minAnc1Week..Konstants.maxAnc1Week -> 1 in Konstants.minAnc2Week..Konstants.maxAnc2Week -> 2 @@ -552,34 +1056,37 @@ class PregnantWomanAncVisitDataset( } if (ancVisit.entries?.contains(calcVisitNumber.toString()) == true) { ancVisit.value = calcVisitNumber.toString() + + // Handle Fundal Height visibility (Enable/Disable instead of Add/Remove) + if (weeks <= 12) { + fundalHeight.inputType = InputType.TEXT_VIEW + fundalHeight.value = null + } else { + fundalHeight.inputType = InputType.EDIT_TEXT + } + val listChanged2 = if (weeks <= 12) triggerDependants( source = ancVisit, addItems = listOf(numFolicAcidTabGiven), - removeItems = listOf(fundalHeight, numIfaAcidTabGiven), + removeItems = listOf(numIfaAcidTabGiven), position = getIndexById(dateOfTTOrTdBooster.id) + 1 ) else { triggerDependants( source = ancVisit, removeItems = listOf(numFolicAcidTabGiven), - addItems = listOf(fundalHeight), - position = getIndexById(hb.id) + 1 - ) - triggerDependants( - source = ancVisit, - removeItems = listOf(), addItems = listOf(numIfaAcidTabGiven), position = getIndexById(dateOfTTOrTdBooster.id) + 1 ) } - return if (listChanged >= 0 || listChanged2 >= 0) + return if (listChanged >= 0 || listChanged2 >= 0 || abortionResult >= 0) 1 else -1 } - return listChanged + return if (abortionResult >= 0) maxOf(listChanged, abortionResult) else listChanged } -1 } @@ -589,19 +1096,13 @@ class PregnantWomanAncVisitDataset( triggerDependants( source = ancVisit, addItems = listOf(numFolicAcidTabGiven), - removeItems = listOf(fundalHeight, numIfaAcidTabGiven), + removeItems = listOf(numIfaAcidTabGiven), position = getIndexById(dateOfTTOrTdBooster.id) + 1 ) else { triggerDependants( source = ancVisit, removeItems = listOf(numFolicAcidTabGiven), - addItems = listOf(fundalHeight), - position = getIndexById(hb.id) + 1 - ) - triggerDependants( - source = ancVisit, - removeItems = listOf(), addItems = listOf(numIfaAcidTabGiven), position = getIndexById(dateOfTTOrTdBooster.id) + 1 ) @@ -609,13 +1110,24 @@ class PregnantWomanAncVisitDataset( } } - isAborted.id -> triggerDependants( - source = isAborted, - passedIndex = index, - triggerIndex = 1, - target = listOf(abortionType, abortionDate), - targetSideEffect = listOf(abortionFacility) - ) + isAborted.id -> { + if (isAborted.value == isAborted.entries!![1]) { + triggerDependants( + source = isAborted, + addItems = listOf(abortionType, abortionDate), + removeItems = abortionDisabledFields() + listOf(abortionFacility), + position = getIndexById(isAborted.id) + 1 + ) + } else { + val restored = buildVisibleAncFields().also { it.remove(isAborted) } + triggerDependants( + source = isAborted, + addItems = restored, + removeItems = listOf(abortionType, abortionDate, abortionFacility), + position = getIndexById(isAborted.id) + 1 + ) + } + } abortionType.id -> triggerDependants( source = abortionType, @@ -625,30 +1137,190 @@ class PregnantWomanAncVisitDataset( ) dateOfTTOrTd1.id -> { - if (dateOfTTOrTd1.value == null) - dateOfTTOrTdBooster.inputType = InputType.DATE_PICKER - else - dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW - -1 + var result = -1 + if (dateOfTTOrTd1.value == null) { + // If Td1 removed, disable Td2 and Booster + if (dateOfTTOrTd2.inputType != InputType.TEXT_VIEW) { + dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + result = 1 + } + dateOfTTOrTd2.value = null + + // Re-enable Booster if Td1 is cleared + if (dateOfTTOrTdBooster.inputType != InputType.DATE_PICKER) { + dateOfTTOrTdBooster.inputType = InputType.DATE_PICKER + result = 1 + } + + // Remove Td2 (and Booster if present) + val res = triggerDependants( + source = dateOfTTOrTd1, + addItems = emptyList(), + removeItems = listOf(dateOfTTOrTd2, dateOfTTOrTdBooster), + position = getIndexById(dateOfTTOrTd1.id) + 1 + ) + if (res != -1) result = res + } else { + // If Td1 entered, enable Td2 + + val td1Date = getLongFromDate(dateOfTTOrTd1.value) + val minDate = td1Date + TimeUnit.DAYS.toMillis(4 * 7) // 4 weeks after Td1 + // Set max to 36 weeks LMP or Today + val maxDate = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + // Always add Td2 if Td1 is entered + if (getIndexOfTd2() == -1) { + val res = triggerDependants( + source = dateOfTTOrTd1, + addItems = listOf(dateOfTTOrTd2), + removeItems = emptyList(), + position = getIndexById(dateOfTTOrTd1.id) + 1 + ) + if (res != -1) result = res + } + + // Check if Td2 is valid (Min <= Max) to Enable it + if (minDate <= maxDate) { + if (dateOfTTOrTd2.inputType != InputType.DATE_PICKER) { + dateOfTTOrTd2.inputType = InputType.DATE_PICKER + result = 1 + } + dateOfTTOrTd2.min = minDate + dateOfTTOrTd2.max = maxDate + } else { + // Td2 not possible yet -> Disable it (Show as TextView) + if (dateOfTTOrTd2.inputType != InputType.TEXT_VIEW) { + dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + result = 1 + } + dateOfTTOrTd2.value = null + } + + // Disable Booster (Mutual Exclusion) - DO NOT CLEAR VALUE + if (dateOfTTOrTdBooster.inputType != InputType.TEXT_VIEW) { + dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW + result = 1 + } + } + result + } + + dateOfTTOrTd2.id -> { + var result = -1 + if (dateOfTTOrTd2.value != null) { + // Always Add Booster if Td2 is entered + if (getIndexOfTdBooster() == -1) { + val res = triggerDependants( + source = dateOfTTOrTd2, + addItems = listOf(dateOfTTOrTdBooster), + removeItems = emptyList(), + position = getIndexById(dateOfTTOrTd2.id) + 1 + ) + if (res != -1) result = res + } + + val minBoosterDate = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + val maxBoosterDate = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + // Check availability + if (minBoosterDate <= maxBoosterDate) { + if (dateOfTTOrTdBooster.inputType != InputType.DATE_PICKER) { + dateOfTTOrTdBooster.inputType = InputType.DATE_PICKER + result = 1 + } + dateOfTTOrTdBooster.min = minBoosterDate + dateOfTTOrTdBooster.max = maxBoosterDate + } else { + // Booster not possible yet -> Disable + if (dateOfTTOrTdBooster.inputType != InputType.TEXT_VIEW) { + dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW + result = 1 + } + dateOfTTOrTdBooster.value = null + } + } else { + // Remove message if cleared + if (dateOfTTOrTd2.title.contains(allTdDosesMessage)) { + dateOfTTOrTd2.title = dateOfTTOrTd2.title.replace(allTdDosesMessage, "") + result = 1 + } + // Disable Booster if Td2 cleared -> Remove Booster + if (dateOfTTOrTdBooster.inputType != InputType.TEXT_VIEW) { + dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW + result = 1 + } + dateOfTTOrTdBooster.value = null + + val res = triggerDependants( + source = dateOfTTOrTd2, + addItems = emptyList(), + removeItems = listOf(dateOfTTOrTdBooster), + position = getIndexById(dateOfTTOrTd2.id) + 1 + ) + if (res != -1) result = res + } + result } dateOfTTOrTdBooster.id -> { - if (dateOfTTOrTdBooster.value == null) { - dateOfTTOrTd1.inputType = InputType.DATE_PICKER + var result = -1 + if (dateOfTTOrTdBooster.value != null) { + // Booster entered -> Disable Td1/Td2 (Mutual Exclusion) - DO NOT CLEAR VALUE + if (dateOfTTOrTd1.inputType != InputType.TEXT_VIEW) { + dateOfTTOrTd1.inputType = InputType.TEXT_VIEW + result = 1 + } + if (dateOfTTOrTd2.inputType != InputType.TEXT_VIEW) { + dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + result = 1 + } } else { - dateOfTTOrTd1.inputType = InputType.TEXT_VIEW + // Booster removed -> Enable Td1 (if eligible) + if (regis.tt1 == null) { + if (dateOfTTOrTd1.inputType != InputType.DATE_PICKER) { + dateOfTTOrTd1.inputType = InputType.DATE_PICKER + result = 1 + } + } + // Remove message + if (dateOfTTOrTdBooster.title.contains(allTdDosesMessage)) { + dateOfTTOrTdBooster.title = dateOfTTOrTdBooster.title.replace(allTdDosesMessage, "") + result = 1 + } } + result + } + bp.id -> { + validateForBp(bp) + // PRD: show HRP alert dialog if systolic < 90 or >= 140, or diastolic < 60 or >= 90 + if (bp.errorText == null) { + val bpVal = bp.value + if (!bpVal.isNullOrEmpty() && bpVal.contains("/")) { + val sys = bpVal.substringBefore("/").toIntOrNull() + val dia = bpVal.substringAfter("/").toIntOrNull() + if ((sys != null && (sys < 90 || sys >= 140)) || (dia != null && (dia < 60 || dia >= 90))) { + emitAlertErrorMessage(R.string.anc_alert_bp_hrp) + } + } + } + recomputeHighRiskState() -1 } - bp.id -> validateForBp(bp) - weight.id -> validateIntMinMax(weight) hb.id -> { validateDoubleUpto1DecimalPlaces(hb) if (hb.errorText == null) validateDoubleMinMax(hb) + // PRD: show Severe Anaemia alert dialog if Hb < 7 + if (hb.errorText == null) { + val hbVal = hb.value?.toDoubleOrNull() + if (hbVal != null && hbVal < 7.0) { + emitAlertErrorMessage(R.string.anc_alert_hb_severe_anemia) + } + } + recomputeHighRiskState() -1 } @@ -665,15 +1337,21 @@ class PregnantWomanAncVisitDataset( if (it < 7) highRiskCondition.value = highRiskCondition.entries!![5] } - bp.value?.takeIf { it.isNotEmpty() && hb.errorText == null }?.let { - val sys = it.substringBefore("/").toInt() - val dia = it.substringAfter("/").toInt() - if (sys > 140 || dia > 90) { - highRiskCondition.value = highRiskCondition.entries!![1] + if (bp.value != null) { + val bpValue = bp.value!! + if (bpValue.contains("/")) { + val sys = bpValue.substringBefore("/").toIntOrNull() + val dia = bpValue.substringAfter("/").toIntOrNull() + // HRP alert: Systolic < 90 or >= 140, Diastolic < 60 or >= 90 + if ((sys != null && (sys < 90 || sys >= 140)) || (dia != null && (dia < 60 || dia >= 90))) { + highRiskCondition.value = highRiskCondition.entries!![1] + } } } if (highRiskCondition.value == null) highRiskCondition.value = highRiskCondition.entries!!.first() + highRiskCondition.required = + (anyHighRisk.value == anyHighRisk.entries!!.last()) } highRiskCondition.id -> triggerDependants( @@ -695,13 +1373,151 @@ class PregnantWomanAncVisitDataset( target = hrpConfirmedBy, ) - maternalDeath.id -> triggerDependants( - source = maternalDeath, - passedIndex = index, - triggerIndex = 1, - target = listOf(maternalDeathProbableCause, maternalDateOfDeath), - targetSideEffect = listOf(otherMaternalDeathProbableCause) - ) + maternalDeath.id -> { + val ancFields = mutableListOf( + isAborted, + weight, + bp, + pulseRate, + hb, + bloodSugarFasting, +// fundalHeight, + urineAlbumin, + urineSugar, + fetalHeartRate, + randomBloodSugarTest, + dateOfTTOrTd1, + // Td2 and Booster added conditionally below +// dateOfTTOrTd2, +// dateOfTTOrTdBooster, + numFolicAcidTabGiven, + numIfaAcidTabGiven, + calciumGiven, + anyHighRisk, + dangerSigns, + highRiskReferralFacility, + hrpConfirm, + counsellingProvided, + nextAncVisitDate + ) + + val dependantFields = listOf( + abortionType, abortionFacility, abortionDate, + highRiskCondition, otherHighRiskCondition, + hrpConfirmedBy, + counsellingTopics + ) + + if (maternalDeath.value == "Yes") { + triggerDependants( + source = maternalDeath, + addItems = listOf(maternalDeathProbableCause, maternalDateOfDeath), + removeItems = ancFields + listOf(deliveryDone, otherMaternalDeathProbableCause) + dependantFields, + position = getIndexById(maternalDeath.id) + 1 + ) + } else { + ancDate.value?.let { + val long = getLongFromDate(it) + val weeks = getWeeksOfPregnancy(long, regis.lmpDate) + + if (weeks <= 12) { + // Disable Fundal Height but keep visible + fundalHeight.inputType = InputType.TEXT_VIEW + fundalHeight.value = null + + ancFields.remove(numIfaAcidTabGiven) + } else { + fundalHeight.inputType = InputType.EDIT_TEXT + ancFields.remove(numFolicAcidTabGiven) + } + + if (weeks <= 14) { + ancFields.remove(calciumGiven) + } + + if (weeks >= Konstants.minWeekToShowDelivered) { + ancFields.add(deliveryDone) + } + + if (weeks > Konstants.maxWeekToShowAbortion) { + ancFields.remove(isAborted) + } + } + + // Restore dependant fields if they were active + if (isAborted.value == isAborted.entries!!.last()) { + ancFields.addAll(ancFields.indexOf(isAborted) + 1, listOf(abortionType, abortionFacility, abortionDate)) + } + + if (anyHighRisk.value == anyHighRisk.entries!!.last()) { + val hrIndex = ancFields.indexOf(anyHighRisk) + if (hrIndex != -1) { + ancFields.add(hrIndex + 1, highRiskCondition) + if (highRiskCondition.value == highRiskCondition.entries!!.last()) { + ancFields.add(hrIndex + 2, otherHighRiskCondition) + } + } + } + + if (hrpConfirm.value == hrpConfirm.entries!!.last()) { + ancFields.add(ancFields.indexOf(hrpConfirm) + 1, hrpConfirmedBy) + } + + if (counsellingProvided.value == counsellingProvided.entries!!.last()) { + ancFields.add(ancFields.indexOf(counsellingProvided) + 1, counsellingTopics) + } + + // Re-apply Td Logic dynamically + val td1Index = ancFields.indexOf(dateOfTTOrTd1) + if (td1Index != -1) { + // If Td1 is present (history or current), add Td2 + if (regis.tt1 != null || dateOfTTOrTd1.value != null) { + if (!ancFields.contains(dateOfTTOrTd2)) { + val td1Date = regis.tt1 ?: getLongFromDate(dateOfTTOrTd1.value) + if (td1Date != null) { + val minTd2Date = td1Date + TimeUnit.DAYS.toMillis(4 * 7) + val maxTd2Date = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + if (minTd2Date <= maxTd2Date) { + dateOfTTOrTd2.inputType = InputType.DATE_PICKER + dateOfTTOrTd2.min = minTd2Date + dateOfTTOrTd2.max = maxTd2Date + } else { + dateOfTTOrTd2.inputType = InputType.TEXT_VIEW + } + ancFields.add(td1Index + 1, dateOfTTOrTd2) + } + } + } + // If Td2 is present (history or current), add Booster + val td2Index = ancFields.indexOf(dateOfTTOrTd2) + if (td2Index != -1) { + if (regis.tt2 != null || dateOfTTOrTd2.value != null) { + if (!ancFields.contains(dateOfTTOrTdBooster)) { + val minBoosterDate = regis.lmpDate + TimeUnit.DAYS.toMillis(5 * 7) + val maxBoosterDate = minOf(System.currentTimeMillis(), regis.lmpDate + TimeUnit.DAYS.toMillis(36 * 7)) + + if (minBoosterDate <= maxBoosterDate) { + dateOfTTOrTdBooster.inputType = InputType.DATE_PICKER + dateOfTTOrTdBooster.min = minBoosterDate + dateOfTTOrTdBooster.max = maxBoosterDate + } else { + dateOfTTOrTdBooster.inputType = InputType.TEXT_VIEW + } + ancFields.add(td2Index + 1, dateOfTTOrTdBooster) + } + } + } + } + + triggerDependants( + source = maternalDeath, + addItems = ancFields, + removeItems = listOf(maternalDeathProbableCause, maternalDateOfDeath, otherMaternalDeathProbableCause), + position = getIndexById(maternalDeath.id) + 1 + ) + } + } maternalDeathProbableCause.id -> triggerDependants( source = maternalDeathProbableCause, @@ -715,6 +1531,103 @@ class PregnantWomanAncVisitDataset( validateAllAlphabetsSpaceOnEditText(otherMaternalDeathProbableCause) } + // NEW FIELD VALIDATIONS + + bloodSugarFasting.id -> { + validateIntMinMax(bloodSugarFasting) + // PRD: show alert dialog if blood sugar > 95 mg/dL + if (bloodSugarFasting.errorText == null) { + val bsVal = bloodSugarFasting.value?.toIntOrNull() + if (bsVal != null && bsVal > 95) { + emitAlertErrorMessage(R.string.anc_alert_blood_sugar_high) + } + } + recomputeHighRiskState() + -1 + } + + urineSugar.id -> { + // PRD: show HRP alert dialog if urine sugar is + or more + val urineSugarRiskLevels = arrayOf("+", "++", "+++") + if (urineSugar.value in urineSugarRiskLevels) { + emitAlertErrorMessage(R.string.anc_alert_urine_sugar_hrp) + } + recomputeHighRiskState() + -1 + } + + fetalHeartRate.id -> { + validateDoubleUpto1DecimalPlaces(fetalHeartRate) + if (fetalHeartRate.errorText == null) validateDoubleMinMax(fetalHeartRate) + // PRD: show bradycardia / tachycardia alert dialog + if (fetalHeartRate.errorText == null) { + val fhrVal = fetalHeartRate.value?.toDoubleOrNull() + when { + fhrVal != null && fhrVal < 110.0 -> + emitAlertErrorMessage(R.string.anc_alert_bradycardia) + fhrVal != null && fhrVal > 160.0 -> + emitAlertErrorMessage(R.string.anc_alert_tachycardia) + } + } + recomputeHighRiskState() + -1 + } + + urineAlbumin.id -> { + // PRD: show HRP alert dialog if urine albumin is + or more + val albuminRiskLevels = arrayOf("+", "++", "+++") + if (urineAlbumin.value in albuminRiskLevels) { + emitAlertErrorMessage(R.string.anc_alert_urine_albumin_hrp) + } + recomputeHighRiskState() + -1 + } + + calciumGiven.id -> validateIntMinMax(calciumGiven) + + dangerSigns.id -> { + // Checkbox adapter encodes index as (arrayIndex + 1) * (+1 checked / -1 unchecked). + val realIndex = (if (index < 0) -index else index) - 1 + val isChecked = index > 0 + val noneStr = "None" + val noneIndex = dangerSigns.entries?.indexOf(noneStr) ?: -1 + val isNoneOption = noneIndex >= 0 && realIndex == noneIndex + val clickedOption = dangerSigns.entries?.getOrNull(realIndex) + + if (clickedOption != null && isChecked) { + if (isNoneOption) { + dangerSigns.value = noneStr + } else { + val parts = (dangerSigns.value ?: "") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() && it != noneStr } + dangerSigns.value = + if (parts.isEmpty()) null else parts.joinToString(",") + } + // Same FormElement reference in old & new lists makes DiffUtil + // miss the in-place value change; force a rebind so checkboxes redraw. + forceRefreshId(dangerSigns.id) + } + + // PRD: show HRP alert dialog when any danger sign (other than None) selected + val updatedVal = dangerSigns.value + val hasSignAfterUpdate = !updatedVal.isNullOrEmpty() && !updatedVal.equals(noneStr, ignoreCase = true) + if (hasSignAfterUpdate) { + emitAlertErrorMessage(R.string.anc_alert_danger_signs_hrp) + } + + recomputeHighRiskState() + -1 + } + + counsellingProvided.id -> triggerDependants( + source = counsellingProvided, + passedIndex = index, + triggerIndex = 1, + target = counsellingTopics, + ) + else -> -1 } @@ -724,6 +1637,195 @@ class PregnantWomanAncVisitDataset( fun getIndexOfTd2() = getIndexById(dateOfTTOrTd2.id) fun getIndexOfTdBooster() = getIndexById(dateOfTTOrTdBooster.id) + /** + * Recomputes high-risk state from all current inputs and updates flags accordingly. + * Clears high-risk flags when no triggers apply. + */ + private fun recomputeHighRiskState() { + if (isAborted.value == isAborted.entries!!.last()) { + + anyHighRisk.value = anyHighRisk.entries!!.first() + highRiskCondition.value = null + hrpConfirm.value = null + hrpConfirmedBy.value = null + highRiskReferralFacility.value = null + + return + } + + var hasHighRisk = false + var riskCondition: String? = null + + // Check all health parameters + checkBloodSugarForRisk()?.let { + hasHighRisk = true + riskCondition = updateRiskCondition(riskCondition, it) + } + + checkUrineSugarForRisk()?.let { + hasHighRisk = true + riskCondition = updateRiskCondition(riskCondition, it) + } + + checkFetalHeartRateForRisk()?.let { + hasHighRisk = true + riskCondition = updateRiskCondition(riskCondition, it) + } + + if (checkUrineAlbuminForRisk()) { + hasHighRisk = true + } + + checkDangerSignsForRisk()?.let { + hasHighRisk = true + riskCondition = updateRiskCondition(riskCondition, it) + } + + checkHbForRisk()?.let { + hasHighRisk = true + riskCondition = updateRiskCondition(riskCondition, it) + } + + checkBpForRisk()?.let { + hasHighRisk = true + riskCondition = updateRiskCondition(riskCondition, it) + } + + // Update UI based on risk assessment + updateHighRiskUI(hasHighRisk, riskCondition) + } + + private fun checkBloodSugarForRisk(): Int? { + bloodSugarFasting.value?.takeIf { it.isNotEmpty() && bloodSugarFasting.errorText == null }?.toIntOrNull()?.let { bsValue -> + if (bsValue > 95) { + return 6 // DIABETES + } + } + return null + } + + private fun checkUrineSugarForRisk(): Int? { + urineSugar.value?.let { value -> + val riskLevels = arrayOf("+", "++", "+++") + if (value in riskLevels) { + return 6 // DIABETES + } + } + return null + } + + private fun checkFetalHeartRateForRisk(): Int? { + fetalHeartRate.value?.takeIf { it.isNotEmpty() && fetalHeartRate.errorText == null }?.toDoubleOrNull()?.let { fhrValue -> + if (fhrValue < 110.0 || fhrValue > 160.0) { + return highRiskCondition.entries!!.lastIndex // OTHER + } + } + return null + } + + private fun checkUrineAlbuminForRisk(): Boolean { + urineAlbumin.value?.let { value -> + val riskLevels = arrayOf("+", "++", "+++") + if (value in riskLevels) { + return true + } + } + return false + } + + private fun checkDangerSignsForRisk(): Int? { + dangerSigns.value?.let { value -> + val selectedSigns = dangerSigns.entries?.filter { entry -> + value.contains(entry) + } ?: emptyList() + val hasDangerSigns = selectedSigns.any { it != "None" } + if (hasDangerSigns) { + return when { + selectedSigns.contains("Vaginal Bleeding") -> 3 + selectedSigns.contains("Convulsions/ seizures") -> 2 + else -> highRiskCondition.entries!!.lastIndex + } + } + } + return null + } + + private fun checkHbForRisk(): Int? { + hb.value?.takeIf { it.isNotEmpty() && hb.errorText == null }?.toDoubleOrNull()?.let { + if (it < 7) { + return 5 // SEVERE ANAEMIA + } + } + return null + } + + private fun checkBpForRisk(): Int? { + if (bp.value != null) { + val bpValue = bp.value!! + if (bpValue.contains("/")) { + val sys = bpValue.substringBefore("/").toIntOrNull() + val dia = bpValue.substringAfter("/").toIntOrNull() + if ((sys != null && (sys < 90 || sys >= 140)) || (dia != null && (dia < 60 || dia >= 90))) { + return 1 // HIGH BP + } + } + } + return null + } + + private fun updateRiskCondition(current: String?, newRiskIndex: Int): String? { + if (current == null || current == highRiskCondition.entries?.first()) { + return highRiskCondition.entries!![newRiskIndex] + } + return current + } + + private fun updateHighRiskUI(hasHighRisk: Boolean, riskCondition: String?) { + if (hasHighRisk) { + setHighRiskFlags(riskCondition) + } else { + clearHighRiskFlags() + } + } + + private fun setHighRiskFlags(riskCondition: String?) { + if (anyHighRisk.value != anyHighRisk.entries?.last()) { + anyHighRisk.value = anyHighRisk.entries?.last() + triggerDependants( + source = anyHighRisk, + removeItems = emptyList(), + addItems = listOf(highRiskCondition), + position = getIndexById(anyHighRisk.id) + 1 + ) + } + + updateHighRiskConditionValue(riskCondition) + } + + private fun updateHighRiskConditionValue(riskCondition: String?) { + if (riskCondition != null && (highRiskCondition.value == null || highRiskCondition.value == highRiskCondition.entries?.first())) { + highRiskCondition.value = riskCondition + if (riskCondition == highRiskCondition.entries!!.last()) { + triggerDependants( + source = highRiskCondition, + passedIndex = highRiskCondition.entries!!.lastIndex, + triggerIndex = highRiskCondition.entries!!.lastIndex, + target = otherHighRiskCondition, + ) + } + } + } + + private fun clearHighRiskFlags() { + if (anyHighRisk.value == anyHighRisk.entries?.last()) { + anyHighRisk.value = anyHighRisk.entries?.first() + if (highRiskCondition.value != null && highRiskCondition.value != highRiskCondition.entries?.first()) { + highRiskCondition.value = highRiskCondition.entries?.first() + } + } + highRiskCondition.required = false + } + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { (cacheModel as PregnantWomanAncCache).let { cache -> @@ -735,19 +1837,23 @@ class PregnantWomanAncVisitDataset( cache.abortionFacility = abortionFacility.value cache.abortionFacilityId = abortionFacility.getPosition() cache.abortionDate = abortionDate.value?.let { getLongFromDate(it) } - cache.weight = weight.value?.toInt() - cache.bpSystolic = bp.value?.takeIf { it.isNotEmpty() }?.substringBefore("/")?.toInt() - cache.bpDiastolic = bp.value?.takeIf { it.isNotEmpty() }?.substringAfter("/")?.toInt() + cache.weight = weight.value?.toIntOrNull() + bp.value?.let { + if (it.contains("/")) { + cache.bpSystolic = it.substringBefore("/").toIntOrNull() + cache.bpDiastolic = it.substringAfter("/").toIntOrNull() + } + } cache.pulseRate = pulseRate.value?.takeIf { it.isNotEmpty() } - cache.hb = hb.value?.toDouble() - cache.fundalHeight = fundalHeight.value?.toInt() + cache.hb = hb.value?.toDoubleOrNull() + cache.fundalHeight = fundalHeight.value?.toIntOrNull() cache.urineAlbumin = urineAlbumin.value cache.urineAlbuminId = urineAlbumin.getPosition() cache.randomBloodSugarTest = randomBloodSugarTest.value cache.randomBloodSugarTestId = randomBloodSugarTest.getPosition() updateRegistrationForTdX() - cache.numFolicAcidTabGiven = numFolicAcidTabGiven.value?.toInt() ?: 0 - cache.numIfaAcidTabGiven = numIfaAcidTabGiven.value?.toInt() ?: 0 + cache.numFolicAcidTabGiven = numFolicAcidTabGiven.value?.toIntOrNull() ?: 0 + cache.numIfaAcidTabGiven = numIfaAcidTabGiven.value?.toIntOrNull() ?: 0 anyHighRisk.value?.let { cache.anyHighRisk = it == anyHighRisk.entries!!.last() } @@ -767,29 +1873,84 @@ class PregnantWomanAncVisitDataset( deliveryDone.value?.let { cache.pregnantWomanDelivered = it == deliveryDone.entries!!.first() } + + + cache.bloodSugarFasting = bloodSugarFasting.value?.toIntOrNull() + cache.urineSugar = urineSugar.value + cache.urineSugarId = urineSugar.getPosition() + cache.fetalHeartRate = fetalHeartRate.value?.toDoubleOrNull() + cache.calciumGiven = calciumGiven.value?.toIntOrNull() ?: 0 + cache.dangerSigns = dangerSigns.value + // For multi-select checkboxes, getPosition() doesn't work correctly + // Store 0 since the value itself contains all the information + cache.dangerSignsId = if (dangerSigns.inputType == InputType.CHECKBOXES) 0 else dangerSigns.getPosition() + cache.counsellingProvided = counsellingProvided.value?.let { it == counsellingProvided.entries?.last() } + cache.counsellingTopics = counsellingTopics.value + // For multi-select checkboxes, getPosition() doesn't work correctly + // Store 0 since the value itself contains all the information + cache.counsellingTopicsId = if (counsellingTopics.inputType == InputType.CHECKBOXES) 0 else counsellingTopics.getPosition() + cache.nextAncVisitDate = nextAncVisitDate.value?.let { getLongFromDate(it) } } } private fun updateRegistrationForTdX() { - if (dateOfTTOrTd1.value.isNullOrBlank() || dateOfTTOrTd2.value.isNullOrBlank() || dateOfTTOrTdBooster.value.isNullOrBlank()) - return - else { - val td1 = if (dateOfTTOrTd1.inputType == InputType.DATE_PICKER) getLongFromDate( - dateOfTTOrTd1.value - ) else null - val td2 = if (dateOfTTOrTd2.inputType == InputType.DATE_PICKER) getLongFromDate( - dateOfTTOrTd2.value - ) else null - val tdBooster = - if (dateOfTTOrTdBooster.inputType == InputType.DATE_PICKER) getLongFromDate( - dateOfTTOrTdBooster.value - ) else null - if (td1 == null && td2 == null && tdBooster == null) - return - } - regis.tt1 = dateOfTTOrTd1.value?.let { getLongFromDate(it) } - regis.tt2 = dateOfTTOrTd2.value?.let { getLongFromDate(it) } - regis.ttBooster = dateOfTTOrTdBooster.value?.let { getLongFromDate(it) } + var updated = updateTd1() + if (updateTd2()) updated = true + if (updateBooster()) updated = true + + if (updated) { + updateRegistrationMetadata() + } + } + + private fun updateTd1(): Boolean { + if (dateOfTTOrTd1.inputType == InputType.DATE_PICKER || dateOfTTOrTd1.inputType == InputType.TEXT_VIEW) { + val newVal = dateOfTTOrTd1.value?.let { getLongFromDate(it) } + if (regis.tt1 != newVal) { + regis.tt1 = newVal + return true + } + } + return false + } + + private fun updateTd2(): Boolean { + // Only update if the field is visible/relevant (not completely hidden/irrelevant) + // But since we use TEXT_VIEW for disabled, we should update. + if (dateOfTTOrTd2.inputType == InputType.DATE_PICKER || dateOfTTOrTd2.inputType == InputType.TEXT_VIEW) { + val newVal = dateOfTTOrTd2.value?.let { getLongFromDate(it) } + if (regis.tt2 != newVal) { + regis.tt2 = newVal + return true + } + } else if (dateOfTTOrTd1.value == null) { + // Logic: If Td1 is null, Td2 must be null (strict sequence) + if (regis.tt2 != null) { + regis.tt2 = null + return true + } + } + return false + } + + private fun updateBooster(): Boolean { + if (dateOfTTOrTdBooster.inputType == InputType.DATE_PICKER || dateOfTTOrTdBooster.inputType == InputType.TEXT_VIEW) { + val newVal = dateOfTTOrTdBooster.value?.let { getLongFromDate(it) } + if (regis.ttBooster != newVal) { + regis.ttBooster = newVal + return true + } + } else if (dateOfTTOrTd2.value == null) { + // Logic: If Td2 is null, Booster must be null + if (regis.ttBooster != null) { + regis.ttBooster = null + return true + } + } + return false + } + + private fun updateRegistrationMetadata() { regis.updatedDate = System.currentTimeMillis() if (regis.processed != "N") regis.processed = "U" regis.syncState = SyncState.UNSYNCED @@ -797,6 +1958,8 @@ class PregnantWomanAncVisitDataset( fun getWeeksOfPregnancy(): Int = getIndexById(weekOfPregnancy.id) + fun getAncVisitNumber(): Int? = ancVisit.value?.toIntOrNull() + // fun updateBenRecordToDelivered(it: BenRegCache) { // it.genDetails?.apply { // reproductiveStatus = diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanRegistrationDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanRegistrationDataset.kt new file mode 100644 index 000000000..a463f5f2e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/PregnantWomanRegistrationDataset.kt @@ -0,0 +1,1426 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.PatientDisplay +import org.piramalswasthya.cho.model.PregnantWomanRegistrationCache +import timber.log.Timber +import java.util.Calendar +import java.util.concurrent.TimeUnit + +class PregnantWomanRegistrationDataset( + context: Context, currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + companion object { + // Test result constants + const val TEST_RESULT_REACTIVE = "Reactive" + const val TEST_RESULT_NON_REACTIVE = "Non-Reactive" + const val TEST_RESULT_TEST_NOT_DONE = "Test Not Done" + const val TEST_RESULT_POSITIVE = "Positive" + const val TEST_RESULT_NEGATIVE = "Negative" + + // Test result indices (matching array order) + private const val TEST_RESULT_INDEX_REACTIVE = 0 + private const val TEST_RESULT_INDEX_NON_REACTIVE = 1 + private const val TEST_RESULT_INDEX_NOT_DONE = 2 + + private fun getMinLmpMillis(): Long { + val cal = Calendar.getInstance() + cal.add(Calendar.DAY_OF_YEAR, -42 * 7) // Last 42 weeks + return cal.timeInMillis + } + + private fun getEddFromLmp(lmpDate: Long): Long { + return lmpDate + TimeUnit.DAYS.toMillis(280) + } + + private fun calculateGestationalAge(lmpDate: Long, currentDate: Long): Pair { + val days = TimeUnit.MILLISECONDS.toDays(currentDate - lmpDate) + val weeks = (days / 7).toInt() + val remainingDays = (days % 7).toInt() + return Pair(weeks, remainingDays) + } + + private fun calculateTrimester(weeks: Int): String { + return when { + weeks <= 12 -> "First" + weeks <= 26 -> "Second" + else -> "Third" + } + } + + private fun calculateBMI(weight: Double, height: Double): Double { + return weight / ((height / 100) * (height / 100)) + } + + private fun getBMICategory(bmi: Double): String { + return when { + bmi < 18.5 -> "Underweight" + bmi < 24.9 -> "Normal" + bmi < 29.9 -> "Overweight" + else -> "Obese" + } + } + } + + private var _isFormReadOnly: Boolean = false + val isFormReadOnly: Boolean + get() = _isFormReadOnly + private lateinit var registrationCache: PregnantWomanRegistrationCache + private var dateOfRegMillis: Long = System.currentTimeMillis() + private var oneYearBeforeRegMillis: Long = 0L + private var patientAgeYears: Int? = null + + var onShowAlert: ((String) -> Unit)? = null + var onNavigateToEligibleCouple: (() -> Unit)? = null + var onNavigateToVitalsAndPrescription: (() -> Unit)? = null + + // Date of Registration + private val dateOfReg = FormElement( + id = 1, + inputType = InputType.DATE_PICKER, + title = "Date of PW Registration", + required = true, + max = System.currentTimeMillis(), + hasDependants = true + ) + + // RCH ID + private val rchId = FormElement( + id = 2, + inputType = InputType.EDIT_TEXT, + title = "RCH ID No. of Woman", + required = false, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + etMaxLength = 12 + ) + +// // Pregnancy Test Flow +// private val pregnancyTestAtFacility = FormElement( +// id = 3, +// inputType = InputType.RADIO, +// title = "Is the pregnancy test conducted at facility?", +// entries = arrayOf("Yes", "No"), +// required = true, +// hasDependants = true +// ) +// +// private val uptResult = FormElement( +// id = 4, +// inputType = InputType.RADIO, +// title = "Result of UPT", +// entries = arrayOf("Positive", "Negative"), +// required = false, +// hasDependants = true +// ) + + // Pregnancy Details + private val lmp = FormElement( + id = 5, + inputType = InputType.DATE_PICKER, + title = "LMP", + required = true, + hasDependants = true, + max = System.currentTimeMillis(), + min = getMinLmpMillis() + ) + + private val edd = FormElement( + id = 6, + inputType = InputType.TEXT_VIEW, + title = "EDD", + required = false + ) + + private val gestationalAge = FormElement( + id = 7, + inputType = InputType.TEXT_VIEW, + title = "Gestational Age (Weeks)", + required = false, + ) + + private val trimester = FormElement( + id = 8, + inputType = InputType.TEXT_VIEW, + title = "Trimester", + required = false, + ) + + // Blood Group + private val bloodGroup = FormElement( + id = 9, + inputType = InputType.DROPDOWN, + title = "Blood Group", + entries = arrayOf( + "A +ve", + "B +ve", + "AB +ve", + "O +ve", + "A -ve", + "B -ve", + "AB -ve", + "O -ve" + ), + required = false + ) + + // Gravida & Para + private val gravida = FormElement( + id = 10, + inputType = InputType.EDIT_TEXT, + title = "Gravida", + required = true, + min = 1, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + etMaxLength = 2, + hasDependants = true + ) + + private val para1 = FormElement( + id = 11, + inputType = InputType.EDIT_TEXT, + title = "Para", + required = true, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + etMaxLength = 2 + ) + + private val para2 = FormElement( + id = 1100, + inputType = InputType.EDIT_TEXT, + title = "Para", + required = true, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + etMaxLength = 2 + ) + + private var _para = para1 + private val para get() = _para + fun getParaId(): Int = _para.id + + // History Fields + private val historyOfAbortions = FormElement( + id = 12, + inputType = InputType.RADIO, + title = "History of abortions", + entries = arrayOf("Yes", "No"), + required = false, + isEnabled = false, + hasDependants = true + ) + + private val previousLSCS = FormElement( + id = 13, + inputType = InputType.RADIO, + title = "History of previous LSCS", + entries = arrayOf("Yes", "No"), + required = false, + hasDependants = true + ) + + private val complicationsInPreviousPregnancy = FormElement( + id = 14, + inputType = InputType.CHECKBOXES, + title = "Any complications in previous pregnancy", + entries = arrayOf( + "None", + "Gestational Diabetes", + "Pre-eclampsia", + "Eclampsia", + "Hemorrhage", + "Preterm Birth", + "Stillbirth" + ), + required = true, + hasDependants = true + ) + + // Anthropometry + private val height = FormElement( + id = 15, + inputType = InputType.EDIT_TEXT, + title = "Height (cm)", + required = false, + min = 100, + max = 220, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER, + etMaxLength = 3, + hasDependants = true + ) + + private val weight = FormElement( + id = 16, + inputType = InputType.EDIT_TEXT, + title = "Weight (Kgs)", + required = false, + min = 30, + max = 150, + minDecimal = 30.0, + maxDecimal = 150.0, + etInputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL, + etMaxLength = 6, + hasDependants = true + ) + + private val bmi1 = FormElement( + id = 17, + inputType = InputType.TEXT_VIEW, + title = "BMI", + required = false, + ) + + private val bmi2 = FormElement( + id = 1700, + inputType = InputType.TEXT_VIEW, + title = "BMI", + required = false, + ) + + private var _bmi = bmi1 + private val bmi get() = _bmi + fun getBmiId(): Int = _bmi.id + + // Pre-existing Conditions + private val preExistingConditions = FormElement( + id = 18, + inputType = InputType.CHECKBOXES, + title = "Pre-existing conditions", + entries = arrayOf( + "None", + "Hypertension", + "Diabetes Mellitus", + "Thyroid", + "Heart disease", + "Epilepsy", + "Tuberculosis", + "HIV", + "Sexually transmitted Infections", + "Severe Malnutrition", + "Kidney disease", + "Auto Immune disorders" + ), + required = true, + hasDependants = true + ) + + // Lab Investigations - VDRL/RPR + private val vdrlRprResult = FormElement( + id = 19, + inputType = InputType.DROPDOWN, + title = "VDRL/RPR Test result", + entries = arrayOf(TEST_RESULT_REACTIVE, TEST_RESULT_NON_REACTIVE, TEST_RESULT_TEST_NOT_DONE), + required = false, + hasDependants = true + ) + + private val vdrlRprDate = FormElement( + id = 20, + inputType = InputType.DATE_PICKER, + title = "Date of VDRL/RPR Test done", + required = false, + max = System.currentTimeMillis(), + + ) + + // Lab Investigations - HIV + private val hivResult = FormElement( + id = 21, + inputType = InputType.DROPDOWN, + title = "HIV Test result", + entries = arrayOf(TEST_RESULT_REACTIVE, TEST_RESULT_NON_REACTIVE, TEST_RESULT_TEST_NOT_DONE), + required = false, + hasDependants = true + ) + + private val hivTestDate = FormElement( + id = 22, + inputType = InputType.DATE_PICKER, + title = "Date of HIV Test done", + required = false, + max = System.currentTimeMillis(), + ) + + // Lab Investigations - HBsAg + private val hbsAgResult = FormElement( + id = 23, + inputType = InputType.DROPDOWN, + title = "HBsAg Test result", + entries = arrayOf(TEST_RESULT_POSITIVE, TEST_RESULT_NEGATIVE, TEST_RESULT_TEST_NOT_DONE), + required = false, + hasDependants = true + ) + + private val hbsAgTestDate = FormElement( + id = 24, + inputType = InputType.DATE_PICKER, + title = "Date of HBsAg Test done", + required = false, + max = System.currentTimeMillis(), + ) + + // High-Risk Pregnancy Flag + private val isHighRiskPregnancy = FormElement( + id = 25, + inputType = InputType.RADIO, + title = "High-Risk Conditions Present?", + entries = arrayOf("Yes", "No"), + required = false, + isEnabled = false // Auto-calculated + ) + + suspend fun setUpPage( + ben: PatientDisplay?, + savedRecord: PregnantWomanRegistrationCache? + ) { + this.registrationCache = savedRecord ?: createDefaultRegistrationCache(ben) + _isFormReadOnly = savedRecord?.isFirstAncSubmitted == true + patientAgeYears = ben?.patient?.age + + // Initialize date of registration + dateOfRegMillis = savedRecord?.dateOfRegistration ?: System.currentTimeMillis() + oneYearBeforeRegMillis = dateOfRegMillis - TimeUnit.DAYS.toMillis(365) + + _para = para1 + _bmi = bmi1 + + val list = mutableListOf() + + // Always add RCH ID first + list.add(rchId) + + // Build form based on read-only or editable mode + if (isFormReadOnly) { + savedRecord?.let { buildReadOnlyFormList(it, list) } + } else { + buildEditableFormList(savedRecord, list) + updateHighRiskStatus() + } + + setUpPage(list) + } + + /** + * Build form list for read-only mode + */ + private suspend fun buildReadOnlyFormList( + cache: PregnantWomanRegistrationCache, + list: MutableList + ) { + populateFormFromCache(cache) + + // Add all main fields in consistent order +// list.add(pregnancyTestAtFacility) +// list.add(uptResult) + + list.addAll(getPregnancyBasicFields()) + + // Add history fields if gravida > 1 + addHistoryFieldsIfNeeded(cache, list) + + list.addAll(getPhysicalFields()) + + addLabFieldsCombined(list) + + list.add(isHighRiskPregnancy) + + // Make all fields read-only + makeFormReadOnly(list) + } + + /** + * Build form list for editable mode + */ + private suspend fun buildEditableFormList( + savedRecord: PregnantWomanRegistrationCache?, + list: MutableList + ) { +// list.add(pregnancyTestAtFacility) + + if (savedRecord == null) { + rchId.value = "0" + // Default to showing registration fields + list.addAll(getPregnancyBasicFields()) + list.addAll(getPhysicalFields()) + addLabFieldsCombined(list) + list.add(isHighRiskPregnancy) + } + + savedRecord?.let { cache -> + populateFormFromCache(cache) +// val hasLmpDate = cache.lmpDate > 0 + +// if (hasLmpDate) { +// list.add(uptResult) + list.addAll(getPregnancyBasicFields()) + + // Add history fields after Para + addHistoryFieldsIfNeeded(cache, list) + + // Add physical fields + list.addAll(getPhysicalFields()) + + // Add lab fields combined (results and dates) + addLabFieldsCombined(list) + + // Finally add High Risk status + list.add(isHighRiskPregnancy) +// } else { +// list.add(uptResult) +// } + } + } + + /** + * Add history fields if gravida > 1 + */ + private fun addHistoryFieldsIfNeeded( + cache: PregnantWomanRegistrationCache, + list: MutableList + ) { + cache.numPrevPregnancy?.let { gravidaValue -> + if (gravidaValue > 1) { + list.addAll(listOf( + historyOfAbortions, + previousLSCS, + complicationsInPreviousPregnancy + )) + } + } + } + + /** + * Helper to add lab result fields and their corresponding date fields together + */ + private fun addLabFieldsCombined(list: MutableList) { + list.add(vdrlRprResult) + if (shouldShowTestDateField(vdrlRprResult.value, isHbsAg = false)) { + list.add(vdrlRprDate) + } + list.add(hivResult) + if (shouldShowTestDateField(hivResult.value, isHbsAg = false)) { + list.add(hivTestDate) + } + list.add(hbsAgResult) + if (shouldShowTestDateField(hbsAgResult.value, isHbsAg = true)) { + list.add(hbsAgTestDate) + } + } + + /** + * Check if test date field should be shown based on test result + */ + private fun shouldShowTestDateField(testResult: String?, isHbsAg: Boolean): Boolean { + return when { + testResult == null -> false + isHbsAg -> testResult == TEST_RESULT_POSITIVE || testResult == TEST_RESULT_NEGATIVE + else -> testResult == TEST_RESULT_REACTIVE || testResult == TEST_RESULT_NON_REACTIVE + } + } + + private fun getPregnancyBasicFields(): List { + return listOf( + lmp, edd, gestationalAge, trimester, + bloodGroup, + gravida, para + ) + } + + private fun getPhysicalFields(): List { + return listOf( + preExistingConditions, + height, weight, bmi + ) + } + + + fun createDefaultRegistrationCache(ben: PatientDisplay?): PregnantWomanRegistrationCache { + return PregnantWomanRegistrationCache( + patientID = ben?.patient?.patientID ?: "", + syncState = SyncState.UNSYNCED, + createdDate = System.currentTimeMillis(), + updatedDate = System.currentTimeMillis(), + dateOfRegistration = System.currentTimeMillis(), + bloodGroupId = 0, + vdrlRprTestResultId = 0, + hivTestResultId = 0, + hbsAgTestResultId = 0, + complicationPrevPregnancyId = null, + hrpIdById = 0, + isFirstAncSubmitted = false, + id = 0, + mcpCardNumber = null, + rchId = null, + lmpDate = 0L, + bloodGroup = null, + weight = null, + height = null, + vdrlRprTestResult = null, + dateOfVdrlRprTest = null, + historyOfAbortions = null, + previousLSCS = null, + hivTestResult = null, + dateOfHivTest = null, + hbsAgTestResult = null, + dateOfHbsAgTest = null, + pastIllness = null, + otherPastIllness = null, + is1st = true, + numPrevPregnancy = null, + para = null, + complicationPrevPregnancy = null, + otherComplication = null, + isHrp = false, + hrpIdBy = null, + active = true, + tt1 = null, + tt2 = null, + ttBooster = null, + processed = "N", + createdBy = "", + updatedBy = "" + ) + } + + private fun populateFormFromCache(cache: PregnantWomanRegistrationCache) { + populateBasicFields(cache) + populateTestResultFields(cache) + populateHistoryFieldsIfNeeded(cache) + } + + /** + * Populate basic form fields from cache + */ + private fun populateBasicFields(cache: PregnantWomanRegistrationCache) { + dateOfReg.value = getDateFromLong(cache.dateOfRegistration) + rchId.value = (cache.rchId ?: 0L).toString() +// pregnancyTestAtFacility.value = cache.pregnancyTestAtFacility +// uptResult.value = cache.uptResult + + if (cache.lmpDate > 0) { + lmp.value = getDateFromLong(cache.lmpDate) + updateCalculatedFields(System.currentTimeMillis()) + } + + bloodGroup.value = cache.bloodGroup + gravida.value = cache.numPrevPregnancy?.toString() + // Use stored Para if available, else calculate default + // Also handle the case where cache.para might be null (legacy data) + para.value = cache.para?.toString() ?: (cache.numPrevPregnancy?.let { it - 1 })?.toString() + height.value = cache.height?.toString() + weight.value = cache.weight?.toString() + updateBMI() + preExistingConditions.value = cache.pastIllness + isHighRiskPregnancy.value = if(cache.isHrp) "Yes" else "No" + } + + /** + * Populate test result fields and their date fields + */ + private fun populateTestResultFields(cache: PregnantWomanRegistrationCache) { + populateVdrlRprFields(cache) + populateHivFields(cache) + populateHbsAgFields(cache) + } + + /** + * Populate VDRL/RPR test result and date fields + */ + private fun populateVdrlRprFields(cache: PregnantWomanRegistrationCache) { + vdrlRprResult.value = cache.vdrlRprTestResult + if (shouldShowTestDateField(cache.vdrlRprTestResult, isHbsAg = false)) { + vdrlRprDate.isEnabled = true + vdrlRprDate.required = true + vdrlRprDate.value = cache.dateOfVdrlRprTest?.let { getDateFromLong(it) } + } + } + + /** + * Populate HIV test result and date fields + */ + private fun populateHivFields(cache: PregnantWomanRegistrationCache) { + hivResult.value = cache.hivTestResult + if (shouldShowTestDateField(cache.hivTestResult, isHbsAg = false)) { + hivTestDate.isEnabled = true + hivTestDate.required = true + hivTestDate.value = cache.dateOfHivTest?.let { getDateFromLong(it) } + } + } + + /** + * Populate HBsAg test result and date fields + */ + private fun populateHbsAgFields(cache: PregnantWomanRegistrationCache) { + hbsAgResult.value = cache.hbsAgTestResult + if (shouldShowTestDateField(cache.hbsAgTestResult, isHbsAg = true)) { + hbsAgTestDate.isEnabled = true + hbsAgTestDate.required = true + hbsAgTestDate.value = cache.dateOfHbsAgTest?.let { getDateFromLong(it) } + } + } + + /** + * Populate history fields if gravida > 1 + */ + private fun populateHistoryFieldsIfNeeded(cache: PregnantWomanRegistrationCache) { + cache.numPrevPregnancy?.let { gravidaValue -> + if (gravidaValue > 1) { + enableHistoryFields(cache) + updateHighRiskStatus() + } + } + } + + /** + * Enable and populate history-related fields + */ + private fun enableHistoryFields(cache: PregnantWomanRegistrationCache) { + historyOfAbortions.isEnabled = true + historyOfAbortions.required = true + historyOfAbortions.value = if (cache.historyOfAbortions == true) "Yes" else "No" + + previousLSCS.isEnabled = true + previousLSCS.required = true + previousLSCS.value = if (cache.previousLSCS == true) "Yes" else "No" + + complicationsInPreviousPregnancy.isEnabled = true + complicationsInPreviousPregnancy.required = true + complicationsInPreviousPregnancy.value = cache.complicationPrevPregnancy + + preExistingConditions.value = cache.pastIllness + } + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + dateOfReg.id -> handleDateOfRegChange() +// pregnancyTestAtFacility.id -> handlePregnancyTestFlow(index) +// uptResult.id -> handleUPTResultFlow(index) + lmp.id -> handleLmpChange() + historyOfAbortions.id -> handleHistoryOfAbortionsChange(index) + gravida.id -> handleGravidaChange() + para1.id, para2.id -> handleParaChange() + height.id, weight.id -> handleAnthropometryChange(formId) + vdrlRprResult.id -> { + handleVdrlRprResultChange(index) + return vdrlRprResult.id // Return the ID so Fragment can update UI + } + hivResult.id -> { + handleHivResultChange(index) + return hivResult.id // Return the ID so Fragment can update UI + } + hbsAgResult.id -> { + handleHbsAgResultChange(index) + return hbsAgResult.id // Return the ID so Fragment can update UI + } + previousLSCS.id -> handlePreviousLSCSChange(index) + complicationsInPreviousPregnancy.id -> handleComplicationsChange(index) + preExistingConditions.id -> handlePreExistingConditionsChange(index) + else -> -1 + } + } + + + private fun handleDateOfRegChange(): Int { + dateOfReg.value?.let { + dateOfRegMillis = getLongFromDate(it) + oneYearBeforeRegMillis = dateOfRegMillis - TimeUnit.DAYS.toMillis(365) + + // Update min dates for test dates (should be >= Date of Registration - 1 year) + vdrlRprDate.min = oneYearBeforeRegMillis + hivTestDate.min = oneYearBeforeRegMillis + hbsAgTestDate.min = oneYearBeforeRegMillis + + // Update max dates for test dates (should be <= today's date) + val today = System.currentTimeMillis() + vdrlRprDate.max = today + hivTestDate.max = today + hbsAgTestDate.max = today + + // Update LMP max date + lmp.max = dateOfRegMillis + } + return -1 + } + +// private fun handlePregnancyTestFlow(index: Int): Int { +// return if (index == 0) { // "Yes" selected +// triggerDependants( +// source = pregnancyTestAtFacility, +// addItems = listOf(uptResult), +// removeItems = emptyList() +// ) +// } else { // "No" selected +// onShowAlert?.invoke("Please conduct UPT (Urine Pregnancy Test)") +// triggerDependants( +// source = pregnancyTestAtFacility, +// addItems = listOf(uptResult), +// removeItems = emptyList() +// ) +// } +// } +// +// private fun handleUPTResultFlow(index: Int): Int { +// val baseRegistrationFields = listOf( +// lmp, edd, gestationalAge, trimester, bloodGroup, +// gravida, para, height, weight, bmi, +// preExistingConditions, vdrlRprResult, hivResult, hbsAgResult, +// isHighRiskPregnancy +// ) +// +// return (if (index == 0) { // Positive +// // Get current gravida value to decide if history fields should be included +// val gravidaValue = gravida.value?.toIntOrNull() ?: 1 +// +// val registrationFields = mutableListOf().apply { +// addAll(getPregnancyBasicFields()) +// +// // Only add history fields if gravida > 1 +// if (gravidaValue > 1) { +// add(historyOfAbortions) +// add(previousLSCS) +// add(complicationsInPreviousPregnancy) +// } +// +// addAll(getPhysicalFields()) +// addLabFieldsCombined(this) +// add(isHighRiskPregnancy) +// } +// +// triggerDependants( +// source = uptResult, +// addItems = registrationFields, +// removeItems = emptyList() +// ) +// } else { // Negative - Redirect to Eligible Couple Tracking Form +// val allFieldsToRemove = mutableListOf().apply { +// addAll(getPregnancyBasicFields()) +// addAll(getPhysicalFields()) +// add(vdrlRprResult) +// add(hivResult) +// add(hbsAgResult) +// add(isHighRiskPregnancy) +// add(historyOfAbortions) +// add(previousLSCS) +// add(complicationsInPreviousPregnancy) +// add(vdrlRprDate) +// add(hivTestDate) +// add(hbsAgTestDate) +// } +// +// triggerDependants( +// source = uptResult, +// addItems = emptyList(), +// removeItems = allFieldsToRemove +// ) +// }) +// } + + fun shouldNavigateToEligibleCouple(): Boolean { + + return false // uptResult.value == "Negative" + } + + private fun handleLmpChange(): Int { + lmp.value?.let { + updateCalculatedFields(System.currentTimeMillis()) + } + return lmp.id + } + + private fun handleGravidaChange(): Int { + gravida.value?.toIntOrNull()?.let { gravidaValue -> + Timber.d("Gravida changed to: $gravidaValue") + + if (gravidaValue <= 1) { + removeHistoryFields() + } else { + addHistoryFields() + } + + handleParaFieldForGravida(gravidaValue) + updateHighRiskStatus() + return historyOfAbortions.id + } + + return -1 + } + + /** + * Remove history fields when gravida <= 1 + */ + private fun removeHistoryFields() { + val historyFields = listOf(historyOfAbortions, previousLSCS, complicationsInPreviousPregnancy) + + historyFields.forEach { field -> + field.isEnabled = false + field.required = false + field.value = null + triggerDependants( + source = gravida, + removeItems = listOf(field), + addItems = emptyList() + ) + } + + updateHighRiskStatus() + } + + /** + * Add history fields when gravida > 1 + */ + private fun addHistoryFields() { + historyOfAbortions.isEnabled = true + historyOfAbortions.required = true + + val paraPosition = getIndexById(para.id) + if (paraPosition >= 0 && getIndexById(historyOfAbortions.id) < 0) { + triggerDependants( + source = gravida, + addItems = listOf(historyOfAbortions), + removeItems = emptyList(), + position = paraPosition + 1 + ) + } + + addFieldIfNotExists(previousLSCS) { getIndexById(historyOfAbortions.id) + 1 } + addFieldIfNotExists(complicationsInPreviousPregnancy) { getIndexById(previousLSCS.id) + 1 } + } + + /** + * Add a field if it doesn't exist in the form + */ + private fun addFieldIfNotExists(field: FormElement, positionProvider: () -> Int) { + field.isEnabled = true + field.required = true + if (getIndexById(field.id) < 0) { + triggerDependants( + source = gravida, + addItems = listOf(field), + removeItems = emptyList(), + position = positionProvider() + ) + } + } + + /** + * Handle Para field based on gravida value + */ + /** + * Handle Para field based on gravida value + */ + private fun handleParaFieldForGravida(gravidaValue: Int) { + // Swap para field to force UI refresh + val oldPara = _para + val newPara = if (_para.id == para1.id) para2 else para1 + _para = newPara + + if (gravidaValue == 1) { + newPara.value = "0" + newPara.isEnabled = false + newPara.required = false + newPara.errorText = null + Timber.d("Para auto-set to 0 and disabled") + } else { + newPara.isEnabled = true + newPara.required = true + newPara.value = oldPara.value // Preserve value if switching + if (newPara.value == "0") { + newPara.value = null + } + } + + // Trigger the swap in the list + triggerDependants( + source = gravida, + removeItems = listOf(oldPara), + addItems = listOf(newPara), + position = getIndexById(oldPara.id) + ) + } + + private fun handleHistoryOfAbortionsChange(index: Int): Int { + if (index == 0) { // Yes + onShowAlert?.invoke("History of abortions detected – High Risk Pregnancy") + } + updateHighRiskStatus() + return -1 + } + + private fun handlePreviousLSCSChange(index: Int): Int { + if (index == 0) { // Yes + onShowAlert?.invoke("Previous LSCS detected – High Risk Pregnancy") + } + updateHighRiskStatus() + return -1 + } + + private fun handleVdrlRprResultChange(index: Int) { + when (index) { + TEST_RESULT_INDEX_REACTIVE -> { // Reactive + onShowAlert?.invoke("VDRL/RPR Test is Reactive - HRP condition") + showVdrlRprDateField() + } + TEST_RESULT_INDEX_NON_REACTIVE -> { // Non-Reactive + showVdrlRprDateField() + } + TEST_RESULT_INDEX_NOT_DONE -> { // Test Not Done + hideVdrlRprDateField() + } + } + updateHighRiskStatus() + } + + private fun handleHivResultChange(index: Int) { + when (index) { + TEST_RESULT_INDEX_REACTIVE -> { // Reactive + onShowAlert?.invoke("HIV Test is Reactive - HRP condition") + showHivTestDateField() + } + TEST_RESULT_INDEX_NON_REACTIVE -> { // Non-Reactive + showHivTestDateField() + } + TEST_RESULT_INDEX_NOT_DONE -> { // Test Not Done + hideHivTestDateField() + } + } + updateHighRiskStatus() + } + + private fun handleHbsAgResultChange(index: Int) { + when (index) { + TEST_RESULT_INDEX_REACTIVE -> { // Positive + onShowAlert?.invoke("HBsAg Test is Positive - HRP condition") + showHbsAgTestDateField() + } + TEST_RESULT_INDEX_NON_REACTIVE -> { // Negative + showHbsAgTestDateField() + } + TEST_RESULT_INDEX_NOT_DONE -> { // Test Not Done + hideHbsAgTestDateField() + } + } + updateHighRiskStatus() + } + /** + * Generic helper to show a test date field + */ + private fun showTestDateField(dateField: FormElement, sourceField: FormElement) { + val today = System.currentTimeMillis() + dateField.min = oneYearBeforeRegMillis + dateField.max = today + dateField.isEnabled = true + dateField.required = true + addDateFieldAfterSource(sourceField, dateField) + } + + /** + * Generic helper to hide a test date field + */ + private fun hideTestDateField(dateField: FormElement) { + dateField.isEnabled = false + dateField.required = false + dateField.value = null + removeDateField(dateField) + } + + private fun showVdrlRprDateField() { + showTestDateField(vdrlRprDate, vdrlRprResult) + } + + private fun hideVdrlRprDateField() { + hideTestDateField(vdrlRprDate) + } + + private fun showHivTestDateField() { + showTestDateField(hivTestDate, hivResult) + } + + private fun hideHivTestDateField() { + hideTestDateField(hivTestDate) + } + + private fun showHbsAgTestDateField() { + showTestDateField(hbsAgTestDate, hbsAgResult) + } + + private fun hideHbsAgTestDateField() { + hideTestDateField(hbsAgTestDate) + } + + // Helper methods to add/remove fields + private fun addDateFieldAfterSource(source: FormElement, dateField: FormElement) { + // Check if date field already exists + if (getIndexById(dateField.id) < 0) { + // Use triggerDependants to add the date field + triggerDependants( + source = source, + addItems = listOf(dateField), + removeItems = emptyList(), + position = getIndexById(source.id) + 1 + ) + } + } + + private fun removeDateField(dateField: FormElement) { + // Find which test result this date field belongs to + val sourceField = when (dateField.id) { + vdrlRprDate.id -> vdrlRprResult + hivTestDate.id -> hivResult + hbsAgTestDate.id -> hbsAgResult + else -> null + } + + sourceField?.let { + triggerDependants( + source = it, + removeItems = listOf(dateField), + addItems = emptyList() + ) + } + } + /** + * Generic helper to handle checkbox field changes with "None" option + */ + private fun handleCheckboxFieldChange( + field: FormElement, + index: Int, + alertMessage: String? = null + ): Int { + val entries = field.entries!! + val realIndex = (if (index < 0) -index else index) - 1 + val clickedOption = entries[realIndex] + val currentValue = field.value ?: "" + if (realIndex == 0) { + if (currentValue.contains(clickedOption)) { + field.value = clickedOption + } + } else { + val selections = mutableSetOf() + if (currentValue.isNotEmpty()) { + selections.addAll(currentValue.split(",").map { it.trim() }.filter { it.isNotEmpty() }) + } + + // If the option is present, it was checked. + if (selections.contains(clickedOption)) { + // Remove "None" if it exists + if (selections.contains("None")) { + selections.remove("None") + } + alertMessage?.let { onShowAlert?.invoke(it) } + field.value = selections.joinToString(",") + } + // If option is NOT present, it was unchecked. Nothing extra to do. + } + + updateHighRiskStatus() + return getIndexById(field.id) + } + + private fun handleComplicationsChange(index: Int): Int { + val realIndex = (if (index < 0) -index else index) - 1 + return handleCheckboxFieldChange( + field = complicationsInPreviousPregnancy, + index = index, + alertMessage = "Previous pregnancy complication: ${complicationsInPreviousPregnancy.entries!![realIndex]} - High Risk Pregnancy" ) + } + + private fun handleParaChange(): Int { + val paraValue = para.value?.toIntOrNull() ?: return -1 + val gravidaValue = gravida.value?.toIntOrNull() ?: return -1 + + return if (paraValue > gravidaValue) { + para.errorText = "Para cannot exceed Gravida" + para.value = gravidaValue.toString() + -1 + } else { + para.errorText = null + -1 + } + } + + private fun handleAnthropometryChange(formId: Int): Int { + + if (formId == height.id) { + val heightText = height.value ?: "" + val heightValue = heightText.toIntOrNull() + + when { + heightValue != null && heightValue < 100 -> { + height.errorText = "Height should be greater than or equal to 100 cm" + } + + heightValue != null && heightText.length >= 3 && heightValue > 220 -> { + height.errorText = "Height should be less than or equal to 220 cm" + } + + heightValue != null && heightValue in 100..220 -> { + height.errorText = null + + if (heightValue < 145 && heightText.length >= 3) { + onShowAlert?.invoke("Height < 145 cm detected - High Risk Pregnancy") + } + } + + else -> { + height.errorText = null + } + } + } + + if (formId == weight.id) { + validateDoubleMinMax(weight) + } + + updateBMI() + updateHighRiskStatus() + + return if (getIndexById(bmi.id) >= 0) bmi.id else -1 + } + + + + fun validateHeightStrict(): Boolean { + validateIntMinMax(height) + return height.errorText == null + } + + private fun handlePreExistingConditionsChange(index: Int): Int { + val realIndex = (if (index < 0) -index else index) - 1 + return handleCheckboxFieldChange( + field = preExistingConditions, + index = index, + alertMessage = "Pre-existing condition: ${preExistingConditions.entries!![realIndex]} - High Risk Pregnancy" ) + } + + private fun updateCalculatedFields(currentDate: Long) { + lmp.value?.let { lmpStr -> + val lmpLong = getLongFromDate(lmpStr) + + // Calculate EDD + val eddLong = getEddFromLmp(lmpLong) + edd.value = getDateFromLong(eddLong) + + // Calculate gestational age + val (weeks, days) = calculateGestationalAge(lmpLong, currentDate) + gestationalAge.value = "$weeks weeks $days days" + + // Calculate trimester + trimester.value = calculateTrimester(weeks) + } + } + + private fun updateBMI(isHeightChange: Boolean = false) { + val heightValue = height.value?.toDoubleOrNull() + val weightValue = weight.value?.toDoubleOrNull() + + if (heightValue != null && heightValue > 0 && weightValue != null && weightValue > 0) { + val bmiValue = calculateBMI(weightValue, heightValue) + val category = getBMICategory(bmiValue) + + // Swap BMI element to force UI refresh + val oldBmi = _bmi + val newBmi = if (_bmi.id == bmi1.id) bmi2 else bmi1 + _bmi = newBmi + + newBmi.value = String.format("%.1f (%s)", bmiValue, category) + + // Trigger the swap in the list + triggerDependants( + source = weight, // Using weight as source (it's adjacent usually) or we can use specific position + removeItems = listOf(oldBmi), + addItems = listOf(newBmi), + position = getIndexById(oldBmi.id) + ) + + Timber.d("BMI updated: Height=$heightValue, Weight=$weightValue, BMI=${newBmi.value}") + + // Check for height < 145 cm HRP condition + // Only trigger alert if user has typed at least 3 digits to avoid premature alerts + // AND if the height field is the one that was actively changed + if (isHeightChange && heightValue < 145 && (height.value?.length ?: 0) >= 3) { + onShowAlert?.invoke("Height < 145 cm detected - HRP condition") + } + } else { + // Clear BMI if values are invalid + val oldBmi = _bmi + val newBmi = if (_bmi.id == bmi1.id) bmi2 else bmi1 + _bmi = newBmi + + newBmi.value = "" + + triggerDependants( + source = weight, + removeItems = listOf(oldBmi), + addItems = listOf(newBmi), + position = getIndexById(oldBmi.id) + ) + Timber.d("BMI cleared - invalid height or weight values") + } + } + + private fun updateHighRiskStatus() { + val isHRP = checkHighRiskConditions() + isHighRiskPregnancy.value = if (isHRP) "Yes" else "No" + + // Also update the cache + registrationCache.isHrp = isHRP + } + + private fun checkHighRiskConditions(): Boolean { + return checkAgeCondition() || + checkHeightCondition() || + checkHistoryOfAbortions() || + checkPreviousLSCS() || + checkPreExistingConditions() || + checkComplicationsInPreviousPregnancy() || + checkLabResults() + } + + /** + * Check if age < 18 or > 35 years (HRP condition) + */ + private fun checkAgeCondition(): Boolean { + return patientAgeYears?.let { age -> age < 18 || age > 35 } == true + } + + /** + * Check if height < 145 cm (HRP condition) + */ + private fun checkHeightCondition(): Boolean { + return height.value?.toDoubleOrNull()?.let { it < 145 && (height.value?.length ?: 0) >= 3 } == true + } + + /** + * Check if history of abortions exists + */ + private fun checkHistoryOfAbortions(): Boolean { + return historyOfAbortions.value == "Yes" + } + + /** + * Check if previous LSCS exists + */ + private fun checkPreviousLSCS(): Boolean { + return previousLSCS.value == "Yes" + } + + /** + * Check if pre-existing conditions exist (excluding "None") + */ + private fun checkPreExistingConditions(): Boolean { + return preExistingConditions.value?.let { value -> + value.isNotEmpty() && hasNonNoneSelections(value) + } == true + } + + /** + * Check if complications in previous pregnancy exist (excluding "None") + */ + private fun checkComplicationsInPreviousPregnancy(): Boolean { + return complicationsInPreviousPregnancy.value?.let { value -> + value.isNotEmpty() && hasNonNoneSelections(value) + } == true + } + + /** + * Check if any lab results indicate HRP + */ + private fun checkLabResults(): Boolean { + return vdrlRprResult.value == TEST_RESULT_REACTIVE || + hivResult.value == TEST_RESULT_REACTIVE || + hbsAgResult.value == TEST_RESULT_POSITIVE + } + + /** + * Check if comma-separated selections contain non-"None" values + */ + private fun hasNonNoneSelections(value: String): Boolean { + val selections = value.split(",").map { it.trim() } + return selections.isNotEmpty() && !selections.contains("None") + } + + private fun makeFormReadOnly(fields: List) { + fields.forEach { field -> + when (field.inputType) { + InputType.EDIT_TEXT -> field.inputType = InputType.TEXT_VIEW + InputType.DATE_PICKER -> field.inputType = InputType.TEXT_VIEW + InputType.DROPDOWN -> field.inputType = InputType.TEXT_VIEW + InputType.RADIO -> field.inputType = InputType.TEXT_VIEW + InputType.CHECKBOXES -> field.inputType = InputType.TEXT_VIEW + else -> { + // No conversion needed for other input types + } + } + field.isEnabled = false + } + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as PregnantWomanRegistrationCache).let { cache -> + // Map Date of Registration + cache.dateOfRegistration = dateOfReg.value?.let { getLongFromDate(it) } ?: System.currentTimeMillis() + + // Map RCH ID + cache.rchId = rchId.value?.toLongOrNull() + +// cache.pregnancyTestAtFacility = pregnancyTestAtFacility.value +// cache.uptResult = uptResult.value + + // Map pregnancy details + cache.lmpDate = lmp.value?.let { getLongFromDate(it) } ?: 0L + + // Map blood group + cache.bloodGroup = bloodGroup.value + cache.bloodGroupId = bloodGroup.getPosition() + + // Map anthropometry + cache.height = height.value?.toIntOrNull() + cache.weight = weight.value?.toIntOrNull() + + // Map obstetric history + cache.numPrevPregnancy = gravida.value?.toIntOrNull() + cache.para = para.value?.toIntOrNull() + cache.is1st = (gravida.value?.toIntOrNull() ?: 1) == 1 + cache.historyOfAbortions = historyOfAbortions.value == "Yes" + cache.previousLSCS = previousLSCS.value == "Yes" + cache.complicationPrevPregnancy = complicationsInPreviousPregnancy.value + + // Map pre-existing conditions + cache.pastIllness = preExistingConditions.value + + // Map VDRL/RPR lab investigations + cache.vdrlRprTestResult = vdrlRprResult.value + cache.vdrlRprTestResultId = vdrlRprResult.getPosition() + cache.dateOfVdrlRprTest = vdrlRprDate.value?.let { getLongFromDate(it) } + + // Map HIV lab investigations + cache.hivTestResult = hivResult.value + cache.hivTestResultId = hivResult.getPosition() + cache.dateOfHivTest = hivTestDate.value?.let { getLongFromDate(it) } + + // Map HBsAg lab investigations + cache.hbsAgTestResult = hbsAgResult.value + cache.hbsAgTestResultId = hbsAgResult.getPosition() + cache.dateOfHbsAgTest = hbsAgTestDate.value?.let { getLongFromDate(it) } + + // Map high-risk status + cache.isHrp = isHighRiskPregnancy.value == "Yes" + + // Set sync state + cache.syncState = SyncState.UNSYNCED + + // Mark as first ANC submitted if this is the first submission + if (!cache.isFirstAncSubmitted) { + cache.isFirstAncSubmitted = true + } + } + } + + fun shouldNavigateToVitals(): Boolean { + // Navigate to Vitals & Prescription after successful submission + return !isFormReadOnly + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/PsychosocialCaregiverSupportDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/PsychosocialCaregiverSupportDataset.kt new file mode 100644 index 000000000..7a9774808 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/PsychosocialCaregiverSupportDataset.kt @@ -0,0 +1,137 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.PsychosocialCaregiverSupport + +class PsychosocialCaregiverSupportDataset( + context: Context, + currentLanguage: Languages +) : ReferralFollowUpDataset(context, currentLanguage) { + + private lateinit var cache: PsychosocialCaregiverSupport + + private val optionYes = context.getString(R.string.yes_option) + private val optionNo = context.getString(R.string.no_option) + + // ---------------- Psychosocial counselling ---------------- + private val psychosocialCounsellingProvided = FormElement( + id = 1, + inputType = InputType.RADIO, + title = context.getString(R.string.psychosocial_counselling_provided), + entries = arrayOf(optionYes, optionNo), + required = true + ) + + // ---------------- Caregiver counselling ---------------- + private val caregiverCounsellingProvided = FormElement( + id = 2, + inputType = InputType.RADIO, + title = context.getString(R.string.psychosocial_caregiver_counselling), + entries = arrayOf(optionYes, optionNo), + required = true + ) + + // ---------------- Caregiver distress ---------------- + private val caregiverDistressIdentified = FormElement( + id = 3, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.psychosocial_caregiver_distress), + entries = arrayOf(optionYes), + required = false + ) + + // ---------------- Counselling remarks ---------------- + private val counsellingRemarks = FormElement( + id = 4, + inputType = InputType.EDIT_TEXT, + title = context.getString(R.string.psychosocial_counselling_remarks), + required = false, + etMaxLength = 250, + etInputType = android.text.InputType.TYPE_CLASS_TEXT + ) + + // ---------------- Section F: Referral & Follow-up ---------------- + + override val referralRequired = createReferralRequired(5) + + override val referralLevel = createReferralLevel(6) + + override val reasonForReferral = createReasonForReferral(7) + + override val followUpRequired = createFollowUpRequired(8) + + override val followUpDate = createFollowUpDate(9) + override val caseStatus = createCaseStatus(20) + override val dateOfDeath = createDateOfDeath(21) + override val remarks = createRemarks(22) + + // ---------------- Setup Page ---------------- + suspend fun setUpPage(savedRecord: PsychosocialCaregiverSupport?) { + cache = savedRecord ?: createDefaultCache() + populateFromCache(cache) + + val list = mutableListOf() + list.add(psychosocialCounsellingProvided) + list.add(caregiverCounsellingProvided) + list.add(caregiverDistressIdentified) + list.add(counsellingRemarks) + + // Section F + addReferralFollowUpElements(list) + + setUpPage(list) + } + + // ---------------- Value Change Handler ---------------- + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return handleReferralFollowUpChange(formId, index) + } + + // ---------------- Cache Helpers ---------------- + private fun createDefaultCache(): PsychosocialCaregiverSupport { + return PsychosocialCaregiverSupport( + patientId = "", + benVisitNo = null + ) + } + + private fun populateFromCache(cache: PsychosocialCaregiverSupport) { + psychosocialCounsellingProvided.value = + cache.psychosocialCounsellingProvided?.let { if (it) optionYes else optionNo } + + caregiverCounsellingProvided.value = + cache.caregiverCounsellingProvided?.let { if (it) optionYes else optionNo } + + caregiverDistressIdentified.value = + if (cache.caregiverDistressIdentified == true) optionYes else null + + counsellingRemarks.value = cache.counsellingRemarks + + // Section F + populateReferralFollowUpFromCache(cache) + } + + // ---------------- Map Values ---------------- + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as PsychosocialCaregiverSupport).let { + + it.psychosocialCounsellingProvided = + psychosocialCounsellingProvided.value == optionYes + + it.caregiverCounsellingProvided = + caregiverCounsellingProvided.value == optionYes + + it.caregiverDistressIdentified = + caregiverDistressIdentified.value == optionYes + + it.counsellingRemarks = counsellingRemarks.value + + // Section F + mapReferralFollowUpValues(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/RMNCHAIconDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/RMNCHAIconDataset.kt new file mode 100644 index 000000000..1fb7c799d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/RMNCHAIconDataset.kt @@ -0,0 +1,240 @@ +package org.piramalswasthya.cho.configuration + +import android.content.res.Resources +import android.os.Bundle +import androidx.navigation.NavDirections +import dagger.hilt.android.scopes.ActivityRetainedScoped +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.model.Icon +import org.piramalswasthya.cho.repositories.EcrRepo +import org.piramalswasthya.cho.repositories.InfantRegRepo +import org.piramalswasthya.cho.repositories.MaternalHealthRepo +import org.piramalswasthya.cho.repositories.PatientRepo +import org.piramalswasthya.cho.repositories.PncRepo +import javax.inject.Inject + +@ActivityRetainedScoped +class RMNCHAIconDataset @Inject constructor( + private val maternalHealthRepo: MaternalHealthRepo, + private val pncRepo: PncRepo, + private val patientRepo: PatientRepo, + private val infantRegRepo: InfantRegRepo, + private val ecrRepo: EcrRepo, +) { + + companion object { + const val MODULE_TYPE_KEY = "module_type" + const val SHOW_EC_TRACKING_KEY = "show_ec_tracking" + const val SHOW_PWR_KEY = "show_pwr" + const val SHOW_ANC_VISITS_KEY = "show_anc_visits" + const val SHOW_DELIVERY_OUTCOME_KEY = "show_delivery_outcome" + const val SHOW_NEONATAL_OUTCOME_KEY = "show_neonatal_outcome" + const val SHOW_PNC_MOTHER_LIST_KEY = "show_pnc_mother_list" + const val SHOW_E_PMSMA_LIST_KEY = "show_e_pmsma_list" + const val SHOW_ABORTION_LIST_KEY = "show_abortion_list" + const val SHOW_INFANT_LIST_KEY = "show_infant_list" + const val SHOW_CHILD_LIST_KEY = "show_child_list" + const val SHOW_ADOLESCENT_LIST_KEY = "show_adolescent_list" + const val MODULE_MATERNAL_HEALTH = "maternal_health" + private const val ACTION_SUBMODULE = 1001 // Custom action ID for sub-modules + private const val ACTION_EC_TRACKING = 1004 // Action for EC tracking list + private const val ACTION_PWR = 1005 // Action for Pregnant Women Registration list + private const val ACTION_ANC_VISITS = 1006 // Action for ANC Visits list + private const val ACTION_DELIVERY_OUTCOME = 1007 // Action for Delivery Outcome list + private const val ACTION_PNC_MOTHER_LIST = 1008 // Action for PNC Mother List + private const val ACTION_NEONATAL_OUTCOME = 1009 // Action for Neonatal Outcome List + private const val ACTION_E_PMSMA_LIST = 1016 // Action for e-PMSMA List + private const val ACTION_ABORTION_LIST = 1011 // Action for Abortion List + private const val ACTION_INFANT_LIST = 1013 // Action for Infant List + private const val ACTION_CHILD_LIST = 1014 // Action for Child List + private const val ACTION_ADOLESCENT_LIST = 1015 // Action for Adolescent List + } + + // NavDirections for sub-module navigation + private fun createNavAction(moduleType: String): NavDirections { + return object : NavDirections { + override val actionId: Int = ACTION_SUBMODULE + override val arguments = Bundle().apply { + putString(MODULE_TYPE_KEY, moduleType) + } + } + } + + // NavDirections for showing EC Tracking list + private val showECTrackingAction = object : NavDirections { + override val actionId: Int = ACTION_EC_TRACKING + override val arguments = Bundle().apply { + putBoolean(SHOW_EC_TRACKING_KEY, true) + } + } + + // NavDirections for showing Pregnant Women Registration list + private val showPWRAction = object : NavDirections { + override val actionId: Int = ACTION_PWR + override val arguments = Bundle().apply { + putBoolean(SHOW_PWR_KEY, true) + } + } + + // NavDirections for showing ANC Visits list + private val showANCVisitsAction = object : NavDirections { + override val actionId: Int = ACTION_ANC_VISITS + override val arguments = Bundle().apply { + putBoolean(SHOW_ANC_VISITS_KEY, true) + } + } + + // NavDirections for showing Delivery Outcome list + private val showDeliveryOutcomeAction = object : NavDirections { + override val actionId: Int = ACTION_DELIVERY_OUTCOME + override val arguments = Bundle().apply { + putBoolean(SHOW_DELIVERY_OUTCOME_KEY, true) + } + } + + // NavDirections for showing Neonatal Outcome list + private val showNeonatalOutcomeAction = object : NavDirections { + override val actionId: Int = ACTION_NEONATAL_OUTCOME + override val arguments = Bundle().apply { + putBoolean(SHOW_NEONATAL_OUTCOME_KEY, true) + } + } + + // NavDirections for showing PNC Mother List + private val showPNCMotherListAction = object : NavDirections { + override val actionId: Int = ACTION_PNC_MOTHER_LIST + override val arguments = Bundle().apply { + putBoolean(SHOW_PNC_MOTHER_LIST_KEY, true) + } + } + + // NavDirections for showing e-PMSMA List (women flagged high-risk in any ANC visit) + private val showEPmsmaListAction = object : NavDirections { + override val actionId: Int = ACTION_E_PMSMA_LIST + override val arguments = Bundle().apply { + putBoolean(SHOW_E_PMSMA_LIST_KEY, true) + } + } + + // NavDirections for showing Abortion List + private val showAbortionListAction = object : NavDirections { + override val actionId: Int = ACTION_ABORTION_LIST + override val arguments = Bundle().apply { + putBoolean(SHOW_ABORTION_LIST_KEY, true) + } + } + + // NavDirections for showing Infant List + private val showInfantListAction = object : NavDirections { + override val actionId: Int = ACTION_INFANT_LIST + override val arguments = Bundle().apply { + putBoolean(SHOW_INFANT_LIST_KEY, true) + } + } + + // NavDirections for showing Child List + private val showChildListAction = object : NavDirections { + override val actionId: Int = ACTION_CHILD_LIST + override val arguments = Bundle().apply { + putBoolean(SHOW_CHILD_LIST_KEY, true) + } + } + + // NavDirections for showing Adolescent List + private val showAdolescentListAction = object : NavDirections { + override val actionId: Int = ACTION_ADOLESCENT_LIST + override val arguments = Bundle().apply { + putBoolean(SHOW_ADOLESCENT_LIST_KEY, true) + } + } + + private val placeholderNavAction = object : NavDirections { + override val actionId: Int = 0 + override val arguments = Bundle() + } + + fun getRMNCHAIconDataset(resources: Resources): List { + return listOf( + Icon( + R.drawable.ic__eligible_couple, + resources.getString(R.string.eligible_couple_tracking), + ecrRepo.getEligibleCoupleTrackingCount(), + showECTrackingAction + ), + Icon( + R.drawable.ic__maternal_health, + resources.getString(R.string.maternal_health), + null, + createNavAction(MODULE_MATERNAL_HEALTH) + ), + Icon( + R.drawable.ic__adolescent, + resources.getString(R.string.adolescent_list), + patientRepo.getAdolescentListCount(), + showAdolescentListAction + ) + ).mapIndexed { index, icon -> + icon.copy(colorPrimary = index % 2 == 0) + } + } + + /** + * Maternal Health sub-modules dataset + */ + fun getMaternalHealthDataset(resources: Resources): List { + return listOf( + Icon( + R.drawable.ic__pwr, + resources.getString(R.string.pregnant_women_registration), + maternalHealthRepo.getPWRCount(), + showPWRAction + ), + Icon( + R.drawable.ic__anc_visit, + resources.getString(R.string.anc_visits), + maternalHealthRepo.getANCCount(), + showANCVisitsAction + ), + Icon( + R.drawable.ic__pwr, + resources.getString(R.string.e_pmsma_list), + maternalHealthRepo.getEPmsmaWomenCount(), + showEPmsmaListAction + ), + Icon( + R.drawable.ic__delivery_outcome, + resources.getString(R.string.delivery_outcome), + maternalHealthRepo.getDeliveredWomenCount(), + showDeliveryOutcomeAction + ), + Icon( + R.drawable.ic__mother, + resources.getString(R.string.pnc_mother_list), + pncRepo.getPNCMothersCount(), + showPNCMotherListAction + ), + Icon( + R.drawable.ic__infant_registration, + resources.getString(R.string.infant_registration), + infantRegRepo.getInfantRegisterCount(), + showInfantListAction + ), + Icon( + R.drawable.ic__child_registration, + resources.getString(R.string.child_registration), + infantRegRepo.getRegisteredInfantsCount(), + showChildListAction + ), + Icon( + R.drawable.ic__child_registration, + resources.getString(R.string.abortion_list), + maternalHealthRepo.getAbortionWomenCount(), + showAbortionListAction + ) + ).mapIndexed { index, icon -> + icon.copy(colorPrimary = index % 2 == 0) + } + } + + +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/ReferralFollowUpDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/ReferralFollowUpDataset.kt new file mode 100644 index 000000000..15fb68b65 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/ReferralFollowUpDataset.kt @@ -0,0 +1,243 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.ReferralFollowUpModel +import java.util.concurrent.TimeUnit +import org.piramalswasthya.cho.R + + +abstract class ReferralFollowUpDataset(context: Context, currentLanguage: Languages) : Dataset(context, currentLanguage) { + + // Resolve all user-facing strings, options, and sentinel values through the base + // Dataset.resources, which is locked to the *screening* language passed in via + // currentLanguage. Using context.getString / context.resources would resolve from + // the device locale instead, which can drift from the screening language and break + // comparisons like `caseStatus.value == optionDeath` when the two locales differ. + private val optionYes = resources.getString(R.string.yes) + private val optionNo = resources.getString(R.string.no) + + private val caseStatusEntries = resources.getStringArray(R.array.case_status_options) + private val optionDeath = caseStatusEntries.last() + + protected abstract val referralRequired: FormElement + protected abstract val referralLevel: FormElement + protected abstract val reasonForReferral: FormElement + protected abstract val followUpRequired: FormElement + protected abstract val followUpDate: FormElement + protected abstract val caseStatus: FormElement + protected abstract val dateOfDeath: FormElement + protected abstract val remarks: FormElement + + + + protected fun createReferralRequired(id: Int) = FormElement( + id = id, + inputType = InputType.RADIO, + title = resources.getString(R.string.referral_required_title), + entries = resources.getStringArray(R.array.referral_required_options), + trueIndex = 0, + falseIndex = 1, + required = true, + hasDependants = true + ) + + protected fun createReferralLevel(id: Int) = FormElement( + id = id, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.referral_level_title), + entries = resources.getStringArray(R.array.referral_level_options), + required = false + ) + + protected fun createReasonForReferral(id: Int) = FormElement( + id = id, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.reason_for_referral_title), + entries = resources.getStringArray(R.array.reason_for_referral_options), + required = true + ) + + protected fun createFollowUpRequired(id: Int) = FormElement( + id = id, + inputType = InputType.RADIO, + title = resources.getString(R.string.follow_up_required_title), + entries = resources.getStringArray(R.array.follow_up_required_options), + trueIndex = 0, + falseIndex = 1, + required = true, + hasDependants = true + ) + + protected fun createFollowUpDate(id: Int) = FormElement( + id = id, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.follow_up_date_title), + required = false + ) + + protected fun addReferralFollowUpElements(list: MutableList) { + list.add(referralRequired) + if (referralRequired.value == optionYes) { + referralLevel.required = true + list.add(referralLevel) + list.add(reasonForReferral) + } + list.add(followUpRequired) + if (followUpRequired.value == optionYes) { + followUpDate.required = true + followUpDate.min = System.currentTimeMillis() + followUpDate.max = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365 * 10) + list.add(followUpDate) + } + // Case closure fields are always visible below follow-up + list.add(caseStatus) + if (caseStatus.value == optionDeath) { + list.add(dateOfDeath) + } + list.add(remarks) + } + protected fun createCaseStatus(id: Int) = FormElement( + id = id, + inputType = InputType.DROPDOWN, + title = resources.getString(R.string.case_status_title), + entries = resources.getStringArray(R.array.case_status_options), + required = true, + hasDependants = true + ) + + protected fun createDateOfDeath(id: Int) = FormElement( + id = id, + inputType = InputType.DATE_PICKER, + title = resources.getString(R.string.date_of_death_title), + required = true, + max = System.currentTimeMillis() + ) + + protected fun createRemarks(id: Int) = FormElement( + id = id, + inputType = InputType.EDIT_TEXT, + title = resources.getString(R.string.remarks), + required = false, + etMaxLength = 250 + ) + + protected suspend fun handleReferralFollowUpChange(formId: Int, index: Int): Int { + return when (formId) { + referralRequired.id -> { + if (index == referralRequired.trueIndex) { + referralLevel.required = true + triggerDependants( + source = referralRequired, + addItems = listOf(referralLevel, reasonForReferral), + removeItems = emptyList() + ) + } else { + referralLevel.value = null + referralLevel.required = false + reasonForReferral.value = null + triggerDependants( + source = referralRequired, + addItems = emptyList(), + removeItems = listOf(referralLevel, reasonForReferral) + ) + } + referralRequired.id + } + caseStatus.id -> { + if (caseStatus.value == optionDeath) { + triggerDependants( + source = caseStatus, + addItems = listOf(dateOfDeath), + removeItems = emptyList() + ) + } else { + dateOfDeath.value = null + triggerDependants( + source = caseStatus, + addItems = emptyList(), + removeItems = listOf(dateOfDeath) + ) + } + caseStatus.id + } + followUpRequired.id -> { + if (index == followUpRequired.trueIndex) { + followUpDate.required = true + followUpDate.min = System.currentTimeMillis() + followUpDate.max = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365 * 10) + triggerDependants( + source = followUpRequired, + addItems = listOf(followUpDate), + removeItems = emptyList() + ) + } else { + followUpDate.value = null + followUpDate.required = false + triggerDependants( + source = followUpRequired, + addItems = emptyList(), + removeItems = listOf(followUpDate) + ) + } + followUpRequired.id + } + else -> -1 + } + } + + protected fun populateReferralFollowUpFromCache(cacheValue: ReferralFollowUpModel) { + referralRequired.value = when (cacheValue.referralRequired) { + true -> optionYes + false -> optionNo + else -> null + } + // cacheValue.* dropdown fields are stored in English; re-localize for display + // so the user sees the form in their current UI language. + referralLevel.value = getLocalValueInArray(R.array.referral_level_options, cacheValue.referralLevel) + reasonForReferral.value = getLocalValueInArray(R.array.reason_for_referral_options, cacheValue.reasonForReferral) + followUpRequired.value = when (cacheValue.followUpRequired) { + true -> optionYes + false -> optionNo + else -> null + } + followUpDate.value = cacheValue.followUpDate + caseStatus.value = getLocalValueInArray(R.array.case_status_options, cacheValue.caseStatus) + dateOfDeath.value = cacheValue.dateOfDeath + remarks.value = cacheValue.remarks + } + + protected fun mapReferralFollowUpValues(cacheValue: ReferralFollowUpModel) { + cacheValue.referralRequired = when (referralRequired.value) { + optionYes -> true + optionNo -> false + else -> null + } + // Persist dropdowns in English canonical form so the DB stays locale-neutral. + if (cacheValue.referralRequired == true) { + cacheValue.referralLevel = getEnglishValueInArray(R.array.referral_level_options, referralLevel.value) + cacheValue.reasonForReferral = getEnglishValueInArray(R.array.reason_for_referral_options, reasonForReferral.value) + } else { + cacheValue.referralLevel = null + cacheValue.reasonForReferral = null + } + + // Follow-up and closure fields are always visible, persist regardless of referral + cacheValue.followUpRequired = when (followUpRequired.value) { + optionYes -> true + optionNo -> false + else -> null + } + if (cacheValue.followUpRequired == true) { + cacheValue.followUpDate = followUpDate.value + } else { + cacheValue.followUpDate = null + } + cacheValue.caseStatus = getEnglishValueInArray(R.array.case_status_options, caseStatus.value) + // caseStatus.value is local; optionDeath is local — same-locale compare. + cacheValue.dateOfDeath = if (caseStatus.value == optionDeath) dateOfDeath.value else null + cacheValue.remarks = remarks.value + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/configuration/ThroatDiagnosisDataset.kt b/app/src/main/java/org/piramalswasthya/cho/configuration/ThroatDiagnosisDataset.kt new file mode 100644 index 000000000..57e1438e2 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/configuration/ThroatDiagnosisDataset.kt @@ -0,0 +1,301 @@ +package org.piramalswasthya.cho.configuration + +import android.content.Context +import org.piramalswasthya.cho.helpers.Languages +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.InputType +import org.piramalswasthya.cho.model.ThroatDiagnosisAssessment + +class ThroatDiagnosisDataset( + private val context: Context, + currentLanguage: Languages +) : Dataset(context, currentLanguage) { + + private lateinit var cache: ThroatDiagnosisAssessment + + var onShowAlert: ((String) -> Unit)? = null + + private val optionYes = context.getString(R.string.yes) + private val optionNo = context.getString(R.string.no) + + + + private val symptoms = FormElement( + id = 1, + inputType = InputType.CHECKBOXES, + title = context.getString(R.string.throat_diagnosis_symptoms), + entries = arrayOf( + context.getString(R.string.throat_symptom_pain), + context.getString(R.string.throat_symptom_soreness), + context.getString(R.string.throat_symptom_cold), + context.getString(R.string.throat_symptom_itching), + context.getString(R.string.throat_symptom_hoarseness) + ), + required = true + ) + + private val neckSwelling = FormElement( + id = 2, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_neck_swelling), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + private val difficultySwallowing = FormElement( + id = 3, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_difficulty_swallowing), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + private val tonsillitis = FormElement( + id = 4, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_tonsillitis), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val pharyngitis = FormElement( + id = 5, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_pharyngitis), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val laryngitis = FormElement( + id = 6, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_laryngitis), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val sinusitis = FormElement( + id = 7, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_sinusitis), + entries = arrayOf(optionYes, optionNo), + required = false + ) + + private val cleftLip = FormElement( + id = 8, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_cleft_lip), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + private val cleftPalate = FormElement( + id = 9, + inputType = InputType.RADIO, + title = context.getString(R.string.throat_diagnosis_cleft_palate), + entries = arrayOf(optionYes, optionNo), + required = false, + hasAlertError = true + ) + + /* -------------------- PAGE SETUP -------------------- */ + + suspend fun setUpPage(savedRecord: ThroatDiagnosisAssessment?) { + cache = savedRecord ?: createDefaultCache() + populateFromCache(cache) + + val list = mutableListOf() + list.add(symptoms) + list.addAll( + listOf( + neckSwelling, + difficultySwallowing, + tonsillitis, + pharyngitis, + laryngitis, + sinusitis, + cleftLip, + cleftPalate + ) + ) + setUpPage(list) + } + + /* -------------------- VALUE CHANGE -------------------- */ + + override suspend fun handleListOnValueChanged(formId: Int, index: Int): Int { + return when (formId) { + + + neckSwelling.id -> { + if (index == 0) { + onShowAlert?.invoke(context.getString(R.string.throat_alert_neck_swelling)) + } + neckSwelling.id + } + + difficultySwallowing.id -> { + if (index == 0) { + onShowAlert?.invoke(context.getString(R.string.throat_alert_difficulty_swallowing)) + } + difficultySwallowing.id + } + + cleftLip.id -> { + if (index == 0) { + onShowAlert?.invoke(context.getString(R.string.throat_alert_cleft_lip)) + } + cleftLip.id + } + + cleftPalate.id -> { + if (index == 0) { + onShowAlert?.invoke(context.getString(R.string.throat_alert_cleft_palate)) + } + cleftPalate.id + } + + symptoms.id -> symptoms.id + + else -> -1 + } + } + + /* -------------------- MULTI-SELECT TRIGGER -------------------- */ + + var onTriggerMultiSelect: ((formId: Int, title: String, items: Array, selectedItems: BooleanArray) -> Unit)? = + null + + fun triggerMultiSelect(formId: Int) { + when (formId) { + symptoms.id -> { + val symptomList = symptoms.entries!! + val selectedSymptoms = symptoms.value + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?: emptyList() + val selectedItems = BooleanArray(symptomList.size) { + selectedSymptoms.contains(symptomList[it]) + } + onTriggerMultiSelect?.invoke( + formId, + symptoms.title!!, + symptomList, + selectedItems + ) + } + } + } + + suspend fun updateMultiSelectValue(formId: Int, selectedItems: List) { + val valueString = if (selectedItems.isEmpty()) null else selectedItems.joinToString(", ") + when (formId) { + symptoms.id -> { + symptoms.value = valueString + } + } + updateList(formId, -1) + } + + /* -------------------- CACHE -------------------- */ + + private fun createDefaultCache() = + ThroatDiagnosisAssessment(patientId = "", benVisitNo = null) + + private fun populateFromCache(cache: ThroatDiagnosisAssessment) { + symptoms.value = cache.symptoms?.joinToString(", ") + neckSwelling.value = when (cache.neckSwelling) { + true -> optionYes + false -> optionNo + else -> null + } + difficultySwallowing.value = when (cache.difficultySwallowing) { + true -> optionYes + false -> optionNo + else -> null + } + tonsillitis.value = when (cache.tonsillitis) { + true -> optionYes + false -> optionNo + else -> null + } + pharyngitis.value = when (cache.pharyngitis) { + true -> optionYes + false -> optionNo + else -> null + } + laryngitis.value = when (cache.laryngitis) { + true -> optionYes + false -> optionNo + else -> null + } + sinusitis.value = when (cache.sinusitis) { + true -> optionYes + false -> optionNo + else -> null + } + cleftLip.value = when (cache.cleftLip) { + true -> optionYes + false -> optionNo + else -> null + } + cleftPalate.value = when (cache.cleftPalate) { + true -> optionYes + false -> optionNo + else -> null + } + } + + override fun mapValues(cacheModel: FormDataModel, pageNumber: Int) { + (cacheModel as ThroatDiagnosisAssessment).apply { + symptoms = + this@ThroatDiagnosisDataset.symptoms.value?.split(", ")?.filter { it.isNotBlank() } + neckSwelling = when (this@ThroatDiagnosisDataset.neckSwelling.value) { + optionYes -> true + optionNo -> false + else -> null + } + difficultySwallowing = when (this@ThroatDiagnosisDataset.difficultySwallowing.value) { + optionYes -> true + optionNo -> false + else -> null + } + tonsillitis = when (this@ThroatDiagnosisDataset.tonsillitis.value) { + optionYes -> true + optionNo -> false + else -> null + } + pharyngitis = when (this@ThroatDiagnosisDataset.pharyngitis.value) { + optionYes -> true + optionNo -> false + else -> null + } + laryngitis = when (this@ThroatDiagnosisDataset.laryngitis.value) { + optionYes -> true + optionNo -> false + else -> null + } + sinusitis = when (this@ThroatDiagnosisDataset.sinusitis.value) { + optionYes -> true + optionNo -> false + else -> null + } + cleftLip = when (this@ThroatDiagnosisDataset.cleftLip.value) { + optionYes -> true + optionNo -> false + else -> null + } + cleftPalate = when (this@ThroatDiagnosisDataset.cleftPalate.value) { + optionYes -> true + optionNo -> false + else -> null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/coroutines/CoroutinesModule.kt b/app/src/main/java/org/piramalswasthya/cho/coroutines/CoroutinesModule.kt new file mode 100644 index 000000000..3c13cfead --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/coroutines/CoroutinesModule.kt @@ -0,0 +1,17 @@ +package org.piramalswasthya.cho.coroutines + +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@dagger.Module +@InstallIn(SingletonComponent::class) +object CoroutinesModule { + + @Provides + @Singleton + fun provideDispatcherProvider( + defaultDispatcherProvider: DefaultDispatcherProvider + ): DispatcherProvider = defaultDispatcherProvider +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/coroutines/DispatcherProvider.kt b/app/src/main/java/org/piramalswasthya/cho/coroutines/DispatcherProvider.kt new file mode 100644 index 000000000..9cc85970c --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/coroutines/DispatcherProvider.kt @@ -0,0 +1,23 @@ +package org.piramalswasthya.cho.coroutines + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +interface DispatcherProvider { + val main: CoroutineDispatcher + val io: CoroutineDispatcher + val default: CoroutineDispatcher + val unconfined: CoroutineDispatcher +} + +class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider { + override val main: CoroutineDispatcher + get() = Dispatchers.Main + override val io: CoroutineDispatcher + get() = Dispatchers.IO + override val default: CoroutineDispatcher + get() = Dispatchers.Default + override val unconfined: CoroutineDispatcher + get() = Dispatchers.Unconfined +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/database/converters/MasterDataListConverter.kt b/app/src/main/java/org/piramalswasthya/cho/database/converters/MasterDataListConverter.kt index 3fc595d19..673f29fc3 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/converters/MasterDataListConverter.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/converters/MasterDataListConverter.kt @@ -3,7 +3,7 @@ package org.piramalswasthya.cho.database.converters import androidx.room.TypeConverter import com.google.gson.Gson import com.google.gson.reflect.TypeToken -import org.piramalswasthya.cho.moddel.OccupationMaster +import org.piramalswasthya.cho.model.OccupationMaster import org.piramalswasthya.cho.model.AgeUnit import org.piramalswasthya.cho.model.AlcoholDropdown import org.piramalswasthya.cho.model.AllergicReactionDropdown diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/InAppDb.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/InAppDb.kt index 92b2a5697..59883a621 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/InAppDb.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/InAppDb.kt @@ -29,16 +29,20 @@ import org.piramalswasthya.cho.database.room.dao.CaseRecordeDao import org.piramalswasthya.cho.database.room.dao.CbacDao import org.piramalswasthya.cho.database.room.dao.ChiefComplaintMasterDao import org.piramalswasthya.cho.database.room.dao.DeliveryOutcomeDao +import org.piramalswasthya.cho.database.room.dao.NeonatalOutcomeDao import org.piramalswasthya.cho.database.room.dao.DistrictMasterDao import org.piramalswasthya.cho.database.room.dao.EcrDao import org.piramalswasthya.cho.database.room.dao.GovIdEntityMasterDao import org.piramalswasthya.cho.database.room.dao.HealthCenterDao import org.piramalswasthya.cho.database.room.dao.HistoryDao import org.piramalswasthya.cho.database.room.dao.ImmunizationDao +import org.piramalswasthya.cho.database.room.dao.InfantRegDao import org.piramalswasthya.cho.database.room.dao.InvestigationDao import org.piramalswasthya.cho.database.room.dao.LanguageDao import org.piramalswasthya.cho.database.room.dao.LoginSettingsDataDao +import org.piramalswasthya.cho.database.room.dao.AshaDueListDao import org.piramalswasthya.cho.database.room.dao.MaternalHealthDao +import org.piramalswasthya.cho.database.room.dao.OphthalmicDao import org.piramalswasthya.cho.database.room.dao.OtherGovIdEntityMasterDao import org.piramalswasthya.cho.database.room.dao.OutreachDao import org.piramalswasthya.cho.database.room.dao.PatientDao @@ -51,6 +55,7 @@ import org.piramalswasthya.cho.database.room.dao.ProcedureMasterDao import org.piramalswasthya.cho.database.room.dao.ReferRevisitDao import org.piramalswasthya.cho.database.room.dao.RegistrarMasterDataDao import org.piramalswasthya.cho.database.room.dao.StateMasterDao +import org.piramalswasthya.cho.database.room.dao.StatusOfWomanDao import org.piramalswasthya.cho.database.room.dao.SubCatVisitDao import org.piramalswasthya.cho.database.room.dao.UserAuthDao import org.piramalswasthya.cho.database.room.dao.UserDao @@ -58,8 +63,14 @@ import org.piramalswasthya.cho.database.room.dao.VaccinationTypeAndDoseDao import org.piramalswasthya.cho.database.room.dao.VillageMasterDao import org.piramalswasthya.cho.database.room.dao.VisitReasonsAndCategoriesDao import org.piramalswasthya.cho.database.room.dao.VitalsDao -import org.piramalswasthya.cho.moddel.OccupationMaster +import org.piramalswasthya.cho.model.OccupationMaster +import org.piramalswasthya.cho.database.room.dao.EarDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.NoseDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.PainAndSymptomAssessmentDao +import org.piramalswasthya.cho.database.room.dao.OralHealthDao +import org.piramalswasthya.cho.database.room.dao.PsychosocialCaregiverSupportDao import org.piramalswasthya.cho.model.AgeUnit +import org.piramalswasthya.cho.model.AshaDueListCache import org.piramalswasthya.cho.model.AlcoholDropdown import org.piramalswasthya.cho.model.AllergicReactionDropdown import org.piramalswasthya.cho.model.AssociateAilmentsDropdown @@ -85,7 +96,10 @@ import org.piramalswasthya.cho.model.DistrictMaster import org.piramalswasthya.cho.model.DoseType import org.piramalswasthya.cho.model.DrugFormMaster import org.piramalswasthya.cho.model.DrugFrequencyMaster +import org.piramalswasthya.cho.model.EligibleCoupleRegCache import org.piramalswasthya.cho.model.EligibleCoupleTrackingCache +import org.piramalswasthya.cho.model.InfantRegCache +import org.piramalswasthya.cho.model.NeonatalOutcomeCache import org.piramalswasthya.cho.model.FamilyMemberDiseaseTypeDropdown import org.piramalswasthya.cho.model.FamilyMemberDropdown import org.piramalswasthya.cho.model.GenderMaster @@ -104,6 +118,7 @@ import org.piramalswasthya.cho.model.MasterLocation import org.piramalswasthya.cho.model.MedicationHistory import org.piramalswasthya.cho.model.OtherGovIdEntityMaster import org.piramalswasthya.cho.model.OutreachDropdownList +import org.piramalswasthya.cho.model.OphthalmicVisit import org.piramalswasthya.cho.model.PNCVisitCache import org.piramalswasthya.cho.model.PastIllnessHistory import org.piramalswasthya.cho.model.PastSurgeryHistory @@ -127,6 +142,7 @@ import org.piramalswasthya.cho.model.ReferRevisitModel import org.piramalswasthya.cho.model.RelationshipMaster import org.piramalswasthya.cho.model.ReligionMaster import org.piramalswasthya.cho.model.StateMaster +import org.piramalswasthya.cho.model.StatusOfWomanMaster import org.piramalswasthya.cho.model.SubVisitCategory import org.piramalswasthya.cho.model.SurgeryDropdown import org.piramalswasthya.cho.model.TobaccoAlcoholHistory @@ -140,6 +156,18 @@ import org.piramalswasthya.cho.model.VisitCategory import org.piramalswasthya.cho.model.VisitDB import org.piramalswasthya.cho.model.VisitReason import org.piramalswasthya.cho.model.fhir.SelectedOutreachProgram +import org.piramalswasthya.cho.model.EarDiagnosisAssessment +import org.piramalswasthya.cho.model.NoseDiagnosisAssessment +import org.piramalswasthya.cho.model.PainAndSymptomAssessment +import org.piramalswasthya.cho.model.OralHealth +import org.piramalswasthya.cho.model.PsychosocialCaregiverSupport +import org.piramalswasthya.cho.database.room.dao.MentalHealthScreeningDao +import org.piramalswasthya.cho.database.room.dao.ThroatDiagnosisAssessmentDao +import org.piramalswasthya.cho.model.MentalHealthScreeningCache +import org.piramalswasthya.cho.model.ThroatDiagnosisAssessment +import org.piramalswasthya.cho.database.room.dao.ElderlyHealthAssessmentDao +import org.piramalswasthya.cho.model.ElderlyHealthAssessment + @Database( entities = [ @@ -218,16 +246,29 @@ import org.piramalswasthya.cho.model.fhir.SelectedOutreachProgram Vaccine::class, ImmunizationCache::class, DeliveryOutcomeCache::class, + NeonatalOutcomeCache::class, + EligibleCoupleRegCache::class, EligibleCoupleTrackingCache::class, + InfantRegCache::class, PrescriptionTemplateDB::class, CbacCache::class, ProcedureMaster::class, ComponentDetailsMaster::class, - ComponentOptionsMaster::class - + ComponentOptionsMaster::class, + AshaDueListCache::class, + StatusOfWomanMaster::class, + OphthalmicVisit::class, + EarDiagnosisAssessment::class, + NoseDiagnosisAssessment::class, + PainAndSymptomAssessment::class, + PsychosocialCaregiverSupport::class, + OralHealth::class, + MentalHealthScreeningCache::class, + ThroatDiagnosisAssessment::class, + ElderlyHealthAssessment::class ], views = [PrescriptionWithItemMasterAndDrugFormMaster::class], - version = 110, exportSchema = false + version = 148, exportSchema = false ) @@ -286,12 +327,28 @@ abstract class InAppDb : RoomDatabase() { abstract val procedureDao: ProcedureDao abstract val prescriptionTemplateDao:PrescriptionTemplateDao abstract val maternalHealthDao: MaternalHealthDao + abstract val ashaDueListDao: AshaDueListDao abstract val immunizationDao: ImmunizationDao abstract val deliveryOutcomeDao: DeliveryOutcomeDao + abstract val neonatalOutcomeDao: NeonatalOutcomeDao abstract val pncDao: PncDao abstract val ecrDao: EcrDao + abstract val infantRegDao: InfantRegDao abstract val cbacDao: CbacDao abstract val procedureMasterDao: ProcedureMasterDao + abstract val painAndSymptomAssessmentDao: PainAndSymptomAssessmentDao + abstract val psychosocialCaregiverSupportDao: PsychosocialCaregiverSupportDao + + + // This comment is for Github glitch + abstract val statusOfWomanDao: StatusOfWomanDao + abstract val ophthalmicDao: OphthalmicDao + abstract val earDiagnosisAssessmentDao: EarDiagnosisAssessmentDao + abstract val oralHealthDao: OralHealthDao + abstract val noseDiagnosisAssessmentDao: NoseDiagnosisAssessmentDao + abstract val mentalHealthScreeningDao: MentalHealthScreeningDao + abstract val throatDiagnosisAssessmentDao: ThroatDiagnosisAssessmentDao + abstract val elderlyHealthAssessmentDao: ElderlyHealthAssessmentDao companion object { @Volatile @@ -320,6 +377,838 @@ abstract class InAppDb : RoomDatabase() { database.execSQL("ALTER TABLE BenFlow ADD COLUMN externalInvestigation TEXT") } } + + val MIGRATION_110_111 = object : Migration(110, 111) { + override fun migrate(database: SupportSQLiteDatabase) { + // Lab procedure master seed is now applied via ProcedureRepo.ensureLabProcedureMasterSeed() (DAO) when user opens lab technician + } + } + + val MIGRATION_111_112 = object : Migration(111, 112) { + override fun migrate(database: SupportSQLiteDatabase) { + // Remove duplicate radio options (keep one Negative, one Positive per component) + database.execSQL( + "DELETE FROM component_options_master WHERE id NOT IN (SELECT MIN(id) FROM component_options_master GROUP BY component_details_id, name)" + ) + } + } + + // There was conflict i have fixed and inform to Abhilash and shiva to verify + val MIGRATION_112_113 = object : Migration(112, 113) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(""" + CREATE TABLE IF NOT EXISTS ELIGIBLE_COUPLE_REG ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patientID TEXT NOT NULL, + dateOfReg INTEGER NOT NULL, + lmpDate INTEGER, + noOfChildren INTEGER NOT NULL, + noOfLiveChildren INTEGER NOT NULL, + noOfMaleChildren INTEGER NOT NULL, + noOfFemaleChildren INTEGER NOT NULL, + isRegistered INTEGER NOT NULL, + processed TEXT, + createdBy TEXT NOT NULL, + createdDate INTEGER NOT NULL, + updatedBy TEXT NOT NULL, + updatedDate INTEGER NOT NULL, + syncState INTEGER NOT NULL, + FOREIGN KEY(patientID) REFERENCES PATIENT(patientID) ON UPDATE CASCADE ON DELETE CASCADE + ) + """.trimIndent()) + database.execSQL("CREATE INDEX IF NOT EXISTS ecrInd ON ELIGIBLE_COUPLE_REG(patientID)") + } + } + + val MIGRATION_113_114 = object : Migration(113, 114) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(""" + CREATE TABLE IF NOT EXISTS INFANT_REG ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + childPatientID TEXT, + motherPatientID TEXT NOT NULL, + isActive INTEGER NOT NULL, + babyName TEXT, + babyIndex INTEGER NOT NULL, + infantTerm TEXT, + corticosteroidGiven TEXT, + genderID INTEGER, + babyCriedAtBirth INTEGER, + resuscitation INTEGER, + referred TEXT, + hadBirthDefect TEXT, + birthDefect TEXT, + otherDefect TEXT, + weight REAL, + breastFeedingStarted INTEGER, + opv0Dose INTEGER, + bcgDose INTEGER, + hepBDose INTEGER, + vitkDose INTEGER, + processed TEXT, + createdBy TEXT NOT NULL, + createdDate INTEGER NOT NULL, + updatedBy TEXT NOT NULL, + updatedDate INTEGER NOT NULL, + syncState INTEGER NOT NULL, + FOREIGN KEY(motherPatientID) REFERENCES PATIENT(patientID) ON UPDATE CASCADE ON DELETE CASCADE + ) + """.trimIndent()) + database.execSQL("CREATE INDEX IF NOT EXISTS infRegInd ON INFANT_REG(motherPatientID)") + } + } + + val MIGRATION_114_115 = object : Migration(114, 115) { + override fun migrate(database: SupportSQLiteDatabase) { + // Recreate component_details_master so FK references procedure_master(id) instead of procedure(id). + database.execSQL("PRAGMA foreign_keys=OFF") + database.execSQL(""" + CREATE TABLE IF NOT EXISTS component_details_master_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + test_component_id INTEGER NOT NULL, + procedure_id INTEGER NOT NULL, + range_normal_min INTEGER, + range_normal_max INTEGER, + range_min INTEGER, + range_max INTEGER, + isDecimal INTEGER, + inputType TEXT NOT NULL, + measurement_nit TEXT, + test_component_name TEXT NOT NULL, + test_component_desc TEXT NOT NULL, + FOREIGN KEY(procedure_id) REFERENCES procedure_master(id) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent()) + database.execSQL(""" + INSERT INTO component_details_master_new ( + id, test_component_id, procedure_id, range_normal_min, range_normal_max, + range_min, range_max, isDecimal, inputType, measurement_nit, + test_component_name, test_component_desc + ) SELECT + id, test_component_id, procedure_id, range_normal_min, range_normal_max, + range_min, range_max, isDecimal, inputType, measurement_nit, + test_component_name, test_component_desc + FROM component_details_master + """.trimIndent()) + database.execSQL("DROP TABLE component_details_master") + database.execSQL("ALTER TABLE component_details_master_new RENAME TO component_details_master") + database.execSQL("PRAGMA foreign_keys=ON") + } + } + + val MIGRATION_115_116 = object : Migration(115, 116) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add new columns to ELIGIBLE_COUPLE_TRACKING table + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN financialYear TEXT") + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN visitMonth TEXT") + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN lmpDate INTEGER") + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN anyOtherMethod TEXT") + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN antraDose TEXT") + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN antraInjectionDate INTEGER") + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN antraDueDate INTEGER") + database.execSQL("ALTER TABLE ELIGIBLE_COUPLE_TRACKING ADD COLUMN dateOfSterilization INTEGER") + } + } + + val MIGRATION_116_117 = object : Migration(116, 117) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(""" + CREATE TABLE IF NOT EXISTS ASHA_DUE_LIST ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patientID TEXT NOT NULL, + beneficiaryID INTEGER, + listType TEXT NOT NULL DEFAULT 'ANC', + addedDate INTEGER NOT NULL, + ashaId INTEGER NOT NULL DEFAULT 0, + createdBy TEXT NOT NULL, + syncState INTEGER NOT NULL, + FOREIGN KEY(patientID) REFERENCES PATIENT(patientID) ON UPDATE CASCADE ON DELETE CASCADE + ) + """.trimIndent()) + database.execSQL("CREATE INDEX IF NOT EXISTS ind_asha_due ON ASHA_DUE_LIST(patientID, listType)") + } + } + + val MIGRATION_117_118 = object : Migration(117, 118) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS ind_asha_due") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS ind_asha_due ON ASHA_DUE_LIST(patientID, listType)") + } + } + + val MIGRATION_118_119 = object : Migration(118, 119) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN motherCondition TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN maternalComplications TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN motherCurrentlyAdmitted INTEGER") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN isDeath INTEGER") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN isDeathValue TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN dateOfDeath TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN placeOfDeath TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN placeOfDeathId INTEGER") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN otherPlaceOfDeath TEXT") + } + } + + val MIGRATION_119_120 = object : Migration(119, 120) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add new columns to PATIENT table + database.execSQL("ALTER TABLE PATIENT ADD COLUMN statusOfWomanID INTEGER") + + // Create STATUS_OF_WOMAN_MASTER table + database.execSQL(""" + CREATE TABLE IF NOT EXISTS STATUS_OF_WOMAN_MASTER ( + statusID INTEGER PRIMARY KEY NOT NULL, + statusName TEXT NOT NULL, + statusCode TEXT NOT NULL + ) + """) + + database.execSQL("INSERT OR IGNORE INTO STATUS_OF_WOMAN_MASTER (statusID, statusName, statusCode) VALUES (1, 'Eligible Couple', 'EC')") + database.execSQL("INSERT OR IGNORE INTO STATUS_OF_WOMAN_MASTER (statusID, statusName, statusCode) VALUES (2, 'Pregnant Woman', 'PW')") + database.execSQL("INSERT OR IGNORE INTO STATUS_OF_WOMAN_MASTER (statusID, statusName, statusCode) VALUES (3, 'Postnatal', 'PN')") + database.execSQL("INSERT OR IGNORE INTO STATUS_OF_WOMAN_MASTER (statusID, statusName, statusCode) VALUES (4, 'Elderly', 'EL')") + database.execSQL("INSERT OR IGNORE INTO STATUS_OF_WOMAN_MASTER (statusID, statusName, statusCode) VALUES (5, 'Adolescent', 'AD')") + database.execSQL("INSERT OR IGNORE INTO STATUS_OF_WOMAN_MASTER (statusID, statusName, statusCode) VALUES (6, 'Permanent Sterilization', 'ST')") + database.execSQL("INSERT OR IGNORE INTO STATUS_OF_WOMAN_MASTER (statusID, statusName, statusCode) VALUES (7, 'Not Applicable', 'NA')") + } + } + + val MIGRATION_120_121 = object : Migration(120, 121) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add missing ANC tracking fields from FLW app + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN lmpDate INTEGER") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN visitDate INTEGER") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN weekOfPregnancy INTEGER") + + // Abortion extended fields + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN serialNo TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN methodOfTermination TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN methodOfTerminationId INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN terminationDoneBy TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN terminationDoneById INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN isPaiucdId INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN isYesOrNo INTEGER") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN isPaiucd TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN dateSterilisation INTEGER") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN remarks TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN abortionImg1 TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN abortionImg2 TEXT") + + // Maternal death extended fields + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN placeOfDeath TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN placeOfDeathId INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN otherPlaceOfDeath TEXT") + + // MCP card image paths + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN frontFilePath TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN backFilePath TEXT") + } + } + + val MIGRATION_121_122 = object : Migration(121, 122) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add JIRA validation requirement fields + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN bloodSugarFasting INTEGER") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN urineSugar TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN urineSugarId INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN fetalHeartRate REAL") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN calciumGiven INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN dangerSigns TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN dangerSignsId INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN counsellingProvided INTEGER") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN counsellingTopics TEXT") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN counsellingTopicsId INTEGER DEFAULT 0") + database.execSQL("ALTER TABLE PREGNANCY_ANC ADD COLUMN nextAncVisitDate INTEGER") + } + } + + val MIGRATION_122_123 = object : Migration(122, 123) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN gestationalAgeAtDelivery TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN deliveryConductedBy TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN modeOfDelivery TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN indicationForLSCS TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN indicationForLSCSOther TEXT") + database.execSQL("ALTER TABLE DELIVERY_OUTCOME ADD COLUMN privateHospitalName TEXT") + } + } + + val MIGRATION_123_124 = object : Migration(123, 124) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE PREGNANCY_REGISTER ADD COLUMN isFirstAncSubmitted INTEGER NOT NULL DEFAULT 0" + ) + db.execSQL( + "ALTER TABLE PREGNANCY_REGISTER ADD COLUMN historyOfAbortions INTEGER" + ) + db.execSQL( + "ALTER TABLE PREGNANCY_REGISTER ADD COLUMN previousLSCS INTEGER" + ) + } + } + + + val MIGRATION_124_125 = object : Migration(124, 125) { + override fun migrate(db: SupportSQLiteDatabase) { + // Create NEONATAL_OUTCOME table for tracking detailed newborn health information + db.execSQL(""" + CREATE TABLE IF NOT EXISTS NEONATAL_OUTCOME ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + deliveryOutcomeId INTEGER NOT NULL, + neonateIndex INTEGER NOT NULL, + neonateUniqueId TEXT, + outcomeAtBirth TEXT, + outcomeAtBirthId INTEGER, + sex TEXT, + sexId INTEGER, + criedImmediately TEXT, + criedImmediatelyId INTEGER, + typeOfResuscitation TEXT, + birthWeight INTEGER, + congenitalAnomalyDetected TEXT, + congenitalAnomalyDetectedId INTEGER, + typeOfCongenitalAnomaly TEXT, + otherCongenitalAnomaly TEXT, + newbornComplications TEXT, + currentStatusOfBaby TEXT, + currentStatusOfBabyId INTEGER, + causeOfDeath TEXT, + otherCauseOfDeath TEXT, + birthDoseVaccinesGiven TEXT, + reasonForNoVaccines TEXT, + vitaminKInjectionGiven INTEGER, + reasonForNoVitaminK TEXT, + birthCertificateIssued TEXT, + birthCertificateIssuedId INTEGER, + isStillbirth INTEGER, + isNeonatalDeath INTEGER, + processed TEXT, + createdBy TEXT NOT NULL, + createdDate INTEGER NOT NULL, + updatedBy TEXT NOT NULL, + updatedDate INTEGER NOT NULL, + syncState INTEGER NOT NULL, + FOREIGN KEY(deliveryOutcomeId) REFERENCES DELIVERY_OUTCOME(id) ON UPDATE CASCADE ON DELETE CASCADE + ) + """.trimIndent()) + db.execSQL("CREATE INDEX IF NOT EXISTS neonatalOutcomeInd ON NEONATAL_OUTCOME(deliveryOutcomeId)") + } + } + + val MIGRATION_125_126 = object : Migration(125, 126) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add BRD Neonatal Outcome fields to INFANT_REG table + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN outcomeAtBirth TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN typeOfResuscitation TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN newbornComplications TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN currentStatusOfBaby TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN causeOfDeath TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN otherCauseOfDeath TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN birthDoseVaccinesGiven TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN reasonForNoVaccines TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN vitaminKInjectionGiven INTEGER") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN reasonForNoVitaminK TEXT") + db.execSQL("ALTER TABLE INFANT_REG ADD COLUMN birthCertificateIssued TEXT") + } + } + + val MIGRATION_126_127 = object : Migration(126, 127) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `OPHTHALMIC_VISIT` (" + + "`visitId` TEXT NOT NULL, " + + "`patientID` TEXT NOT NULL, " + + "`benVisitNo` INTEGER NOT NULL, " + + "`isDiabetic` INTEGER, " + + "`screeningPerformed` INTEGER, " + + "`visualAcuityChartUsed` TEXT, " + + "`distVARight` TEXT, " + + "`distVALeft` TEXT, " + + "`nearVA` TEXT, " + + "`caseIdConditions` TEXT, " + + "`cataractSymptoms` INTEGER, " + + "`glaucomaSymptoms` INTEGER, " + + "`diabeticRetinopathySymptoms` INTEGER, " + + "`presbyopiaSymptoms` INTEGER, " + + "`trachomaStatus` TEXT, " + + "`cornealDiseaseType` TEXT, " + + "`vitaminADeficiency` INTEGER, " + + "`injuryType` TEXT, " + + "`foreignBodyRemoval` TEXT, " + + "`chemicalExposure` INTEGER, " + + "`createdBy` TEXT NOT NULL, " + + "`createdDate` INTEGER NOT NULL, " + + "`updatedBy` TEXT NOT NULL, " + + "`updatedDate` INTEGER NOT NULL, " + + "`syncState` INTEGER NOT NULL, " + + "PRIMARY KEY(`visitId`), " + + "FOREIGN KEY(`patientID`) REFERENCES `PATIENT`(`patientID`) ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_ophthalmic_visit_patientID` ON `OPHTHALMIC_VISIT` (`patientID`)") + } + } + val MIGRATION_127_128 = object : Migration(127, 128) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS EAR_DIAGNOSIS_ASSESSMENT ( + assessment_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER, + difficulty_hearing INTEGER, + whisper_test_response TEXT, + hearing_test_outcome TEXT, + ear_pain INTEGER, + ear_discharge_present INTEGER, + foreign_body_in_ear TEXT, + ear_condition_type TEXT, + congenital_ear_malformation INTEGER + ) + """.trimIndent() + ) + } + } + val MIGRATION_128_129 = object : Migration(128, 129) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS PAIN_SYMPTOM_ASSESSMENT ( + assessment_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER, + pain_severity TEXT, + pain_duration TEXT, + symptoms_present INTEGER, + other_symptoms_severity TEXT, + immediate_relief_provided INTEGER + ) + """.trimIndent() + ) + } + } + val MIGRATION_129_130 = object : Migration(129, 130) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS PSYCHOSOCIAL_CAREGIVER_SUPPORT ( + assessment_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER, + psychosocial_counselling_provided INTEGER, + caregiver_counselling_provided INTEGER, + caregiver_distress_identified INTEGER, + counselling_remarks TEXT + ) + """.trimIndent() + ) + } + } + val MIGRATION_130_131 = object : Migration(130, 131) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS ORAL_HEALTH ( + oral_health_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER, + tooth_decay_present INTEGER, + tooth_decay_symptoms TEXT, + gum_disease_present INTEGER, + gum_disease_symptoms TEXT, + irregular_teeth_jaws INTEGER, + abnormal_growth_ulcer INTEGER, + cleft_lip_palate INTEGER, + dental_fluorosis INTEGER, + dental_emergency TEXT, + created_date INTEGER, + created_by TEXT + ) + """.trimIndent() + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_oral_health_patient_id " + + "ON ORAL_HEALTH(patient_id)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_oral_health_patient_visit " + + "ON ORAL_HEALTH(patient_id, ben_visit_no)" + ) + } + } + + val MIGRATION_131_132 = object : Migration(131, 132) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN referral_required INTEGER") + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN referral_level TEXT") + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN reason_for_referral TEXT") + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN follow_up_required INTEGER") + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN follow_up_date TEXT") + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN referral_required INTEGER") + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN referral_level TEXT") + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN reason_for_referral TEXT") + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN follow_up_required INTEGER") + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN follow_up_date TEXT") + } + } + + val MIGRATION_132_133 = object : Migration(132, 133) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS NOSE_DIAGNOSIS_ASSESSMENT ( + assessment_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER, + difficulty_breathing INTEGER, + open_mouth_breathing INTEGER + ) + """.trimIndent() + ) + } + } + val MIGRATION_133_134 = object : Migration(133, 134) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(""" + CREATE TABLE IF NOT EXISTS MENTAL_HEALTH_SCREENING ( + screening_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER, + emotional_behavioural_concerns INTEGER, + substance_use_concerns INTEGER, + self_harm_suicide_thoughts INTEGER, + memory_loss_confusion INTEGER, + seizures_fits_loc INTEGER, + is_postpartum INTEGER, + phq9_little_interest INTEGER, + phq9_feeling_down INTEGER, + phq9_sleep_trouble INTEGER, + phq9_feeling_tired INTEGER, + phq9_appetite INTEGER, + phq9_feeling_bad INTEGER, + phq9_concentration INTEGER, + phq9_moving_slowly INTEGER, + phq9_self_harm_thoughts INTEGER, + phq9_total_score INTEGER, + substance_alcohol_use INTEGER, + substance_tobacco_use INTEGER, + substance_other_use INTEGER, + substance_other_specify TEXT, + substance_frequency TEXT, + brief_intervention_given INTEGER, + suicide_current_thoughts INTEGER, + suicide_plan INTEGER, + suicide_previous_attempt INTEGER, + suicide_hopelessness INTEGER, + suicide_risk_level TEXT, + dementia_progressive_memory_loss INTEGER, + dementia_forgetting_recent INTEGER, + dementia_disorientation INTEGER, + dementia_daily_activities INTEGER, + dementia_behavioural_changes INTEGER, + epilepsy_recurrent_seizures INTEGER, + epilepsy_jerky_movements INTEGER, + epilepsy_tongue_bite INTEGER, + epilepsy_confusion_after INTEGER, + epilepsy_loc_duration TEXT, + referral_required INTEGER, + referral_level TEXT, + reason_for_referral TEXT, + follow_up_required INTEGER, + follow_up_date TEXT, + phq9_depression_severity TEXT, + phq9_system_action TEXT, + substance_current_tobacco_use INTEGER, + substance_tobacco_type TEXT, + substance_tobacco_frequency TEXT, + substance_tobacco_outcome TEXT, + substance_system_action TEXT + ) + """.trimIndent()) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_mhs_patient_id ON MENTAL_HEALTH_SCREENING(patient_id)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_mhs_patient_visit ON MENTAL_HEALTH_SCREENING(patient_id, ben_visit_no)" + ) + } + } + val MIGRATION_134_135 = object : Migration(134, 135) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS THROAT_DIAGNOSIS_ASSESSMENT ( + assessment_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER, + symptoms TEXT, + neck_swelling INTEGER, + difficulty_swallowing INTEGER, + tonsillitis INTEGER, + pharyngitis INTEGER, + laryngitis INTEGER, + sinusitis INTEGER, + cleft_lip INTEGER, + cleft_palate INTEGER + ) + """.trimIndent() + ) + } + } + + val MIGRATION_135_136 = object : Migration(135, 136) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add missing columns to NOSE_DIAGNOSIS_ASSESSMENT + safeAddColumn(database, "NOSE_DIAGNOSIS_ASSESSMENT", "nose_bleed", "INTEGER") + safeAddColumn(database, "NOSE_DIAGNOSIS_ASSESSMENT", "systolic_bp", "INTEGER") + safeAddColumn(database, "NOSE_DIAGNOSIS_ASSESSMENT", "diastolic_bp", "INTEGER") + safeAddColumn(database, "NOSE_DIAGNOSIS_ASSESSMENT", "foreign_body_nose", "TEXT") + safeAddColumn(database, "NOSE_DIAGNOSIS_ASSESSMENT", "sinusitis", "INTEGER") + + // Add missing column to MENTAL_HEALTH_SCREENING + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "suicide_immediate_assess", "INTEGER") + } + } + + val MIGRATION_136_137 = object : Migration(136, 137) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS ELDERLY_HEALTH_ASSESSMENT ( + assessment_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + patient_id TEXT NOT NULL, + ben_visit_no INTEGER NOT NULL, + geriatric_complaints INTEGER, + multiple_chronic_conditions INTEGER, + recent_falls INTEGER, + difficulty_walking_balance INTEGER, + visual_hearing_difficulty INTEGER, + functional_decline INTEGER, + memory_loss INTEGER, + dementia_memory_loss INTEGER, + dementia_disorientation INTEGER, + dementia_behavioural_changes INTEGER, + dementia_self_care_decline INTEGER, + dementia_screening_outcome TEXT, + dementia_referral_required INTEGER + ) + """.trimIndent() + ) + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_elderly_health_assessment_patient_visit " + + "ON ELDERLY_HEALTH_ASSESSMENT(patient_id, ben_visit_no)" + ) + } + } + + val MIGRATION_137_138 = object : Migration(137, 138) { + override fun migrate(database: SupportSQLiteDatabase) { + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN substance_alcohol_impact INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN substance_alcohol_withdrawal INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN substance_alcohol_problematic INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN substance_alcohol_classification TEXT" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN substance_alcohol_system_action TEXT" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN substance_alcohol_frequency TEXT" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN substance_alcohol_loss INTEGER" + ) + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN edRecurrentEpisodeloss INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_recurrent_jerky_movements INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_progressive_memory_loss INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_confusion_disorientation INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_functional_decline INTEGER" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_screening_outcome TEXT" + ) + + database.execSQL( + "ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_referral_required TEXT" + ) + database.execSQL("ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_psychosocial_intervention_provided INTEGER") + database.execSQL("ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_intervention_type TEXT") + database.execSQL("ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_session_date TEXT") + database.execSQL("ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_duration_minutes INTEGER") + database.execSQL("ALTER TABLE MENTAL_HEALTH_SCREENING ADD COLUMN ed_remarks TEXT") + } + } + val MIGRATION_138_139 = object : Migration(138, 139) { + override fun migrate(database: SupportSQLiteDatabase) { + safeAddColumn(database, "BENFLOW", "reproductiveStatusId", "INTEGER") + safeAddColumn(database, "BENFLOW", "reproductiveStatus", "TEXT") + } + } + val MIGRATION_139_140 = object : Migration(139, 140) { + override fun migrate(database: SupportSQLiteDatabase) { + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "improvement_noted", "TEXT") + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "referral_escalation_required", "INTEGER") + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "case_closure_reason", "TEXT") + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "referral_date", "TEXT") + } + } + val MIGRATION_140_141 = object : Migration(140, 141) { + override fun migrate(database: SupportSQLiteDatabase) { + // ELDERLY_HEALTH_ASSESSMENT + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "referral_required", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "referral_level", "TEXT") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "reason_for_referral", "TEXT") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "follow_up_required", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "follow_up_date", "TEXT") + database.execSQL("ALTER TABLE ELDERLY_HEALTH_ASSESSMENT ADD COLUMN case_status TEXT") + database.execSQL("ALTER TABLE ELDERLY_HEALTH_ASSESSMENT ADD COLUMN date_of_death TEXT") + database.execSQL("ALTER TABLE ELDERLY_HEALTH_ASSESSMENT ADD COLUMN remarks TEXT") + + // PAIN_SYMPTOM_ASSESSMENT + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN case_status TEXT") + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN date_of_death TEXT") + database.execSQL("ALTER TABLE PAIN_SYMPTOM_ASSESSMENT ADD COLUMN remarks TEXT") + + // PSYCHOSOCIAL_CAREGIVER_SUPPORT + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN case_status TEXT") + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN date_of_death TEXT") + database.execSQL("ALTER TABLE PSYCHOSOCIAL_CAREGIVER_SUPPORT ADD COLUMN remarks TEXT") + } + } + val MIGRATION_141_142 = object : Migration(141, 142) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add palliative care identification columns to PAIN_SYMPTOM_ASSESSMENT + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "persistent_pain_present", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "pain_assessment_enabled", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "distressing_symptoms_present", "TEXT") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "bedridden_or_severely_dependent", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "life_limiting_illness_known", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "caregiver_support_required", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "palliative_care_eligible", "INTEGER") + } + } + + val MIGRATION_142_143 = object : Migration(142, 143) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add ADL assessment columns to ELDERLY_HEALTH_ASSESSMENT + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "bathing", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "dressing", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "toileting", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "transferring", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "continence", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "feeding", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "total_score", "INTEGER") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "functional_status", "TEXT") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "functional_decline_flag", "INTEGER") + } + } + val MIGRATION_143_144 = object : Migration(143, 144) { + override fun migrate(database: SupportSQLiteDatabase) { + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "ed_reason", "TEXT") + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "ed_confusion_ordrowsiness", "INTEGER") + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "adherence_to_advice", "TEXT") + } + } + + val MIGRATION_144_145 = object : Migration(144, 145) { + override fun migrate(database: SupportSQLiteDatabase) { + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "basic_symptoms_selected", "TEXT") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "basic_symptom_relief_provided", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "basic_psychosocial_support_provided", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "basic_caregiver_counselling_provided", "INTEGER") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "basic_management_remarks", "TEXT") + } + } + val MIGRATION_145_146 = object : Migration(145, 146) { + override fun migrate(database: SupportSQLiteDatabase) { + safeAddColumn(database, "EAR_DIAGNOSIS_ASSESSMENT", "syncState", "INTEGER NOT NULL DEFAULT 0") + safeAddColumn(database, "NOSE_DIAGNOSIS_ASSESSMENT", "syncState", "INTEGER NOT NULL DEFAULT 0") + safeAddColumn(database, "THROAT_DIAGNOSIS_ASSESSMENT", "syncState", "INTEGER NOT NULL DEFAULT 0") + safeAddColumn(database, "ORAL_HEALTH", "syncState", "INTEGER NOT NULL DEFAULT 0") + safeAddColumn(database, "ELDERLY_HEALTH_ASSESSMENT", "syncState", "INTEGER NOT NULL DEFAULT 0") + safeAddColumn(database, "MENTAL_HEALTH_SCREENING", "syncState", "INTEGER NOT NULL DEFAULT 0") + safeAddColumn(database, "PAIN_SYMPTOM_ASSESSMENT", "syncState", "INTEGER NOT NULL DEFAULT 0") + safeAddColumn(database, "PSYCHOSOCIAL_CAREGIVER_SUPPORT", "syncState", "INTEGER NOT NULL DEFAULT 0") + } + } + + // Scrub abortionImg1/2 values that the new save path can't handle: + // 1. Inline base64 (LENGTH > 1024) — pre-fix bloat that overflows + // SQLite's 2 MB CursorWindow. + // 2. Raw gallery `content://` URIs — these carry only transient + // permission from the picker activity; once that dies the URI + // can never be reloaded by Glide / contentResolver and the + // "view" path throws SecurityException. + // Going forward, abortionImg1/2 hold an internal-storage file path; + // base64 encoding happens lazily at upload time. Affected records + // never successfully uploaded, so no server-side state is lost. + val MIGRATION_146_147 = object : Migration(146, 147) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("UPDATE PREGNANCY_ANC SET abortionImg1 = NULL WHERE LENGTH(abortionImg1) > 1024") + database.execSQL("UPDATE PREGNANCY_ANC SET abortionImg2 = NULL WHERE LENGTH(abortionImg2) > 1024") + database.execSQL("UPDATE PREGNANCY_ANC SET abortionImg1 = NULL WHERE abortionImg1 LIKE 'content://%'") + database.execSQL("UPDATE PREGNANCY_ANC SET abortionImg2 = NULL WHERE abortionImg2 LIKE 'content://%'") + } + } + + val MIGRATION_147_148 = object : Migration(147, 148) { + override fun migrate(database: SupportSQLiteDatabase) { + // HWC API now sends/uses facilityID for benflow-linked operations. + safeAddColumn(database, "BENFLOW", "facilityID", "INTEGER") + safeAddColumn(database, "USER", "facilityID", "INTEGER") + safeAddColumn(database, "USER", "facilityType", "TEXT") + safeAddColumn(database, "USER", "facilityName", "TEXT") + safeAddColumn(database, "USER", "employeeId", "TEXT") + safeAddColumn(database, "USER", "locationType", "TEXT") + } + } + + /** + * Safely adds a column to a table, ignoring the error if the column already exists. + * This handles cases where an older version of a CREATE TABLE migration already + * included the column. + */ + private fun safeAddColumn( + database: SupportSQLiteDatabase, + table: String, + column: String, + type: String + ) { + try { + database.execSQL("ALTER TABLE $table ADD COLUMN $column $type") + } catch (_: Exception) { + // Column already exists — safe to ignore + } + } + + fun getInstance(appContext: Context): InAppDb { synchronized(this) { @@ -334,7 +1223,46 @@ abstract class InAppDb : RoomDatabase() { .addMigrations( MIGRATION_106_107, MIGRATION_107_108, - MIGRATION_108_109,MIGRATION_109_110 + MIGRATION_108_109, + MIGRATION_109_110, + MIGRATION_110_111, + MIGRATION_111_112, + MIGRATION_112_113, + MIGRATION_113_114, + MIGRATION_114_115, + MIGRATION_115_116, + MIGRATION_116_117, + MIGRATION_117_118, + MIGRATION_118_119, + MIGRATION_119_120, + MIGRATION_120_121, + MIGRATION_121_122, + MIGRATION_122_123, + MIGRATION_123_124, + MIGRATION_124_125, + MIGRATION_125_126, + MIGRATION_126_127, + MIGRATION_127_128, + MIGRATION_128_129, + MIGRATION_129_130, + MIGRATION_130_131, + MIGRATION_131_132, + MIGRATION_132_133, + MIGRATION_133_134, + MIGRATION_134_135, + MIGRATION_135_136, + MIGRATION_136_137, + MIGRATION_137_138, + MIGRATION_138_139, + MIGRATION_139_140, + MIGRATION_140_141, + MIGRATION_141_142, + MIGRATION_142_143, + MIGRATION_143_144, + MIGRATION_144_145, + MIGRATION_145_146, + MIGRATION_146_147, + MIGRATION_147_148 ) .fallbackToDestructiveMigration() .setQueryCallback( @@ -354,4 +1282,4 @@ abstract class InAppDb : RoomDatabase() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/LabProcedureMasterSeed.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/LabProcedureMasterSeed.kt new file mode 100644 index 000000000..8df4dcd56 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/LabProcedureMasterSeed.kt @@ -0,0 +1,14 @@ +package org.piramalswasthya.cho.database.room + +/** + * Constants for [org.piramalswasthya.cho.repositories.ProcedureRepo.ensureLabProcedureMasterSeed]. + * Procedure rows are taken from [org.piramalswasthya.cho.model.ProceduresMasterData] (Laboratory); + * component rows are built from [org.piramalswasthya.cho.network.AmritApiService.getProcedureFields]. + */ +object LabProcedureMasterSeed { + + const val PRESCRIPTION_ID = 2802381L + + /** Default radio options when the API does not return option lists (RadioButton). */ + val radioButtonDefaultOptions = listOf("Negative", "Positive") +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/SyncModuleIds.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/SyncModuleIds.kt new file mode 100644 index 000000000..ab9bc1f83 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/SyncModuleIds.kt @@ -0,0 +1,8 @@ +package org.piramalswasthya.cho.database.room + +object SyncModuleIds { + const val DELIVERY_OUTCOME = 9 + const val PNC = 10 + const val INFANT_REG = 11 + const val CHILD_REG = 12 +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/SyncStateValue.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/SyncStateValue.kt new file mode 100644 index 000000000..1533a2f36 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/SyncStateValue.kt @@ -0,0 +1,11 @@ +package org.piramalswasthya.cho.database.room + +/** + * Integer values for tables that persist sync state as Int columns. + * Keep these aligned with SyncState enum ordinal positions. + */ +object SyncStateValue { + const val UNSYNCED = 0 + const val SYNCING = 1 + const val SYNCED = 2 +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/AshaDueListDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/AshaDueListDao.kt new file mode 100644 index 000000000..a892938ff --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/AshaDueListDao.kt @@ -0,0 +1,12 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import org.piramalswasthya.cho.model.AshaDueListCache + +@Dao +fun interface AshaDueListDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(record: AshaDueListCache): Long +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/BenFlowDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/BenFlowDao.kt index 26de21f39..04c072ffb 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/BenFlowDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/BenFlowDao.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow import org.piramalswasthya.cho.model.BenFlow import org.piramalswasthya.cho.model.BlockMaster @@ -14,8 +15,37 @@ interface BenFlowDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertBenFlow(benFlow: BenFlow) - @Query("SELECT COUNT(*) FROM Visit_DB INNER JOIN PATIENT ON PATIENT.patientID = Visit_DB.patientID WHERE PATIENT.genderID = :genderID AND (Visit_DB.category LIKE 'General OPD') AND createdBy = :createdBy AND Visit_DB.benVisitDate LIKE '%' || :periodParam || '%' ") - suspend fun getOpdCount(genderID: Int, periodParam: String, createdBy: String) : Int? + @Query( + "SELECT COUNT(*) " + + "FROM PATIENT pat " + + "LEFT JOIN PATIENT_VISIT_INFO_SYNC vis ON pat.patientID = vis.patientID " + + "LEFT JOIN PATIENT_VISIT_INFO_SYNC AS latestVisit ON pat.patientID = latestVisit.patientID AND vis.benVisitNo < latestVisit.benVisitNo " + + "LEFT JOIN GENDER_MASTER gen ON gen.genderID = pat.genderID " + + "WHERE ( " + + "(:genderBucket = 'male' AND LOWER(IFNULL(gen.gender_name, '')) = 'male') " + + "OR (:genderBucket = 'female' AND LOWER(IFNULL(gen.gender_name, '')) = 'female') " + + "OR (:genderBucket = 'other' AND TRIM(IFNULL(gen.gender_name, '')) <> '' AND LOWER(IFNULL(gen.gender_name, '')) NOT IN ('male', 'female')) " + + ") " + + "AND vis.nurseFlag = 9 " + + "AND latestVisit.patientID IS NULL " + + "AND NOT (vis.doctorFlag = 9 AND IFNULL(vis.pharmacist_flag, 0) IN (0, 9)) " + + "AND vis.patientID IS NOT NULL " + + "AND COALESCE(vis.visitDate, pat.registrationDate) IS NOT NULL " + + "AND strftime('%Y-%m-%d', COALESCE(vis.visitDate, pat.registrationDate) / 1000, 'unixepoch', 'localtime') LIKE '%' || :periodParam || '%'" + ) + suspend fun getDoctorModuleOpdCount(genderBucket: String, periodParam: String) : Int? + + @Query( + "SELECT COUNT(*) " + + "FROM PATIENT pat " + + "LEFT JOIN PATIENT_VISIT_INFO_SYNC vis ON pat.patientID = vis.patientID " + + "LEFT JOIN PATIENT_VISIT_INFO_SYNC AS latestVisit ON pat.patientID = latestVisit.patientID AND vis.benVisitNo < latestVisit.benVisitNo " + + "WHERE vis.nurseFlag = 9 " + + "AND latestVisit.patientID IS NULL " + + "AND NOT (vis.doctorFlag = 9 AND IFNULL(vis.pharmacist_flag, 0) IN (0, 9)) " + + "AND vis.patientID IS NOT NULL" + ) + fun observeDoctorModuleListCount(): Flow @Query("SELECT COUNT(*) FROM Visit_DB WHERE (Visit_DB.category LIKE 'ANC') AND createdBy = :createdBy AND Visit_DB.benVisitDate LIKE '%' || :periodParam || '%' ") suspend fun getAncCount(periodParam: String, createdBy: String) : Int? @@ -23,6 +53,52 @@ interface BenFlowDao { @Query("SELECT COUNT(*) FROM Visit_DB WHERE (Visit_DB.category LIKE 'PNC') AND createdBy = :createdBy AND Visit_DB.benVisitDate LIKE '%' || :periodParam || '%' ") suspend fun getPncCount(periodParam: String, createdBy: String) : Int? + @Query( + "SELECT COUNT(*) FROM Visit_DB " + + "WHERE LOWER(TRIM(IFNULL(Visit_DB.category, ''))) LIKE '%ncd screening%' " + + "AND LOWER(TRIM(IFNULL(createdBy, ''))) = LOWER(TRIM(:createdBy)) " + + "AND IFNULL(Visit_DB.benVisitDate, '') LIKE '%' || :periodParam || '%'" + ) + suspend fun getNcdCount(periodParam: String, createdBy: String) : Int? + + @Query( + "SELECT COUNT(*) " + + "FROM Visit_DB " + + "INNER JOIN PATIENT pat ON pat.patientID = Visit_DB.patientID " + + "LEFT JOIN GENDER_MASTER gen ON gen.genderID = pat.genderID " + + "WHERE LOWER(TRIM(IFNULL(Visit_DB.category, ''))) LIKE '%ncd screening%' " + + "AND LOWER(TRIM(IFNULL(Visit_DB.createdBy, ''))) = LOWER(TRIM(:createdBy)) " + + "AND IFNULL(Visit_DB.benVisitDate, '') LIKE '%' || :periodParam || '%' " + + "AND ( " + + "(:genderBucket = 'male' AND LOWER(IFNULL(gen.gender_name, '')) = 'male') " + + "OR (:genderBucket = 'female' AND LOWER(IFNULL(gen.gender_name, '')) = 'female') " + + "OR (:genderBucket = 'other' AND TRIM(IFNULL(gen.gender_name, '')) <> '' AND LOWER(IFNULL(gen.gender_name, '')) NOT IN ('male', 'female')) " + + ")" + ) + suspend fun getNcdCountByGender( + genderBucket: String, + periodParam: String, + createdBy: String + ) : Int? + + @Query( + "SELECT COUNT(*) " + + "FROM Visit_DB " + + "INNER JOIN PATIENT pat ON pat.patientID = Visit_DB.patientID " + + "LEFT JOIN GENDER_MASTER gen ON gen.genderID = pat.genderID " + + "WHERE LOWER(TRIM(IFNULL(Visit_DB.category, ''))) LIKE '%ncd screening%' " + + "AND LOWER(TRIM(IFNULL(Visit_DB.createdBy, ''))) = LOWER(TRIM(:createdBy)) " + + "AND ( " + + "(:genderBucket = 'male' AND LOWER(IFNULL(gen.gender_name, '')) = 'male') " + + "OR (:genderBucket = 'female' AND LOWER(IFNULL(gen.gender_name, '')) = 'female') " + + "OR (:genderBucket = 'other' AND TRIM(IFNULL(gen.gender_name, '')) <> '' AND LOWER(IFNULL(gen.gender_name, '')) NOT IN ('male', 'female')) " + + ")" + ) + suspend fun getNcdCountAllTimeByGender( + genderBucket: String, + createdBy: String + ) : Int? + @Query("SELECT COUNT(*) FROM Visit_DB WHERE (Visit_DB.category LIKE 'Neonatal and Infant Health Care Services') AND createdBy = :createdBy AND Visit_DB.benVisitDate LIKE '%' || :periodParam || '%' ") suspend fun getImmunizationCount(periodParam: String, createdBy: String) : Int? @@ -59,4 +135,7 @@ interface BenFlowDao { @Query("UPDATE BENFLOW SET nurseFlag = 9, doctorFlag = 9 WHERE benFlowID = :benFlowID") suspend fun updateDoctorCompletedWithoutTest(benFlowID: Long) + @Query("UPDATE BENFLOW SET facilityID = :facilityID WHERE facilityID IS NULL") + suspend fun backfillNullFacilityID(facilityID: Int): Int + } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/DeliveryOutcomeDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/DeliveryOutcomeDao.kt index f6122501e..6c0f25e34 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/DeliveryOutcomeDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/DeliveryOutcomeDao.kt @@ -7,7 +7,11 @@ import androidx.room.MapInfo import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.database.room.SyncModuleIds +import org.piramalswasthya.cho.database.room.SyncStateValue import org.piramalswasthya.cho.model.DeliveryOutcomeCache +import org.piramalswasthya.cho.model.SyncStatusCache @Dao interface DeliveryOutcomeDao { @@ -25,9 +29,27 @@ interface DeliveryOutcomeDao { suspend fun getAllUnprocessedDeliveryOutcomes(): List @Update - suspend fun updateDeliveryOutcome(it: DeliveryOutcomeCache) + suspend fun updateDeliveryOutcome(it: DeliveryOutcomeCache): Int + + @Query( + """ + SELECT + ${SyncModuleIds.DELIVERY_OUTCOME} AS id, + 'Delivery Outcome' AS name, + COUNT(CASE WHEN do.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN do.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN do.syncState = :syncingState THEN 1 END) AS syncing + FROM DELIVERY_OUTCOME do + WHERE do.isActive = 1 + """ + ) + fun getDeliveryOutcomeSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> // @MapInfo(keyColumn = "benId", valueColumn ="dateOfDelivery") // @Query("select * from delivery_outcome where isActive = 1") // suspend fun getAllBenIdAndDeliverDate(): Map -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/EarDiagnosisAssessmentDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/EarDiagnosisAssessmentDao.kt new file mode 100644 index 000000000..496b64005 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/EarDiagnosisAssessmentDao.kt @@ -0,0 +1,48 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.EarDiagnosisAssessment + +@Dao +interface EarDiagnosisAssessmentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(assessment: EarDiagnosisAssessment): Long + + @Update + suspend fun update(assessment: EarDiagnosisAssessment) + + @Query("SELECT * FROM EAR_DIAGNOSIS_ASSESSMENT WHERE assessment_id = :id") + suspend fun getAssessmentById(id: Long): EarDiagnosisAssessment? + + @Query( + "SELECT * FROM EAR_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientId(patientID: String): EarDiagnosisAssessment? + + + @Query( + "SELECT * FROM EAR_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID " + + "AND ben_visit_no = :benVisitNo " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientIdAndVisitNo(patientID: String, benVisitNo: Int): EarDiagnosisAssessment? + @Query("SELECT * FROM EAR_DIAGNOSIS_ASSESSMENT WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM EAR_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/EcrDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/EcrDao.kt index 655c8358b..38fffdbee 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/EcrDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/EcrDao.kt @@ -1,43 +1,114 @@ package org.piramalswasthya.cho.database.room.dao import androidx.room.* +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.EligibleCoupleRegCache import org.piramalswasthya.cho.model.EligibleCoupleTrackingCache +import org.piramalswasthya.cho.model.Patient +import org.piramalswasthya.cho.model.PatientWithECRCache +import org.piramalswasthya.cho.model.SyncStatusCache @Dao interface EcrDao { -// @Insert(onConflict = OnConflictStrategy.REPLACE) -// suspend fun upsert(vararg ecrCache: EligibleCoupleRegCache) -// -// @Query("SELECT * FROM ELIGIBLE_COUPLE_REG WHERE processed in ('N', 'U')") -// suspend fun getAllUnprocessedECR(): List -// + // ===== Eligible Couple Registration Queries ===== + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(vararg ecrCache: EligibleCoupleRegCache) + + @Query("SELECT * FROM ELIGIBLE_COUPLE_REG WHERE processed in ('N', 'U')") + suspend fun getAllUnprocessedECR(): List + + @Query("select count(*) from ELIGIBLE_COUPLE_REG") + suspend fun ecrCount(): Int + + @Query("SELECT * FROM ELIGIBLE_COUPLE_REG WHERE patientID = :patientId limit 1") + suspend fun getSavedECR(patientId: String): EligibleCoupleRegCache? + + @Update + suspend fun update(it: EligibleCoupleRegCache) + + @Transaction + @Query("SELECT * FROM PATIENT") + fun getAllPatientsWithECR(): Flow> + + @Transaction + @Query("SELECT * FROM PATIENT WHERE patientID = :patientId") + suspend fun getPatientWithECR(patientId: String): PatientWithECRCache? + + // ===== Eligible Couple Tracking Queries ===== + @Query("SELECT * FROM ELIGIBLE_COUPLE_TRACKING WHERE processed in ('N','U')") suspend fun getAllUnprocessedECT(): List @Query("SELECT * FROM ELIGIBLE_COUPLE_TRACKING WHERE patientID = :benId") suspend fun getAllECT(benId: String): List - -// -// @Query("select count(*) from ELIGIBLE_COUPLE_REG") -// suspend fun ecrCount(): Int -// -// @Query("SELECT * FROM ELIGIBLE_COUPLE_REG WHERE benId =:benId limit 1") -// suspend fun getSavedECR(benId: Long): EligibleCoupleRegCache? -// -// @Update -// suspend fun update(it: EligibleCoupleRegCache) -// @Update suspend fun updateEligibleCoupleTracking(it: EligibleCoupleTrackingCache) @Query("select * from eligible_couple_tracking where patientID = :patientID and visitDate =:visitDate limit 1") // @Query("select * from eligible_couple_tracking where benId = :benId and CAST((strftime('%s','now') - visitDate/1000)/60/60/24 AS INTEGER) < 30 order by visitDate limit 1") - fun getEct(patientID: String, visitDate : Long): EligibleCoupleTrackingCache? + suspend fun getEct(patientID: String, visitDate : Long): EligibleCoupleTrackingCache? + + @Query( + """ + SELECT * FROM eligible_couple_tracking + WHERE patientID = :patientID + AND date(visitDate / 1000, 'unixepoch', 'localtime') = date(:visitDate / 1000, 'unixepoch', 'localtime') + LIMIT 1 + """ + ) + suspend fun getEctByVisitDay(patientID: String, visitDate: Long): EligibleCoupleTrackingCache? - @Query("select * from eligible_couple_tracking where patientID = :patientID order by visitDate desc limit 1") + @Query("select * from eligible_couple_tracking where patientID = :patientID and isActive = 1 order by visitDate desc limit 1") suspend fun getLatestEct(patientID: String) : EligibleCoupleTrackingCache? + @Query(""" + SELECT p.* FROM PATIENT p + LEFT JOIN ( + SELECT patientID, MAX(visitDate) AS lastVisitDate + FROM ELIGIBLE_COUPLE_TRACKING + WHERE isActive = 1 + GROUP BY patientID + ) ect ON ect.patientID = p.patientID + WHERE p.statusOfWomanID = 1 + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + ORDER BY COALESCE(ect.lastVisitDate, 0) DESC, p.registrationDate DESC + """) + fun getPatientsForTrackingList(): Flow> + + @Query(""" + SELECT COUNT(*) FROM PATIENT + WHERE statusOfWomanID = 1 + AND genderID = 2 + AND age BETWEEN 15 AND 49 + """) + fun getEligibleCoupleTrackingCount(): Flow + + @Transaction + @Query( + """ + SELECT + 6 AS id, + 'EC Tracking' AS name, + COUNT(CASE WHEN ect.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN ect.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN ect.syncState = :syncingState THEN 1 END) AS syncing + FROM ELIGIBLE_COUPLE_TRACKING ect + INNER JOIN PATIENT p ON p.patientID = ect.patientID + WHERE p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + AND p.statusOfWomanID = 1 + """ + ) + fun getEligibleCoupleTrackingSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(vararg eligibleCoupleTrackingCache: EligibleCoupleTrackingCache) @@ -45,4 +116,4 @@ interface EcrDao { // suspend fun ectWithsameCreateDateExists(createdDate: Long): Boolean -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ElderlyHealthAssessmentDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ElderlyHealthAssessmentDao.kt new file mode 100644 index 000000000..55c80311b --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ElderlyHealthAssessmentDao.kt @@ -0,0 +1,38 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.ElderlyHealthAssessment + +@Dao +interface ElderlyHealthAssessmentDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(assessment: ElderlyHealthAssessment) + + @Update + suspend fun update(assessment: ElderlyHealthAssessment) + + @Query("SELECT * FROM ELDERLY_HEALTH_ASSESSMENT WHERE assessment_id = :id") + suspend fun getAssessmentById(id: Long): ElderlyHealthAssessment? + + @Query("SELECT * FROM ELDERLY_HEALTH_ASSESSMENT WHERE patient_id = :patientID ORDER BY assessment_id DESC LIMIT 1") + suspend fun getAssessmentByPatientId(patientID: String): ElderlyHealthAssessment? + + @Query("SELECT * FROM ELDERLY_HEALTH_ASSESSMENT WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo") + suspend fun getAssessment(patientID: String, benVisitNo: Int): ElderlyHealthAssessment? + + @Query("SELECT * FROM ELDERLY_HEALTH_ASSESSMENT WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM ELDERLY_HEALTH_ASSESSMENT " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/HealthCenterDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/HealthCenterDao.kt index edfc9c093..c89ac7f13 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/HealthCenterDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/HealthCenterDao.kt @@ -45,6 +45,9 @@ interface HealthCenterDao { @Query("SELECT * from Item_Master_List where itemID = :id") fun getItemMasterListById(id:Int): ItemMasterList + @Query("SELECT quantityInHand FROM Item_Master_List WHERE itemID = :id") + suspend fun getItemMasterQtyInHandById(id: Int): Int? + @Query("SELECT * from Counselling_Provided") fun getAllCounsellingProvided():LiveData> } diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/InfantRegDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/InfantRegDao.kt new file mode 100644 index 000000000..d0bbdb6b0 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/InfantRegDao.kt @@ -0,0 +1,138 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.database.room.SyncModuleIds +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.InfantRegCache +import org.piramalswasthya.cho.model.InfantRegWithPatient +import org.piramalswasthya.cho.model.PatientWithDeliveryOutcomeAndInfantRegCache +import org.piramalswasthya.cho.model.SyncStatusCache + +@Dao +interface InfantRegDao { + + @Query(""" + SELECT * FROM INFANT_REG + WHERE motherPatientID = :patientID + AND babyIndex = :babyIndex + AND isActive = 1 + ORDER BY updatedDate DESC, createdDate DESC, id DESC + LIMIT 1 + """) + suspend fun getInfantReg(patientID: String, babyIndex: Int): InfantRegCache? + + @Query("SELECT * FROM INFANT_REG WHERE childPatientID = :childPatientID limit 1") + suspend fun getInfantRegFromChildPatientID(childPatientID: String): InfantRegCache? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun saveInfantReg(infantRegCache: InfantRegCache) + + @Query("SELECT * FROM INFANT_REG WHERE processed in ('N', 'U')") + suspend fun getAllUnprocessedInfantReg(): List + + @Update + suspend fun updateInfantReg(it: InfantRegCache) + + @Query("select count(*) from INFANT_REG where isActive = 1 and motherPatientID = :patientID") + suspend fun getNumBabiesRegistered(patientID: String): Int + + @Query("SELECT * FROM INFANT_REG WHERE motherPatientID in (:patientIDs) and isActive = 1") + suspend fun getAllInfantRegs(patientIDs: Set): List + + /** + * Get all patients with delivery outcome for infant registration + * Returns patients who have active delivery outcome with liveBirth > 0 + */ + @Transaction + @Query(""" + SELECT p.* FROM PATIENT p + INNER JOIN DELIVERY_OUTCOME do ON p.patientID = do.patientID + WHERE do.isActive = 1 + AND do.liveBirth > 0 + ORDER BY do.dateOfDelivery DESC + """) + fun getListForInfantRegister(): Flow> + + /** + * Get count of infants eligible for registration (sum of liveBirth) + */ + @Query(""" + SELECT SUM(do.liveBirth) + FROM DELIVERY_OUTCOME do + INNER JOIN PATIENT p ON do.patientID = p.patientID + WHERE do.isActive = 1 + AND do.liveBirth > 0 + """) + fun getInfantRegisterCount(): Flow + + @Query( + """ + SELECT + ${SyncModuleIds.INFANT_REG} AS id, + 'Infant Reg.' AS name, + COUNT(CASE WHEN ir.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN ir.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN ir.syncState = :syncingState THEN 1 END) AS syncing + FROM INFANT_REG ir + WHERE ir.isActive = 1 + """ + ) + fun getInfantRegSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> + + /** + * Get patient with delivery outcome and infant reg by patientID + */ + @Transaction + @Query("SELECT * FROM PATIENT WHERE patientID = :patientID") + suspend fun getPatientWithDeliveryOutcomeAndInfantRegByID(patientID: String): PatientWithDeliveryOutcomeAndInfantRegCache? + + /** + * Get all registered infants for child registration list + * Returns active infants from INFANT_REG (independent of mother PATIENT completeness). + */ + @Transaction + @Query(""" + SELECT ir.* FROM INFANT_REG ir + WHERE ir.isActive = 1 + ORDER BY ir.updatedDate DESC, ir.createdDate DESC + """) + fun getAllRegisteredInfants(): Flow> + + /** + * Get count of registered infants + */ + @Query(""" + SELECT COUNT(*) FROM INFANT_REG ir + WHERE ir.isActive = 1 + """) + fun getAllRegisteredInfantsCount(): Flow + + @Query( + """ + SELECT + ${SyncModuleIds.CHILD_REG} AS id, + 'Child Reg.' AS name, + COUNT(CASE WHEN ir.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN ir.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN ir.syncState = :syncingState THEN 1 END) AS syncing + FROM INFANT_REG ir + WHERE ir.isActive = 1 + AND (ir.childPatientID IS NOT NULL OR ir.processed = 'C') + """ + ) + fun getChildRegSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/LanguageDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/LanguageDao.kt index 30f8a9c78..66a216c02 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/LanguageDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/LanguageDao.kt @@ -13,4 +13,7 @@ interface LanguageDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAllLanguages(language: Language) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLanguages(languages: List) } diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/MaternalHealthDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/MaternalHealthDao.kt index b71e8fe17..2d0b9fbf7 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/MaternalHealthDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/MaternalHealthDao.kt @@ -3,13 +3,18 @@ package org.piramalswasthya.cho.database.room.dao import androidx.lifecycle.LiveData import androidx.room.* import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.database.room.SyncStateValue import org.piramalswasthya.cho.model.* +import org.piramalswasthya.cho.model.SyncStatusCache @Dao interface MaternalHealthDao { @Query("select * from pregnancy_register where patientID = :patientID and active = 1 limit 1") suspend fun getSavedRecord(patientID: String): PregnantWomanRegistrationCache? + @Query("select * from pregnancy_register where patientID = :patientID order by createdDate desc limit 1") + suspend fun getLatestRegistrationRecord(patientID: String): PregnantWomanRegistrationCache? + // @Query("select * from pregnancy_register where patientID = :patientID and active = 1 limit 1") // fun getSavedRecordObserve(patientID: String): LiveData @@ -22,15 +27,24 @@ interface MaternalHealthDao { @Query("select * from pregnancy_anc where patientID = :patientID order by ancDate desc limit 1") suspend fun getLastAnc(patientID: String): PregnantWomanAncCache? + @Query("select * from pregnancy_anc where patientID = :patientID and weight IS NOT NULL and isActive = 1 order by visitNumber desc limit 1") + suspend fun getLastCompletedAnc(patientID: String): PregnantWomanAncCache? + @Query("select visitNumber from pregnancy_anc where patientID = :patientID order by visitNumber desc limit 1") suspend fun getLastVisitNumber(patientID: String): Int? + @Query("select visitNumber from pregnancy_anc where patientID = :patientID and isActive = 1 order by visitNumber desc limit 1") + suspend fun getLastActiveVisitNumber(patientID: String): Int? + @Query("select * from pregnancy_anc where patientID = :patientID and visitNumber = :visitNumber limit 1") suspend fun getSavedRecord(patientID: String, visitNumber: Int): PregnantWomanAncCache? @Query("select * from pregnancy_anc where isActive = 1 and patientID = :patientID") suspend fun getAllActiveAncRecords(patientID: String): List + @Query("select * from pregnancy_anc where isActive = 1 and patientID = :patientID and weight IS NOT NULL order by visitNumber") + suspend fun getCompletedActiveAncRecords(patientID: String): List + // @Query("select * from pregnancy_anc where isActive = 1 and patientID = :patientID") // fun getAllActiveAncRecordsObserve(patientID: String): LiveData> // @Query("select * from pregnancy_anc where benId in (:benId) and isActive = 1") @@ -43,7 +57,7 @@ interface MaternalHealthDao { // fun getLatestAnc(benId: Long): PregnantWomanAncCache? // @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveRecord(pregnancyRegistrationForm: PregnantWomanRegistrationCache) + suspend fun saveRecord(pregnancyRegistrationForm: PregnantWomanRegistrationCache): Long @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveRecord(ancCache: PregnantWomanAncCache) @@ -65,14 +79,319 @@ interface MaternalHealthDao { @Query("SELECT * FROM pregnancy_register WHERE processed in ('N', 'U')") suspend fun getAllUnprocessedPWRs(): List + @Query("SELECT * FROM pregnancy_register WHERE active = 1") + suspend fun getAllActivePregnancyRegistrations(): List + @Update suspend fun updateANC(vararg it: PregnantWomanAncCache) -// -// @Update -// suspend fun updatePwr(it: PregnantWomanRegistrationCache) -// @Query("select * from HRP_NON_PREGNANT_ASSESS assess where ((select count(*) from BEN_BASIC_CACHE b where benId = assess.benId and b.reproductiveStatusId = 1) = 1)") -// fun getAllNonPregnancyAssessRecords(): Flow> -// -// @Query("select * from HRP_PREGNANT_ASSESS assess where ((select count(*) from BEN_BASIC_CACHE b where benId = assess.benId and b.reproductiveStatusId = 2) = 1)") -// fun getAllPregnancyAssessRecords(): Flow> -} \ No newline at end of file + + /** Targeted upsert of post-upload sync state. Avoids whole-row writes + * that can race with other workers in the sync chain re-writing the + * same row with a stale snapshot. */ + @Query("UPDATE pregnancy_anc SET syncState = 2, processed = 'P' WHERE id = :id") + suspend fun markAncSynced(id: Long) + + @Update + suspend fun updatePwr(vararg it: PregnantWomanRegistrationCache) + + /** + * Get all patients with their pregnancy registration data + * Returns only patients who have an active pregnancy registration + * Returns Flow for reactive updates + */ + @Transaction + @Query(""" + SELECT DISTINCT p.* FROM PATIENT p + LEFT OUTER JOIN PREGNANCY_REGISTER pwr ON p.patientID = pwr.patientID + WHERE (pwr.patientID IS NULL OR pwr.active = 1) + AND p.genderID = 2 + AND p.maritalStatusID = 2 + AND p.statusOfWomanID IN (2, 3) + AND p.age BETWEEN 15 AND 49 + ORDER BY p.registrationDate DESC + """) + fun getAllPatientsWithPWR(): Flow> + + @Transaction + @Query(""" + SELECT DISTINCT p.* FROM PATIENT p + LEFT OUTER JOIN PREGNANCY_REGISTER pwr ON p.patientID = pwr.patientID + WHERE (pwr.patientID IS NULL OR pwr.active = 1) + AND p.genderID = 2 + AND p.maritalStatusID = 2 + AND p.statusOfWomanID IN (2) + AND p.age BETWEEN 15 AND 49 + AND NOT EXISTS ( + SELECT 1 FROM PREGNANCY_ANC anc + WHERE anc.patientID = p.patientID + AND anc.isActive = 1 + AND anc.pregnantWomanDelivered = 1 + ) + ORDER BY p.registrationDate DESC + """) + fun getAllPatientsWithANC(): Flow> + + @Transaction + @Query(""" + SELECT p.* FROM PATIENT p + WHERE EXISTS ( + SELECT 1 FROM ELIGIBLE_COUPLE_TRACKING ect + WHERE ect.patientID = p.patientID + AND ( + LOWER(IFNULL(ect.pregnancyTestResult, '')) = 'positive' + OR LOWER(IFNULL(ect.isPregnant, '')) IN ('yes', 'true') + ) + ) + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + AND p.statusOfWomanID IN (2) + ORDER BY p.registrationDate DESC + """) + fun getAllPatientsWithPWRFromEligibleCoupleTracking(): Flow> + + + /** + * Get specific patient with pregnancy registration + */ + @Transaction + @Query("SELECT * FROM PATIENT WHERE patientID = :patientID") + suspend fun getPatientWithPWR(patientID: String): PatientWithPwrCache? + + /** + * Get count of pregnant women registrations + */ + @Query("SELECT COUNT(DISTINCT patientID) FROM pregnancy_register WHERE active = 1") + fun getPWRCount(): Flow + + @Transaction + @Query( + """ + SELECT + 7 AS id, + 'PW Registration' AS name, + COUNT(CASE WHEN pwr.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN pwr.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN pwr.syncState = :syncingState THEN 1 END) AS syncing + FROM PREGNANCY_REGISTER pwr + WHERE pwr.active = 1 + """ + ) + fun getPregnancyRegistrationSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> + + /** + * Get patientIDs for Delivery Outcome list from ANC (isDelivered=true) + DELIVERY_OUTCOME. + */ + @Query(""" + SELECT src.patientID + FROM ( + SELECT patientID, MAX(updatedDate) AS lastUpdated + FROM PREGNANCY_ANC + WHERE isActive = 1 + AND pregnantWomanDelivered = 1 + GROUP BY patientID + UNION ALL + SELECT patientID, MAX(updatedDate) AS lastUpdated + FROM DELIVERY_OUTCOME + WHERE isActive = 1 + GROUP BY patientID + ) src + INNER JOIN PATIENT p ON src.patientID = p.patientID + WHERE p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + GROUP BY src.patientID + ORDER BY MAX(src.lastUpdated) DESC + """) + fun getDeliveredWomenPatientIDs(): Flow> + + /** + * Get count of delivered women from ANC (isDelivered=true) + DELIVERY_OUTCOME. + */ + @Query(""" + SELECT COUNT(DISTINCT src.patientID) + FROM ( + SELECT patientID + FROM PREGNANCY_ANC + WHERE isActive = 1 + AND pregnantWomanDelivered = 1 + UNION + SELECT patientID + FROM DELIVERY_OUTCOME + WHERE isActive = 1 + ) src + INNER JOIN PATIENT p ON src.patientID = p.patientID + WHERE p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + """) + fun getDeliveredWomenCount(): Flow + + @Transaction + @Query( + """ + SELECT + 8 AS id, + 'PW ANC' AS name, + COUNT(CASE WHEN anc.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN anc.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN anc.syncState = :syncingState THEN 1 END) AS syncing + FROM PREGNANCY_ANC anc + WHERE anc.isActive = 1 + """ + ) + fun getAncSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> + + /** + * Get count of women with saved delivery outcome only (for neonatal outcome module). + */ + @Query(""" + SELECT COUNT(DISTINCT do.patientID) + FROM DELIVERY_OUTCOME do + INNER JOIN PATIENT p ON do.patientID = p.patientID + WHERE do.isActive = 1 + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + """) + fun getSavedDeliveryOutcomeWomenCount(): Flow + + /** + * Get patient with PWR by patientID + */ + @Transaction + @Query("SELECT * FROM PATIENT WHERE patientID = :patientID") + suspend fun getPatientWithPWRByID(patientID: String): PatientWithPwrCache? + + /** + * Get delivered women by patientIDs (batch query to avoid N+1). + * Keeps LEFT join on pregnancy register so list still shows if PWR row is missing locally. + */ + @Transaction + @Query(""" + SELECT DISTINCT p.* FROM PATIENT p + LEFT JOIN PREGNANCY_REGISTER pwr ON p.patientID = pwr.patientID + WHERE p.patientID IN (:patientIDs) + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + ORDER BY p.registrationDate DESC + """) + suspend fun getDeliveredWomenByIDs(patientIDs: List): List + + /** + * Get all patients with abortion records (isAborted = 1 and abortionDate is not null) + * Joins Patient, PWR, and ANC to find women with abortions + */ + @Transaction + @Query(""" + SELECT DISTINCT p.* FROM PATIENT p + INNER JOIN PREGNANCY_ANC anc ON p.patientID = anc.patientID + WHERE anc.isAborted = 1 + AND anc.abortionDate IS NOT NULL + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + ORDER BY anc.abortionDate DESC + """) + fun getAllAbortionWomenList(): Flow> + + /** + * Get count of abortion women + */ + @Query(""" + SELECT COUNT(DISTINCT p.patientID) FROM PATIENT p + INNER JOIN PREGNANCY_ANC anc ON p.patientID = anc.patientID + WHERE anc.isAborted = 1 + AND anc.abortionDate IS NOT NULL + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + """) + fun getAllAbortionWomenCount(): Flow + + @Transaction + @Query( + """ + SELECT + 13 AS id, + 'Abortion List' AS name, + COUNT(CASE WHEN anc.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN anc.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN anc.syncState = :syncingState THEN 1 END) AS syncing + FROM PREGNANCY_ANC anc + WHERE anc.isAborted = 1 + AND anc.abortionDate IS NOT NULL + """ + ) + fun getAbortionSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> + + /** + * Get all patients registered for pregnancy (eligible for PMSMA) + * Returns patients with active pregnancy registration + */ + @Transaction + @Query(""" + SELECT DISTINCT p.* FROM PATIENT p + INNER JOIN PREGNANCY_REGISTER pwr ON p.patientID = pwr.patientID + WHERE pwr.active = 1 + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + ORDER BY pwr.createdDate DESC + """) + fun getAllRegisteredPmsmaWomenList(): Flow> + + /** + * Get count of PMSMA eligible women + */ + @Query(""" + SELECT COUNT(DISTINCT p.patientID) FROM PATIENT p + INNER JOIN PREGNANCY_REGISTER pwr ON p.patientID = pwr.patientID + WHERE pwr.active = 1 + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + """) + fun getAllRegisteredPmsmaWomenCount(): Flow + + /** + * Get patientIDs of women who have at least one active ANC visit with anyHighRisk = true. + * Intersected against the ANC eligibility Flow in the repo to guarantee + * e-PMSMA list ⊆ ANC list. + */ + @Query(""" + SELECT DISTINCT anc.patientID FROM PREGNANCY_ANC anc + WHERE anc.isActive = 1 + AND anc.anyHighRisk = 1 + """) + fun getHighRiskAncPatientIDs(): Flow> + + /** + * Get patientIDs of women who have a saved delivery outcome (eligible for neonatal outcome) + */ + @Query(""" + SELECT DISTINCT do.patientID FROM DELIVERY_OUTCOME do + INNER JOIN PATIENT p ON do.patientID = p.patientID + WHERE do.isActive = 1 + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + ORDER BY do.updatedDate DESC + """) + fun getNeonatalOutcomeEligibleWomenPatientIDs(): Flow> + + /** + * Get count of women eligible for neonatal outcome + */ + @Query(""" + SELECT COUNT(DISTINCT do.patientID) FROM DELIVERY_OUTCOME do + INNER JOIN PATIENT p ON do.patientID = p.patientID + WHERE do.isActive = 1 + AND p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + """) + fun getNeonatalOutcomeEligibleWomenCount(): Flow +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/MentalHealthScreeningDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/MentalHealthScreeningDao.kt new file mode 100644 index 000000000..2129a7933 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/MentalHealthScreeningDao.kt @@ -0,0 +1,54 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.MentalHealthScreeningCache + +@Dao +interface MentalHealthScreeningDao { + + @Insert(onConflict = OnConflictStrategy.Companion.REPLACE) + suspend fun insert(screening: MentalHealthScreeningCache): Long + + @Update + suspend fun update(screening: MentalHealthScreeningCache) + + @Query( + "SELECT * FROM MENTAL_HEALTH_SCREENING " + + "WHERE screening_id = :id" + ) + suspend fun getScreeningById(id: Long): MentalHealthScreeningCache? + + @Query( + "SELECT * FROM MENTAL_HEALTH_SCREENING " + + "WHERE patient_id = :patientID " + + "ORDER BY screening_id DESC LIMIT 1" + ) + suspend fun getScreeningByPatientId(patientID: String): MentalHealthScreeningCache? + + @Query( + "SELECT * FROM MENTAL_HEALTH_SCREENING " + + "WHERE patient_id = :patientID " + + "AND ben_visit_no = :benVisitNo " + + "ORDER BY screening_id DESC LIMIT 1" + ) + suspend fun getScreeningByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): MentalHealthScreeningCache? + + @Query("SELECT * FROM MENTAL_HEALTH_SCREENING WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM MENTAL_HEALTH_SCREENING " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/NeonatalOutcomeDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/NeonatalOutcomeDao.kt new file mode 100644 index 000000000..45bb38b9b --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/NeonatalOutcomeDao.kt @@ -0,0 +1,87 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.model.NeonatalOutcomeCache + +@Dao +interface NeonatalOutcomeDao { + + /** + * Insert a new neonatal outcome record + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertNeonatalOutcome(neonatalOutcome: NeonatalOutcomeCache): Long + + /** + * Insert multiple neonatal outcome records (for twins/triplets) + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertNeonatalOutcomes(neonatalOutcomes: List): List + + /** + * Update an existing neonatal outcome record + */ + @Update + suspend fun updateNeonatalOutcome(neonatalOutcome: NeonatalOutcomeCache): Int + + /** + * Get all neonatal outcomes for a specific delivery + * Ordered by neonateIndex (Baby 1, Baby 2, etc.) + */ + @Query("SELECT * FROM NEONATAL_OUTCOME WHERE deliveryOutcomeId = :deliveryOutcomeId ORDER BY neonateIndex ASC") + suspend fun getNeonatalOutcomesByDeliveryId(deliveryOutcomeId: Long): List + + /** + * Get all neonatal outcomes for a specific delivery as Flow (for reactive UI) + */ + @Query("SELECT * FROM NEONATAL_OUTCOME WHERE deliveryOutcomeId = :deliveryOutcomeId ORDER BY neonateIndex ASC") + fun getNeonatalOutcomesByDeliveryIdFlow(deliveryOutcomeId: Long): Flow> + + /** + * Get a specific neonatal outcome by ID + */ + @Query("SELECT * FROM NEONATAL_OUTCOME WHERE id = :neonatalOutcomeId") + suspend fun getNeonatalOutcomeById(neonatalOutcomeId: Long): NeonatalOutcomeCache? + + /** + * Get a specific neonate by delivery ID and neonate index + */ + @Query("SELECT * FROM NEONATAL_OUTCOME WHERE deliveryOutcomeId = :deliveryOutcomeId AND neonateIndex = :neonateIndex") + suspend fun getNeonatalOutcomeByIndexAndDelivery(deliveryOutcomeId: Long, neonateIndex: Int): NeonatalOutcomeCache? + + /** + * Get all neonatal outcomes that need to be synced with server + */ + @Query("SELECT * FROM NEONATAL_OUTCOME WHERE syncState = :syncState") + suspend fun getAllPendingSync(syncState: SyncState = SyncState.UNSYNCED): List + + /** + * Get count of neonates for a delivery + */ + @Query("SELECT COUNT(*) FROM NEONATAL_OUTCOME WHERE deliveryOutcomeId = :deliveryOutcomeId") + suspend fun getCountByDeliveryId(deliveryOutcomeId: Long): Int + + /** + * Update sync state for a neonatal outcome + */ + @Query("UPDATE NEONATAL_OUTCOME SET syncState = :syncState WHERE id = :id") + suspend fun updateSyncState(id: Long, syncState: SyncState): Int + + /** + * Delete neonatal outcomes for a specific delivery (cascade should handle this, but adding for explicit control) + */ + @Query("DELETE FROM NEONATAL_OUTCOME WHERE deliveryOutcomeId = :deliveryOutcomeId") + suspend fun deleteByDeliveryId(deliveryOutcomeId: Long): Int + + /** + * Check if neonatal outcomes exist for a delivery + */ + @Query("SELECT COUNT(*) > 0 FROM NEONATAL_OUTCOME WHERE deliveryOutcomeId = :deliveryOutcomeId") + suspend fun hasNeonatalOutcomes(deliveryOutcomeId: Long): Boolean +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/NoseDiagnosisAssessmentDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/NoseDiagnosisAssessmentDao.kt new file mode 100644 index 000000000..0d4f1544f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/NoseDiagnosisAssessmentDao.kt @@ -0,0 +1,54 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.NoseDiagnosisAssessment + +@Dao +interface NoseDiagnosisAssessmentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(assessment: NoseDiagnosisAssessment): Long + + @Update + suspend fun update(assessment: NoseDiagnosisAssessment) + + @Query( + "SELECT * FROM NOSE_DIAGNOSIS_ASSESSMENT " + + "WHERE assessment_id = :id" + ) + suspend fun getAssessmentById(id: Long): NoseDiagnosisAssessment? + + @Query( + "SELECT * FROM NOSE_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientId(patientID: String): NoseDiagnosisAssessment? + + @Query( + "SELECT * FROM NOSE_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): NoseDiagnosisAssessment? + + + @Query("SELECT * FROM NOSE_DIAGNOSIS_ASSESSMENT WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM NOSE_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/OphthalmicDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/OphthalmicDao.kt new file mode 100644 index 000000000..0b258e299 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/OphthalmicDao.kt @@ -0,0 +1,36 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.OphthalmicVisit + +@Dao +interface OphthalmicDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOphthalmicVisit(visit: OphthalmicVisit) + + @Update + suspend fun updateOphthalmicVisit(visit: OphthalmicVisit) + + @Query("SELECT * FROM OPHTHALMIC_VISIT WHERE patientID = :patientID AND benVisitNo = :benVisitNo") + suspend fun getOphthalmicVisit(patientID: String, benVisitNo: Int): OphthalmicVisit? + + @Query("SELECT * FROM OPHTHALMIC_VISIT WHERE visitId = :visitId") + suspend fun getOphthalmicVisitById(visitId: String): OphthalmicVisit? + + @Query("SELECT * FROM OPHTHALMIC_VISIT WHERE syncState = :unsyncedState") + suspend fun getUnsyncedOphthalmicVisits( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM OPHTHALMIC_VISIT " + + "WHERE patientID = :patientID AND benVisitNo = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/OralHealthDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/OralHealthDao.kt new file mode 100644 index 000000000..4e66c7016 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/OralHealthDao.kt @@ -0,0 +1,52 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.OralHealth + +@Dao +interface OralHealthDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(oralHealth: OralHealth) + + @Update + suspend fun update(oralHealth: OralHealth) + + @Query("SELECT * FROM ORAL_HEALTH WHERE oral_health_id = :id") + suspend fun getById(id: Long): OralHealth? + + @Query( + "SELECT * FROM ORAL_HEALTH " + + "WHERE patient_id = :patientID " + + "ORDER BY oral_health_id DESC LIMIT 1" + ) + suspend fun getByPatientId(patientID: String): OralHealth? + + @Query( + "SELECT * FROM ORAL_HEALTH " + + "WHERE patient_id = :patientID " + + "AND ben_visit_no = :benVisitNo " + + "ORDER BY oral_health_id DESC LIMIT 1" + ) + suspend fun getByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): OralHealth? + + @Query("SELECT * FROM ORAL_HEALTH WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM ORAL_HEALTH " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} + diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PainAndSymptomAssessmentDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PainAndSymptomAssessmentDao.kt new file mode 100644 index 000000000..8b1d91a6a --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PainAndSymptomAssessmentDao.kt @@ -0,0 +1,56 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.PainAndSymptomAssessment + +@Dao +interface PainAndSymptomAssessmentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(assessment: PainAndSymptomAssessment) + + @Update + suspend fun update(assessment: PainAndSymptomAssessment) + + @Query( + "SELECT * FROM PAIN_SYMPTOM_ASSESSMENT " + + "WHERE assessment_id = :id" + ) + suspend fun getAssessmentById(id: Long): PainAndSymptomAssessment? + + @Query( + "SELECT * FROM PAIN_SYMPTOM_ASSESSMENT " + + "WHERE patient_id = :patientID " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientId( + patientID: String + ): PainAndSymptomAssessment? + + @Query( + "SELECT * FROM PAIN_SYMPTOM_ASSESSMENT " + + "WHERE patient_id = :patientID " + + "AND ben_visit_no = :benVisitNo " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): PainAndSymptomAssessment? + + @Query("SELECT * FROM PAIN_SYMPTOM_ASSESSMENT WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM PAIN_SYMPTOM_ASSESSMENT " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientDao.kt index 61e97a9a1..c0599e7f4 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientDao.kt @@ -8,11 +8,13 @@ import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.SyncStateValue import org.piramalswasthya.cho.model.DistrictMaster import org.piramalswasthya.cho.model.GenderMaster import org.piramalswasthya.cho.model.Patient import org.piramalswasthya.cho.model.PatientDisplay import org.piramalswasthya.cho.model.PatientDisplayWithVisitInfo +import org.piramalswasthya.cho.model.SyncStatusCache import java.util.Date @Dao @@ -20,6 +22,10 @@ interface PatientDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertPatient(patient: Patient) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllPatients(patients: List) + @Query("SELECT EXISTS(SELECT 1 FROM patient WHERE beneficiaryRegID = :benRegId)") suspend fun existsByBeneficiaryRegId(benRegId: Long?): Boolean @@ -84,7 +90,8 @@ interface PatientDao { "LEFT JOIN VILLAGE_MASTER vilN ON pat.districtBranchID = vilN.districtBranchID "+ "LEFT JOIN AGE_UNIT age ON age.id = pat.ageUnitID " + "LEFT JOIN MARITAL_STATUS_MASTER mat on mat.maritalStatusID = pat.maritalStatusID " + - "WHERE latestVisit.patientID IS NULL") + "WHERE latestVisit.patientID IS NULL " + + "ORDER BY pat.registrationDate DESC, vis.benVisitNo DESC") fun getPatientDisplayListForNurse(): Flow> @@ -98,7 +105,9 @@ interface PatientDao { "LEFT JOIN VILLAGE_MASTER vilN ON pat.districtBranchID = vilN.districtBranchID "+ "LEFT JOIN AGE_UNIT age ON age.id = pat.ageUnitID " + "LEFT JOIN MARITAL_STATUS_MASTER mat on mat.maritalStatusID = pat.maritalStatusID " + - "WHERE vis.nurseFlag = 9 AND latestVisit.patientID IS NULL") + "WHERE vis.nurseFlag = 9 AND latestVisit.patientID IS NULL " + + "AND NOT (vis.doctorFlag = 9 AND IFNULL(vis.pharmacist_flag, 0) IN (0, 9)) " + + "ORDER BY pat.registrationDate DESC, vis.benVisitNo DESC") fun getPatientDisplayListForDoctor(): Flow> @Query("SELECT pat.*, gen.gender_name as genderName, vilN.village_name as villageName,age.age_name as ageUnit, mat.status as maritalStatus, " + @@ -161,6 +170,9 @@ interface PatientDao { @Query("SELECT * FROM PATIENT WHERE beneficiaryId =:benId LIMIT 1") suspend fun getBen(benId: Long): Patient? + @Query("SELECT * FROM PATIENT WHERE beneficiaryID = :id OR beneficiaryRegID = :id LIMIT 1") + suspend fun getPatientByAnyBeneficiaryId(id: Long): Patient? + // @Transaction // @Query("UPDATE PATIENT SET nurseFlag = 9, doctorFlag = 1 WHERE beneficiaryRegID = :beneficiaryRegID") // suspend fun updateNurseCompleted(beneficiaryRegID: Long) @@ -174,4 +186,99 @@ interface PatientDao { @Query("SELECT COUNT(*) FROM Patient WHERE registrationDate = :registrationDate") suspend fun countPatientsByRegistrationDate(registrationDate: Date): Int -} \ No newline at end of file + + /** + * Get all infants (0–365 days). + * Calculates age in days from DOB (stored as milliseconds since epoch). + * WHO RMNCHA+ standard: infant = 0–365 days inclusive. + */ + @Transaction + @Query(""" + SELECT * FROM PATIENT + WHERE dob IS NOT NULL + AND CAST(((strftime('%s','now') * 1000 - dob) / 86400000) AS INTEGER) BETWEEN 0 AND 365 + ORDER BY dob DESC + """) + fun getAllInfantList(): Flow> + + /** + * Get count of infants (0–365 days). + * WHO RMNCHA+ standard: infant = 0–365 days inclusive. + */ + @Query(""" + SELECT COUNT(*) FROM PATIENT + WHERE dob IS NOT NULL + AND CAST(((strftime('%s','now') * 1000 - dob) / 86400000) AS INTEGER) BETWEEN 0 AND 365 + """) + fun getInfantListCount(): Flow + + /** + * Get all children (365–3285 days, i.e. 1–9 years). + * Calculates age in days from DOB (stored as milliseconds since epoch). + * WHO RMNCHA+ standard: child = 365–3285 days inclusive (1–9 years). + */ + @Transaction + @Query(""" + SELECT * FROM PATIENT + WHERE dob IS NOT NULL + AND CAST(((strftime('%s','now') * 1000 - dob) / 86400000) AS INTEGER) BETWEEN 365 AND 3285 + ORDER BY dob DESC + """) + fun getAllChildList(): Flow> + + /** + * Get count of children (365–3285 days, i.e. 1–9 years). + * WHO RMNCHA+ standard: child = 365–3285 days inclusive (1–9 years). + */ + @Query(""" + SELECT COUNT(*) FROM PATIENT + WHERE dob IS NOT NULL + AND CAST(((strftime('%s','now') * 1000 - dob) / 86400000) AS INTEGER) BETWEEN 365 AND 3285 + """) + fun getChildListCount(): Flow + + /** + * Get all adolescents (age between 10-19 years, i.e., 3650-6935 days) + * Calculates age in days from DOB (stored as milliseconds since epoch) + * Aligned with RMNCHA+ guidelines (10-19 years) + */ + @Transaction + @Query(""" + SELECT * FROM PATIENT + WHERE dob IS NOT NULL + AND CAST(((strftime('%s','now') * 1000 - dob) / 86400000) AS INTEGER) BETWEEN 3650 AND 6935 + ORDER BY dob DESC + """) + fun getAllAdolescentList(): Flow> + + /** + * Get count of adolescents (age between 10-19 years) + * Aligned with RMNCHA+ guidelines (10-19 years) + */ + @Query(""" + SELECT COUNT(*) FROM PATIENT + WHERE dob IS NOT NULL + AND CAST(((strftime('%s','now') * 1000 - dob) / 86400000) AS INTEGER) BETWEEN 3650 AND 6935 + """) + fun getAdolescentListCount(): Flow + + @Query( + """ + SELECT + 14 AS id, + 'Adolescent List' AS name, + COUNT(CASE WHEN syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN syncState = :syncingState THEN 1 END) AS syncing + FROM PATIENT + WHERE dob IS NOT NULL + AND CAST(((strftime('%s','now') * 1000 - dob) / 86400000) AS INTEGER) BETWEEN 3650 AND 6935 + """ + ) + fun getAdolescentSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> + +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientVisitInfoSyncDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientVisitInfoSyncDao.kt index 52c5fea5b..d5309cd65 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientVisitInfoSyncDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PatientVisitInfoSyncDao.kt @@ -6,11 +6,12 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import kotlinx.coroutines.flow.Flow -import org.piramalswasthya.cho.model.Patient -import org.piramalswasthya.cho.model.PatientVisitInfoSync import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.Patient import org.piramalswasthya.cho.model.PatientDisplay import org.piramalswasthya.cho.model.PatientDisplayWithVisitInfo +import org.piramalswasthya.cho.model.PatientVisitInfoSync import org.piramalswasthya.cho.model.PatientVisitInfoSyncWithPatient import org.piramalswasthya.cho.model.SyncStatusCache import java.util.Date @@ -57,8 +58,25 @@ interface PatientVisitInfoSyncDao { suspend fun getPatientVisitInfoSyncByPatientIdAndBenVisitNo(patientID: String, benVisitNo: Int): PatientVisitInfoSync? @Transaction - @Query("UPDATE PATIENT_VISIT_INFO_SYNC SET benFlowId = :benFlowId, pharmacist_flag= :pharmacistFlag, visitCategory = :visitCategory WHERE patientID = :patientID AND benVisitNo = :benVisitNo") - suspend fun updateBenFlowIdByPatientIdAndBenVisitNo(benFlowId: Long, pharmacistFlag:Int, patientID: String, benVisitNo: Int, visitCategory: String) + @Query( + "UPDATE PATIENT_VISIT_INFO_SYNC " + + "SET benFlowId = :benFlowId, " + + "pharmacist_flag = CASE " + + "WHEN pharmacist_flag = 9 AND :pharmacistFlag <> 9 THEN pharmacist_flag " + + "WHEN pharmacistDataSynced IN (:unSynced, :syncing) THEN pharmacist_flag " + + "ELSE :pharmacistFlag END, " + + "visitCategory = :visitCategory " + + "WHERE patientID = :patientID AND benVisitNo = :benVisitNo" + ) + suspend fun updateBenFlowIdByPatientIdAndBenVisitNo( + benFlowId: Long, + pharmacistFlag: Int, + patientID: String, + benVisitNo: Int, + visitCategory: String, + unSynced: SyncState? = SyncState.UNSYNCED, + syncing: SyncState? = SyncState.SYNCING + ) @Query("SELECT * FROM PATIENT_VISIT_INFO_SYNC WHERE createNewBenFlow = :createNewBenFlow AND benVisitNo > 1 ORDER BY benVisitNo ASC") suspend fun getUnsyncedRevisitRecords(createNewBenFlow: Boolean? = true): List @@ -76,8 +94,17 @@ interface PatientVisitInfoSyncDao { suspend fun getPatientDoctorDataUnsyncedForOfflineTransfer(unSynced: SyncState? = SyncState.UNSYNCED) : List - @Query("SELECT * FROM PATIENT_VISIT_INFO_SYNC WHERE doctorDataSynced = :unSynced AND nurseDataSynced = :synced AND labtechFlag = 1 ORDER BY benVisitNo ASC") - suspend fun getPatientDoctorDataAfterTestUnsynced(unSynced: SyncState? = SyncState.UNSYNCED, synced: SyncState? = SyncState.SYNCED, ) : List + @Query( + "SELECT * FROM PATIENT_VISIT_INFO_SYNC " + + "WHERE doctorDataSynced = :unSynced " + + "AND nurseDataSynced = :synced " + + "AND labtechFlag IN (1, 9) " + + "ORDER BY benVisitNo ASC" + ) + suspend fun getPatientDoctorDataAfterTestUnsynced( + unSynced: SyncState? = SyncState.UNSYNCED, + synced: SyncState? = SyncState.SYNCED, + ) : List // @Transaction // @Query("UPDATE PATIENT_VISIT_INFO_SYNC SET beneficiaryID = :beneficiaryID, beneficiaryRegID = :beneficiaryRegID WHERE patientID = :patientID") @@ -137,6 +164,32 @@ interface PatientVisitInfoSyncDao { @Query("UPDATE PATIENT_VISIT_INFO_SYNC SET pharmacist_flag = 9 WHERE patientID = :patientID AND benVisitNo = :benVisitNo") suspend fun updatePharmacistFlag(patientID: String, benVisitNo: Int) + @Transaction + @Query( + "UPDATE PATIENT_VISIT_INFO_SYNC " + + "SET pharmacist_flag = 9, pharmacistDataSynced = :unSynced " + + "WHERE patientID = :patientID AND benVisitNo = :benVisitNo" + ) + suspend fun markPharmacistDispensedLocally( + patientID: String, + benVisitNo: Int, + unSynced: SyncState? = SyncState.UNSYNCED + ) + + @Transaction + @Query( + "UPDATE PATIENT_VISIT_INFO_SYNC " + + "SET pharmacist_flag = 1, " + + "pharmacistDataSynced = :unSynced, " + + "visitCategory = 'General OPD' " + + "WHERE patientID = :patientID AND benVisitNo = :benVisitNo" + ) + suspend fun updatePharmacistFlagToPending( + patientID: String, + benVisitNo: Int, + unSynced: SyncState? = SyncState.UNSYNCED + ) + @Transaction @Query("UPDATE PATIENT_VISIT_INFO_SYNC SET doctorDataSynced = :syncing WHERE patientID = :patientID AND benVisitNo = :benVisitNo") suspend fun updatePatientDoctorDataSyncSyncing(syncing: SyncState? = SyncState.SYNCING, patientID: String, benVisitNo: Int) @@ -177,7 +230,10 @@ interface PatientVisitInfoSyncDao { "LEFT JOIN VILLAGE_MASTER vilN ON pat.districtBranchID = vilN.districtBranchID "+ "LEFT JOIN AGE_UNIT age ON age.id = pat.ageUnitID " + "LEFT JOIN MARITAL_STATUS_MASTER mat on mat.maritalStatusID = pat.maritalStatusID " + - "WHERE vis.nurseFlag = 9 AND vis.visitCategory = 'General OPD' ORDER BY pat.registrationDate DESC") + "WHERE vis.nurseFlag = 9 " + + "AND vis.visitCategory = 'General OPD' " + + "AND NOT (vis.doctorFlag = 9 AND IFNULL(vis.pharmacist_flag, 0) IN (0, 9)) " + + "ORDER BY pat.registrationDate DESC") fun getPatientDisplayListForDoctor(): Flow> @Transaction @@ -199,40 +255,46 @@ interface PatientVisitInfoSyncDao { "LEFT JOIN VILLAGE_MASTER vilN ON pat.districtBranchID = vilN.districtBranchID "+ "LEFT JOIN AGE_UNIT age ON age.id = pat.ageUnitID " + "LEFT JOIN MARITAL_STATUS_MASTER mat on mat.maritalStatusID = pat.maritalStatusID " + - "WHERE vis.doctorFlag = 9 AND vis.visitCategory = 'General OPD' AND vis.pharmacist_flag = 1") + "WHERE vis.visitCategory = 'General OPD' AND vis.pharmacist_flag = 1 AND (vis.doctorFlag = 9 OR vis.doctorFlag = 2 OR vis.doctorFlag = 3) " + + "ORDER BY IFNULL((SELECT MAX(p.id) FROM Prescription p WHERE p.patientID = vis.patientID AND p.benVisitNo = vis.benVisitNo), 0) DESC, " + + "IFNULL(vis.benVisitNo, 0) DESC") fun getPatientDisplayListForPharmacist(): Flow> @Transaction @Query("SELECT " + "1 as id,'Patient' as name," + - "COUNT(CASE WHEN syncState = 2 THEN 1 END) AS synced," + - "COUNT(CASE WHEN syncState = 1 THEN 1 END) AS syncing," + - "COUNT(CASE WHEN syncState = 0 THEN 1 END) AS notSynced" + + "COUNT(CASE WHEN syncState = :syncedState THEN 1 END) AS synced," + + "COUNT(CASE WHEN syncState = :syncingState THEN 1 END) AS syncing," + + "COUNT(CASE WHEN syncState = :unsyncedState THEN 1 END) AS notSynced" + " FROM PATIENT UNION SELECT " + "2 as id,'Nurse' as name," + - "COUNT(CASE WHEN nurseDataSynced = 2 THEN 1 END) AS synced," + - "COUNT(CASE WHEN nurseDataSynced = 1 THEN 1 END) AS syncing," + - "COUNT(CASE WHEN nurseDataSynced = 0 THEN 1 END) AS notSynced" + + "COUNT(CASE WHEN nurseDataSynced = :syncedState THEN 1 END) AS synced," + + "COUNT(CASE WHEN nurseDataSynced = :syncingState THEN 1 END) AS syncing," + + "COUNT(CASE WHEN nurseDataSynced = :unsyncedState THEN 1 END) AS notSynced" + " FROM PATIENT_VISIT_INFO_SYNC WHERE nurseFlag = 9 UNION SELECT " + "3 as id,'Doctor' as name," + - "COUNT(CASE WHEN doctorDataSynced = 2 THEN 1 END) AS synced," + - "COUNT(CASE WHEN doctorDataSynced = 1 THEN 1 END) AS syncing," + - "COUNT(CASE WHEN doctorDataSynced = 0 THEN 1 END) AS notSynced" + + "COUNT(CASE WHEN doctorDataSynced = :syncedState THEN 1 END) AS synced," + + "COUNT(CASE WHEN doctorDataSynced = :syncingState THEN 1 END) AS syncing," + + "COUNT(CASE WHEN doctorDataSynced = :unsyncedState THEN 1 END) AS notSynced" + " FROM PATIENT_VISIT_INFO_SYNC WHERE doctorFlag > 1 UNION SELECT " + "4 as id,'Lab Technician' as name," + - "COUNT(CASE WHEN labDataSynced = 2 THEN 1 END) AS synced," + - "COUNT(CASE WHEN labDataSynced = 1 THEN 1 END) AS syncing," + - "COUNT(CASE WHEN labDataSynced = 0 THEN 1 END) AS notSynced" + + "COUNT(CASE WHEN labDataSynced = :syncedState THEN 1 END) AS synced," + + "COUNT(CASE WHEN labDataSynced = :syncingState THEN 1 END) AS syncing," + + "COUNT(CASE WHEN labDataSynced = :unsyncedState THEN 1 END) AS notSynced" + " FROM PATIENT_VISIT_INFO_SYNC WHERE doctorFlag = 3 UNION SELECT " + "5 as id,'Pharmacist' as name," + - "COUNT(CASE WHEN pharmacistDataSynced = 2 THEN 1 END) AS synced," + - "COUNT(CASE WHEN pharmacistDataSynced = 1 THEN 1 END) AS syncing," + - "COUNT(CASE WHEN pharmacistDataSynced = 0 THEN 1 END) AS notSynced" + + "COUNT(CASE WHEN pharmacistDataSynced = :syncedState THEN 1 END) AS synced," + + "COUNT(CASE WHEN pharmacistDataSynced = :syncingState THEN 1 END) AS syncing," + + "COUNT(CASE WHEN pharmacistDataSynced = :unsyncedState THEN 1 END) AS notSynced" + " FROM PATIENT_VISIT_INFO_SYNC WHERE doctorFlag = 9 AND pharmacist_flag = 9 ORDER BY id") - fun getSyncStatus(): Flow> + fun getSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> @Transaction @Query("UPDATE PATIENT_VISIT_INFO_SYNC SET referDate = :referDate, referTo = :referTo, referralReason = :referralReason WHERE patientID = :patientID AND benVisitNo = :benVisitNo") suspend fun updatePatientReferData(referDate: String?, referTo: String?, referralReason: String?, patientID: String, benVisitNo: Int) : Int -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PncDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PncDao.kt index b7fab1369..efb9c64f5 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PncDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PncDao.kt @@ -5,8 +5,14 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.database.room.SyncModuleIds +import org.piramalswasthya.cho.database.room.SyncStateValue import org.piramalswasthya.cho.model.PNCVisitCache +import org.piramalswasthya.cho.model.PatientWithDeliveryOutcomeAndPncCache +import org.piramalswasthya.cho.model.SyncStatusCache @Dao interface PncDao { @@ -21,7 +27,7 @@ interface PncDao { suspend fun getLastSavedRecord(patientID: String): PNCVisitCache? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(pncCache: PNCVisitCache) + suspend fun insert(pncCache: PNCVisitCache): Long @Query("SELECT * FROM pnc_visit WHERE processed in ('N', 'U')") suspend fun getAllUnprocessedPncVisits(): List @@ -35,5 +41,90 @@ interface PncDao { @Query("select * from pnc_visit where patientID = :patientID and isActive = 1") suspend fun getAllPNCsByPatId(patientID: String): List + @Query( + """ + SELECT + ${SyncModuleIds.PNC} AS id, + 'PNC' AS name, + COUNT(CASE WHEN pnc.syncState = :syncedState THEN 1 END) AS synced, + COUNT(CASE WHEN pnc.syncState = :unsyncedState THEN 1 END) AS notSynced, + COUNT(CASE WHEN pnc.syncState = :syncingState THEN 1 END) AS syncing + FROM PNC_VISIT pnc + WHERE pnc.isActive = 1 + """ + ) + fun getPncSyncStatus( + syncedState: Int = SyncStateValue.SYNCED, + syncingState: Int = SyncStateValue.SYNCING, + unsyncedState: Int = SyncStateValue.UNSYNCED + ): Flow> -} \ No newline at end of file + /** + * Get patientIDs of women eligible for PNC mothers list. + * Source: Patient status postnatal (same pattern as EC/PWR lists), excluding completed 42-day PNC. + */ + @Query(""" + SELECT p.patientID + FROM PATIENT p + WHERE p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + AND p.maritalStatusID = 2 + AND p.statusOfWomanID = 3 + AND NOT EXISTS ( + SELECT 1 FROM PNC_VISIT p42 + WHERE p42.patientID = p.patientID + AND p42.isActive = 1 + AND p42.pncPeriod = 42 + ) + """) + fun getPNCMothersPatientIDs(): Flow> + + /** + * Get count of women eligible for PNC mothers list. + */ + @Query(""" + SELECT COUNT(*) + FROM PATIENT p + WHERE p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + AND p.maritalStatusID = 2 + AND p.statusOfWomanID = 3 + AND NOT EXISTS ( + SELECT 1 FROM PNC_VISIT p42 + WHERE p42.patientID = p.patientID + AND p42.isActive = 1 + AND p42.pncPeriod = 42 + ) + """) + fun getPNCMothersCount(): Flow + + /** + * Get patient with delivery outcome and PNC by patientID + */ + @Transaction + @Query("SELECT * FROM PATIENT WHERE patientID = :patientID") + suspend fun getPatientWithDeliveryOutcomeAndPncByID(patientID: String): PatientWithDeliveryOutcomeAndPncCache? + + /** + * Get all PNC mothers with their delivery outcome and PNC data. + * Source of truth: Patient status postnatal (delivery outcome joined via Room relation). + */ + @Transaction + @Query(""" + SELECT p.* + FROM PATIENT p + WHERE p.genderID = 2 + AND p.age BETWEEN 15 AND 49 + AND p.maritalStatusID = 2 + AND p.statusOfWomanID = 3 + AND NOT EXISTS ( + SELECT 1 FROM PNC_VISIT p42 + WHERE p42.patientID = p.patientID + AND p42.isActive = 1 + AND p42.pncPeriod = 42 + ) + ORDER BY p.registrationDate DESC + """) + fun getAllPNCMothersWithData(): Flow> + +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PrescriptionDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PrescriptionDao.kt index 3467530a6..0abd7699b 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PrescriptionDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PrescriptionDao.kt @@ -35,6 +35,10 @@ interface PrescriptionDao { @Query("delete from prescription where patientID = :patientID AND benVisitNo = :benVisitNo") suspend fun deletePrescriptionByPatientIDAndBenVisitNo(patientID: String, benVisitNo: Int): Int + @Transaction + @Query("delete from prescription where id = :id") + suspend fun deletePrescriptionById(id: Long): Int + // @Transaction // @Query("delete from prescription where prescriptionID = :prescriptionID and beneficiaryRegID = :beneficiaryRegID") // suspend fun deletePrescriptionByIDAndBenRegID(prescriptionID: Long, beneficiaryRegID: Long): Int @@ -43,21 +47,36 @@ interface PrescriptionDao { @Query("delete from Prescription_Cases_Recorde where patientID =:patientID") suspend fun deletePrescriptionByPatientId(patientID: String): Int - @Query("select * from prescription where patientID = :patientID AND benVisitNo = :benVisitNo") + @Query("select * from prescription where patientID = :patientID AND benVisitNo = :benVisitNo order by id desc") suspend fun getPrescriptionsByPatientIdAndBenVisitNo(patientID: String, benVisitNo: Int): List? + @Transaction + @Query( + "delete from prescription " + + "where id = (" + + "select id from prescription where patientID = :patientID and benVisitNo = :benVisitNo order by id desc limit 1" + + ")" + ) + suspend fun deleteLatestPrescriptionByPatientIDAndBenVisitNo(patientID: String, benVisitNo: Int): Int + @Update suspend fun updatePharmacistPrescription(prescription: Prescription) @Query("select * from prescription where patientID = :patientID and benVisitNo = :benVisitNo and prescriptionID = :prescriptionID limit 1") fun getPrescription(patientID: String, benVisitNo: Int, prescriptionID: Long): Prescription + @Query("select * from prescription where patientID = :patientID and benVisitNo = :benVisitNo order by id desc limit 1") + suspend fun getLatestPrescriptionByPatientIdAndBenVisitNo(patientID: String, benVisitNo: Int): Prescription? + @Query("select * from prescribed_drugs where prescriptionID = :prescriptionID") suspend fun getPrescribedDrugs(prescriptionID: Long): List? @Query("select * from prescribed_drugs_batch where drugID = :drugID") fun getPrescribedDrugsBatch(drugID: Long): List? + @Query("delete from prescribed_drugs_batch where drugID = :drugID") + suspend fun deletePrescribedDrugsBatchByDrugId(drugID: Long): Int + @Delete fun deletePrescribedDrugsBatch(prescribedDrugsBatch: PrescribedDrugsBatch) @@ -68,10 +87,27 @@ interface PrescriptionDao { @Transaction @Query("UPDATE prescription SET issueType = :issueType WHERE prescriptionID =:prescriptionID") suspend fun updatePrescription(issueType: String?, prescriptionID: Long) : Int + + @Query( + "UPDATE prescription SET " + + "prescriptionID = :prescriptionID, " + + "visitCode = :visitCode, " + + "consultantName = :consultantName " + + "WHERE id = (" + + "select id from prescription where patientID = :patientID and benVisitNo = :benVisitNo order by id desc limit 1" + + ")" + ) + suspend fun updatePrescriptionHeader( + patientID: String, + benVisitNo: Int, + prescriptionID: Long, + visitCode: Long, + consultantName: String? + ): Int // // @Query("select * from component_details where procedure_id = :procedureId and test_component_id = :testComponentID") // fun getComponentDetails(procedureId: Long, testComponentID: Long): ComponentDetails @Query("delete from Prescription_Cases_Recorde where patientID =:patientID and benVisitNo = :benVisitNo") suspend fun deletePrescriptionByPatientIdAndBenVisitNo(patientID: String, benVisitNo: Int): Int -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureDao.kt index f57f8a8a7..54f06b705 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureDao.kt @@ -37,7 +37,7 @@ interface ProcedureDao { @Query("select * from component_option where component_details_id = :componentId") fun getComponentOptions(componentId: Long): List? - @Query("update component_details set test_result_value = :testResultValue and remarks = :remarks where id = :id") + @Query("update component_details set test_result_value = :testResultValue, remarks = :remarks where id = :id") fun addComponentResult(id: Long, testResultValue: String?, remarks: String?) @Query("select * from procedure where patientID = :patientID and benVisitNo = :benVisitNo and procedure_id = :procedureID limit 1") @@ -55,6 +55,7 @@ interface ProcedureDao { @Query("delete from PROCEDURE_DATA_DOWNSYNC where patientID = :patientID AND benVisitNo = :benVisitNo") suspend fun deleteProcedureDownsyncByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) + @Transaction @Query("select * from PROCEDURE_DATA_DOWNSYNC where patientID = :patientID AND benVisitNo = :benVisitNo") suspend fun getProceduresWithComponent(patientID: String, benVisitNo: Int): List diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureMasterDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureMasterDao.kt index 2e924b09f..79f5ae6a0 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureMasterDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ProcedureMasterDao.kt @@ -22,6 +22,12 @@ public interface ProcedureMasterDao { @Query("select * from procedure_master where procedure_id = :procedureID limit 1") suspend fun getMasterProcedureById(procedureID: Long): ProcedureMaster? + @Query("delete from procedure_master where id = :rowId") + suspend fun deleteMasterProcedureByRowId(rowId: Long) + + @Query("select * from procedure_master order by procedure_id") + suspend fun getAllProcedures(): List + @Query("select * from component_details_master where procedure_id = :procedureID") suspend fun getComponentDetails(procedureID: Long): List diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PsychosocialCaregiverSupportDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PsychosocialCaregiverSupportDao.kt new file mode 100644 index 000000000..935944fd3 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/PsychosocialCaregiverSupportDao.kt @@ -0,0 +1,58 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.PsychosocialCaregiverSupport + +@Dao +interface PsychosocialCaregiverSupportDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(assessment: PsychosocialCaregiverSupport) + + @Update + suspend fun update(assessment: PsychosocialCaregiverSupport) + + @Query( + "SELECT * FROM PSYCHOSOCIAL_CAREGIVER_SUPPORT " + + "WHERE assessment_id = :id" + ) + suspend fun getAssessmentById( + id: Long + ): PsychosocialCaregiverSupport? + + @Query( + "SELECT * FROM PSYCHOSOCIAL_CAREGIVER_SUPPORT " + + "WHERE patient_id = :patientID " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientId( + patientID: String + ): PsychosocialCaregiverSupport? + + @Query( + "SELECT * FROM PSYCHOSOCIAL_CAREGIVER_SUPPORT " + + "WHERE patient_id = :patientID " + + "AND ben_visit_no = :benVisitNo " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): PsychosocialCaregiverSupport? + + @Query("SELECT * FROM PSYCHOSOCIAL_CAREGIVER_SUPPORT WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM PSYCHOSOCIAL_CAREGIVER_SUPPORT " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/RegistrarMasterDataDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/RegistrarMasterDataDao.kt index 8b83a8430..f4ac77612 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/RegistrarMasterDataDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/RegistrarMasterDataDao.kt @@ -4,7 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.piramalswasthya.cho.moddel.OccupationMaster +import org.piramalswasthya.cho.model.OccupationMaster import org.piramalswasthya.cho.model.AgeUnit import org.piramalswasthya.cho.model.CommunityMaster import org.piramalswasthya.cho.model.GenderMaster @@ -32,6 +32,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertGender(genderMaster: GenderMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllGenders(list: List) + //AGE UNIT @Query("SELECT * FROM AGE_UNIT") @@ -40,6 +43,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAgeUnit(ageUnit: AgeUnit) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllAgeUnits(list: List) + //INCOME_MASTER @Query("SELECT * FROM INCOME_MASTER") @@ -48,6 +54,8 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertIncomeStatus(incomeMaster: IncomeMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllIncomeStatuses(list: List) //LITERACY_STATUS @@ -57,6 +65,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertLiteracyStatus(literacyStatus: LiteracyStatus) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllLiteracyStatuses(list: List) + //COMMUNITY_MASTER @Query("SELECT * FROM COMMUNITY_MASTER") @@ -65,6 +76,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCommunity(communityMaster: CommunityMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllCommunities(list: List) + //MARITAL_STATUS @Query("SELECT * FROM MARITAL_STATUS_MASTER") suspend fun getMaritalStatus(): List @@ -72,6 +86,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMaritalStatus(maritalStatusMaster: MaritalStatusMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllMaritalStatuses(list: List) + //GOV_ID_ENTITY_MASTER @Query("SELECT * FROM GOV_ID_ENTITY_MASTER") suspend fun getGovIdMaster(): List @@ -79,6 +96,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertGovIdMaster(govIdEntityMaster: GovIdEntityMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllGovIdMasters(list: List) + //OTHER_GOV_ID_ENTITY_MASTER @Query("SELECT * FROM OTHER_GOV_ID_ENTITY_MASTER") @@ -87,6 +107,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOtherGovIdEntityMaster(maritalStatusMaster: OtherGovIdEntityMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllOtherGovIdEntityMasters(list: List) + //RELATIONSHIP_MASTER @Query("SELECT * FROM RELATIONSHIP_MASTER") suspend fun getRelationshipMaster(): List @@ -94,6 +117,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRelationshipMaster(relationshipMaster: RelationshipMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllRelationshipMasters(list: List) + //RELIGION_MASTER @Query("SELECT * FROM RELIGION_MASTER") suspend fun getReligionMaster(): List @@ -101,6 +127,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertReligionMaster(religionMaster: ReligionMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllReligionMasters(list: List) + //QUALIFICATION_MASTER @Query("SELECT * FROM QUALIFICATION_MASTER") suspend fun getQualificationMaster(): List @@ -108,6 +137,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertQualificationMaster(qualificationMaster: QualificationMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllQualificationMasters(list: List) + //OCCUPATION_MASTER @Query("SELECT * FROM OCCUPATION_MASTER") @@ -116,6 +148,9 @@ interface RegistrarMasterDataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOccupationMaster(occupationMaster: OccupationMaster) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllOccupationMasters(list: List) + diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/StatusOfWomanDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/StatusOfWomanDao.kt new file mode 100644 index 000000000..96b746146 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/StatusOfWomanDao.kt @@ -0,0 +1,26 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.piramalswasthya.cho.model.StatusOfWomanMaster + +@Dao +interface StatusOfWomanDao { + + @Query("SELECT * FROM STATUS_OF_WOMAN_MASTER") + suspend fun getAllStatusOfWoman(): List + + @Query("SELECT * FROM STATUS_OF_WOMAN_MASTER WHERE statusID = :statusId") + suspend fun getStatusById(statusId: Int): StatusOfWomanMaster? + + @Query("SELECT * FROM STATUS_OF_WOMAN_MASTER WHERE statusID IN (:statusIds)") + suspend fun getStatusByIds(statusIds: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(statuses: List) + + @Query("SELECT COUNT(*) FROM STATUS_OF_WOMAN_MASTER") + suspend fun getCount(): Int +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ThroatDiagnosisAssessmentDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ThroatDiagnosisAssessmentDao.kt new file mode 100644 index 000000000..fa2f0c2ce --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/ThroatDiagnosisAssessmentDao.kt @@ -0,0 +1,54 @@ +package org.piramalswasthya.cho.database.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import org.piramalswasthya.cho.database.room.SyncStateValue +import org.piramalswasthya.cho.model.ThroatDiagnosisAssessment + +@Dao +interface ThroatDiagnosisAssessmentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(assessment: ThroatDiagnosisAssessment) + + @Update + suspend fun update(assessment: ThroatDiagnosisAssessment) + + @Query( + "SELECT * FROM THROAT_DIAGNOSIS_ASSESSMENT " + + "WHERE assessment_id = :id" + ) + suspend fun getAssessmentById(id: Long): ThroatDiagnosisAssessment? + + @Query( + "SELECT * FROM THROAT_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientId(patientID: String): ThroatDiagnosisAssessment? + + @Query( + "SELECT * FROM THROAT_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID " + + "AND ben_visit_no = :benVisitNo " + + "ORDER BY assessment_id DESC LIMIT 1" + ) + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): ThroatDiagnosisAssessment? + + @Query("SELECT * FROM THROAT_DIAGNOSIS_ASSESSMENT WHERE syncState = :unsyncedState") + suspend fun getUnsyncedAssessments( + unsyncedState: Int = SyncStateValue.UNSYNCED + ): List + + @Query( + "DELETE FROM THROAT_DIAGNOSIS_ASSESSMENT " + + "WHERE patient_id = :patientID AND ben_visit_no = :benVisitNo" + ) + suspend fun deleteByPatientIdAndVisitNo(patientID: String, benVisitNo: Int) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/VisitReasonAndCategoriesDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/VisitReasonAndCategoriesDao.kt index 6fab6238a..0f4560af7 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/room/dao/VisitReasonAndCategoriesDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/room/dao/VisitReasonAndCategoriesDao.kt @@ -55,6 +55,12 @@ interface VisitReasonsAndCategoriesDao { @Query("SELECT * FROM Visit_DB WHERE patientID = :patientID AND benVisitNo = :benVisitNo") suspend fun getVisitDbByBenRegIdAndBenVisitNo(patientID: String, benVisitNo: Int) : VisitDB? + @Query( + "SELECT * FROM Visit_DB WHERE patientID = :patientID AND benVisitNo = :benVisitNo " + + "ORDER BY rowid DESC LIMIT 1" + ) + suspend fun getLatestVisitDbByPatientIDAndBenVisitNo(patientID: String, benVisitNo: Int): VisitDB? + @Query("SELECT benVisitDate FROM Visit_DB WHERE patientID = :patientId ORDER BY benVisitNo DESC LIMIT 1") fun getLatestVisitIdByPatientId(patientId: String): LiveData diff --git a/app/src/main/java/org/piramalswasthya/cho/database/shared_preferences/PreferenceDao.kt b/app/src/main/java/org/piramalswasthya/cho/database/shared_preferences/PreferenceDao.kt index 204973ebd..2054d4953 100644 --- a/app/src/main/java/org/piramalswasthya/cho/database/shared_preferences/PreferenceDao.kt +++ b/app/src/main/java/org/piramalswasthya/cho/database/shared_preferences/PreferenceDao.kt @@ -1,9 +1,12 @@ package org.piramalswasthya.cho.database.shared_preferences import android.content.Context +import android.content.SharedPreferences import android.os.Build import android.util.Log import androidx.annotation.RequiresApi +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import com.google.gson.Gson import dagger.hilt.android.qualifiers.ApplicationContext import org.piramalswasthya.cho.R @@ -25,6 +28,28 @@ class PreferenceDao @Inject constructor(@ApplicationContext private val context: private val pref = PreferenceManager.getInstance(context) + // EncryptedSharedPreferences for secure credential storage + private val encryptedPref: SharedPreferences by lazy { + try { + EncryptedSharedPreferences.create( + context, + "secret_shared_prefs", + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + Log.e("PreferenceDao", "Failed to create EncryptedSharedPreferences, falling back to standard preferences", e) + // Fallback to standard preferences or throw a more informative error + pref + } + } + + init { + // Run one-time migration of plain-text credentials to encrypted storage + migrateCredentialsIfNeeded() + } + @RequiresApi(Build.VERSION_CODES.O) val date = LocalDate.of(2023, 11, 1) @@ -34,11 +59,11 @@ class PreferenceDao @Inject constructor(@ApplicationContext private val context: fun getPrimaryApiToken(): String? { val prefKey = context.getString(R.string.PREF_primary_API_KEY) - return pref.getString(prefKey, null) + return encryptedPref.getString(prefKey, null) } -// + fun registerPrimaryApiToken(token: String) { - val editor = pref.edit() + val editor = encryptedPref.edit() val prefKey = context.getString(R.string.PREF_primary_API_KEY) editor.putString(prefKey, token) editor.apply() @@ -46,16 +71,23 @@ class PreferenceDao @Inject constructor(@ApplicationContext private val context: fun getJWTAmritToken(): String? { val prefKey = context.getString(R.string.PREF_primary_JWT_API_KEY) - return pref.getString(prefKey, null) + return encryptedPref.getString(prefKey, null) } fun registerJWTAmritToken(token: String) { - val editor = pref.edit() + val editor = encryptedPref.edit() val prefKey = context.getString(R.string.PREF_primary_JWT_API_KEY) editor.putString(prefKey, token) editor.apply() } + fun clearAmritTokens() { + val editor = encryptedPref.edit() + editor.remove(context.getString(R.string.PREF_primary_API_KEY)) + editor.remove(context.getString(R.string.PREF_primary_JWT_API_KEY)) + editor.apply() + } + fun getWorkingLocationID(): Int { val prefKey = context.getString(R.string.WORK_LOCATION_ID) return pref.getInt(prefKey, 21) @@ -187,6 +219,13 @@ class PreferenceDao @Inject constructor(@ApplicationContext private val context: return pref.getString(prefKey, null) ?: DateTimeUtil.formatCustDateAndTime(epochTimestamp) } + // Watermark that pulls the FULL benflow worklist (same value used for a first-ever sync). + // Used by the upsync/form-save benflow re-pull to land a JUST-created benflow that the + // incremental (hour-truncated) watermark would otherwise filter out. + fun getEpochBenflowSyncTime(): String { + return DateTimeUtil.formatCustDateAndTime(epochTimestamp) + } + @RequiresApi(Build.VERSION_CODES.O) fun setLastBenflowSyncTime(currTimeStamp: Long){ val prefKey = context.getString(R.string.last_benflow_sync_time) @@ -224,54 +263,144 @@ class PreferenceDao @Inject constructor(@ApplicationContext private val context: editor.apply() } + fun clearSyncTimestamps() { + val editor = pref.edit() + editor.remove(context.getString(R.string.last_benflow_sync_time)) + editor.remove(context.getString(R.string.last_cbac_sync_time)) + editor.remove(context.getString(R.string.last_patient_sync_time)) + editor.remove(context.getString(R.string.last_sync_time)) + editor.apply() + } + fun getLastSyncTime(): String { val prefKey = context.getString(R.string.last_sync_time) return pref.getString(prefKey, null) ?: DateTimeUtil.formatCustDateAndTime(epochTimestamp) } - fun registerLoginCred(userName: String,password: String) { - val editor = pref.edit() + /** + * One-time migration: Move plain-text credentials from regular prefs to encrypted prefs. + * Runs once on class initialization. + */ + private fun migrateCredentialsIfNeeded() { + try { + val prefUserKey = context.getString(R.string.PREF_rem_me_uname) + val prefPasswordKey = context.getString(R.string.password_local_saved) + + // Read plain-text values from regular prefs + val plainUserName = pref.getString(prefUserKey, null) + val plainPassword = pref.getString(prefPasswordKey, null) + + // Check if encrypted versions already exist + val encryptedUserName = encryptedPref.getString(prefUserKey, null) + + // Migrate if plain-text exists but encrypted doesn't + if (plainUserName != null && encryptedUserName == null) { + registerLoginCred(plainUserName, plainPassword ?: "") + } + + // Clean up plain-text credentials from regular prefs + if (plainUserName != null || plainPassword != null) { + val editor = pref.edit() + editor.remove(prefUserKey) + editor.remove(prefPasswordKey) + editor.apply() + } + + // Migrate Esanjeevani credentials as well + val prefEsUserKey = context.getString(R.string.esanjeevaniusername_local_saved) + val prefEsPasswordKey = context.getString(R.string.esanjeevanipassword_local_saved) + + val plainEsUserName = pref.getString(prefEsUserKey, null) + val plainEsPassword = pref.getString(prefEsPasswordKey, null) + val encryptedEsUserName = encryptedPref.getString(prefEsUserKey, null) + + if (plainEsUserName != null && encryptedEsUserName == null) { + registerEsanjeevaniCred(plainEsUserName, plainEsPassword ?: "") + } + + if (plainEsUserName != null || plainEsPassword != null) { + val editor = pref.edit() + editor.remove(prefEsUserKey) + editor.remove(prefEsPasswordKey) + editor.apply() + } + + // Migrate Amrit API + JWT tokens from plain prefs to encryptedPref. + val prefApiTokenKey = context.getString(R.string.PREF_primary_API_KEY) + val prefJwtTokenKey = context.getString(R.string.PREF_primary_JWT_API_KEY) + + val plainApiToken = pref.getString(prefApiTokenKey, null) + val plainJwtToken = pref.getString(prefJwtTokenKey, null) + val encryptedApiToken = encryptedPref.getString(prefApiTokenKey, null) + val encryptedJwtToken = encryptedPref.getString(prefJwtTokenKey, null) + + if (plainApiToken != null && encryptedApiToken == null) { + encryptedPref.edit().putString(prefApiTokenKey, plainApiToken).apply() + } + if (plainJwtToken != null && encryptedJwtToken == null) { + encryptedPref.edit().putString(prefJwtTokenKey, plainJwtToken).apply() + } + + if (plainApiToken != null || plainJwtToken != null) { + val editor = pref.edit() + editor.remove(prefApiTokenKey) + editor.remove(prefJwtTokenKey) + editor.apply() + } + } catch (e: Exception) { + Log.e("PreferenceDao", "Error during credential migration: ${e.message}") + } + } + + fun registerLoginCred(userName: String, password: String) { + val editor = encryptedPref.edit() val prefUserKey = context.getString(R.string.PREF_rem_me_uname) val prefPasswordKey = context.getString(R.string.password_local_saved) editor.putString(prefUserKey, userName) editor.putString(prefPasswordKey, password) editor.apply() } + fun getRememberedUserName(): String? { val key = context.getString(R.string.PREF_rem_me_uname) - return pref.getString(key, null) + return encryptedPref.getString(key, null) } + fun getRememberedPassword(): String? { val prefPasswordKey = context.getString(R.string.password_local_saved) - return pref.getString(prefPasswordKey, null) + return encryptedPref.getString(prefPasswordKey, null) + } + + fun registerEsanjeevaniCred(userName: String, password: String) { + val editor = encryptedPref.edit() + val prefUserKey = context.getString(R.string.esanjeevaniusername_local_saved) + val prefPasswordKey = context.getString(R.string.esanjeevanipassword_local_saved) + editor.putString(prefUserKey, userName) + editor.putString(prefPasswordKey, password) + editor.apply() } -fun registerEsanjeevaniCred(userName: String,password: String) { - val editor = pref.edit() - val prefUserKey = context.getString(R.string.esanjeevaniusername_local_saved) - val prefPasswordKey = context.getString(R.string.esanjeevanipassword_local_saved) - editor.putString(prefUserKey, userName) - editor.putString(prefPasswordKey, password) - editor.apply() -} fun getEsanjeevaniUserName(): String? { val key = context.getString(R.string.esanjeevaniusername_local_saved) - return pref.getString(key, null) + return encryptedPref.getString(key, null) } + fun getEsanjeevaniPassword(): String? { val prefPasswordKey = context.getString(R.string.esanjeevanipassword_local_saved) - return pref.getString(prefPasswordKey, null) + return encryptedPref.getString(prefPasswordKey, null) } + fun deleteEsanjeevaniCreds() { - val editor = pref.edit() + val editor = encryptedPref.edit() val prefUserKeyEs = context.getString(R.string.esanjeevaniusername_local_saved) val prefPasswordKeyEs = context.getString(R.string.esanjeevanipassword_local_saved) editor.remove(prefUserKeyEs) editor.remove(prefPasswordKeyEs) editor.apply() } + fun deleteLoginCred() { - val editor = pref.edit() + val editor = encryptedPref.edit() val prefUserKey = context.getString(R.string.PREF_rem_me_uname) val prefPasswordKey = context.getString(R.string.password_local_saved) editor.remove(prefUserKey) @@ -331,6 +460,8 @@ fun registerEsanjeevaniCred(userName: String,password: String) { return when (pref.getString(key, null)) { Languages.ENGLISH.symbol -> Languages.ENGLISH Languages.KANNADA.symbol -> Languages.KANNADA + Languages.HINDI.symbol -> Languages.HINDI + Languages.ASSAMESE.symbol -> Languages.ASSAMESE else -> Languages.ENGLISH } } diff --git a/app/src/main/java/org/piramalswasthya/cho/di/AppModule.kt b/app/src/main/java/org/piramalswasthya/cho/di/AppModule.kt index f5a40a3f6..dcf660869 100644 --- a/app/src/main/java/org/piramalswasthya/cho/di/AppModule.kt +++ b/app/src/main/java/org/piramalswasthya/cho/di/AppModule.kt @@ -11,6 +11,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import org.piramalswasthya.cho.BuildConfig import org.piramalswasthya.cho.database.room.InAppDb import org.piramalswasthya.cho.database.room.dao.BatchDao import org.piramalswasthya.cho.database.room.dao.BenFlowDao @@ -19,20 +20,24 @@ import org.piramalswasthya.cho.database.room.dao.CaseRecordeDao import org.piramalswasthya.cho.database.room.dao.CbacDao import org.piramalswasthya.cho.database.room.dao.ChiefComplaintMasterDao import org.piramalswasthya.cho.database.room.dao.DeliveryOutcomeDao +import org.piramalswasthya.cho.database.room.dao.NeonatalOutcomeDao import org.piramalswasthya.cho.database.room.dao.DistrictMasterDao import org.piramalswasthya.cho.database.room.dao.EcrDao import org.piramalswasthya.cho.database.room.dao.GovIdEntityMasterDao import org.piramalswasthya.cho.database.room.dao.HealthCenterDao import org.piramalswasthya.cho.database.room.dao.HistoryDao import org.piramalswasthya.cho.database.room.dao.ImmunizationDao +import org.piramalswasthya.cho.database.room.dao.InfantRegDao import org.piramalswasthya.cho.database.room.dao.InvestigationDao import org.piramalswasthya.cho.database.room.dao.LanguageDao import org.piramalswasthya.cho.database.room.dao.LoginSettingsDataDao +import org.piramalswasthya.cho.database.room.dao.AshaDueListDao import org.piramalswasthya.cho.database.room.dao.MaternalHealthDao import org.piramalswasthya.cho.database.room.dao.OtherGovIdEntityMasterDao import org.piramalswasthya.cho.database.room.dao.OutreachDao import org.piramalswasthya.cho.database.room.dao.PatientDao import org.piramalswasthya.cho.database.room.dao.PatientVisitInfoSyncDao +import org.piramalswasthya.cho.database.room.dao.OphthalmicDao import org.piramalswasthya.cho.database.room.dao.PncDao import org.piramalswasthya.cho.database.room.dao.PrescriptionDao import org.piramalswasthya.cho.database.room.dao.PrescriptionTemplateDao @@ -41,6 +46,7 @@ import org.piramalswasthya.cho.database.room.dao.ProcedureMasterDao import org.piramalswasthya.cho.database.room.dao.ReferRevisitDao import org.piramalswasthya.cho.database.room.dao.RegistrarMasterDataDao import org.piramalswasthya.cho.database.room.dao.StateMasterDao +import org.piramalswasthya.cho.database.room.dao.StatusOfWomanDao import org.piramalswasthya.cho.database.room.dao.SubCatVisitDao import org.piramalswasthya.cho.database.room.dao.UserAuthDao import org.piramalswasthya.cho.database.room.dao.UserDao @@ -53,6 +59,7 @@ import org.piramalswasthya.cho.network.AbhaApiService import org.piramalswasthya.cho.network.AmritApiService import org.piramalswasthya.cho.network.ESanjeevaniApiService import org.piramalswasthya.cho.network.FlwApiService +import org.piramalswasthya.cho.network.interceptors.AuthRefreshInterceptor import org.piramalswasthya.cho.network.interceptors.ContentTypeInterceptor import org.piramalswasthya.cho.network.interceptors.TokenESanjeevaniInterceptor import org.piramalswasthya.cho.network.interceptors.TokenInsertAbhaInterceptor @@ -63,6 +70,14 @@ import retrofit2.converter.moshi.MoshiConverterFactory import java.util.concurrent.TimeUnit import javax.inject.Named import javax.inject.Singleton +import org.piramalswasthya.cho.database.room.dao.EarDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.NoseDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.PainAndSymptomAssessmentDao +import org.piramalswasthya.cho.database.room.dao.OralHealthDao +import org.piramalswasthya.cho.database.room.dao.PsychosocialCaregiverSupportDao +import org.piramalswasthya.cho.database.room.dao.MentalHealthScreeningDao +import org.piramalswasthya.cho.database.room.dao.ThroatDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.ElderlyHealthAssessmentDao @Module @InstallIn(SingletonComponent::class) @@ -72,7 +87,12 @@ object AppModule { private val baseClient = OkHttpClient.Builder() - .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .addInterceptor( + HttpLoggingInterceptor().setLevel( + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC + else HttpLoggingInterceptor.Level.NONE + ) + ) .addInterceptor(ContentTypeInterceptor()) .build() @@ -96,6 +116,7 @@ object AppModule { .readTimeout(600, TimeUnit.SECONDS) .writeTimeout(600, TimeUnit.SECONDS) .addInterceptor(TokenInsertTmcInterceptor()) + .addInterceptor(AuthRefreshInterceptor()) .build() } @Singleton @@ -293,6 +314,10 @@ fun provideESanjeevaniApiService( @Provides fun provideMaternalHealthDao(database: InAppDb): MaternalHealthDao = database.maternalHealthDao + @Singleton + @Provides + fun provideAshaDueListDao(database: InAppDb): AshaDueListDao = database.ashaDueListDao + @Singleton @Provides fun provideImmunizationDao(database: InAppDb): ImmunizationDao = database.immunizationDao @@ -301,6 +326,10 @@ fun provideESanjeevaniApiService( @Provides fun provideDeliveryOutcomeDao(database: InAppDb): DeliveryOutcomeDao = database.deliveryOutcomeDao + @Singleton + @Provides + fun provideNeonatalOutcomeDao(database: InAppDb): NeonatalOutcomeDao = database.neonatalOutcomeDao + @Singleton @Provides fun providePncDao(database: InAppDb): PncDao = database.pncDao @@ -309,6 +338,10 @@ fun provideESanjeevaniApiService( @Provides fun provideEcrDao(database: InAppDb): EcrDao = database.ecrDao + @Singleton + @Provides + fun provideInfantRegDao(database: InAppDb): InfantRegDao = database.infantRegDao + @Singleton @Provides fun providePreferenceDao(@ApplicationContext context: Context) = PreferenceDao(context) @@ -336,4 +369,47 @@ fun provideESanjeevaniApiService( @Singleton @Provides fun provideCbacDao(database: InAppDb): CbacDao = database.cbacDao + + @Singleton + @Provides + fun provideStatusOfWomanDao(database: InAppDb): StatusOfWomanDao = database.statusOfWomanDao + + @Singleton + @Provides + fun provideOphthalmicDao(database: InAppDb): OphthalmicDao = database.ophthalmicDao + + @Singleton + @Provides + fun provideEarDiagnosisAssessmentDao(database: InAppDb): EarDiagnosisAssessmentDao = database.earDiagnosisAssessmentDao + + @Singleton + @Provides + fun provideNoseDiagnosisAssessmentDao(database: InAppDb): NoseDiagnosisAssessmentDao = database.noseDiagnosisAssessmentDao + + @Singleton + @Provides + fun providePainAndSymptomAssessmentDao(database: InAppDb): PainAndSymptomAssessmentDao = database.painAndSymptomAssessmentDao + + @Singleton + @Provides + fun provideOralHealthDao(database: InAppDb): OralHealthDao = + database.oralHealthDao + + @Singleton + @Provides + fun providePsychosocialCaregiverSupportDao(database: InAppDb): PsychosocialCaregiverSupportDao = database.psychosocialCaregiverSupportDao + @Singleton + @Provides + fun provideMentalHealthScreeningDao(database: InAppDb): MentalHealthScreeningDao = database.mentalHealthScreeningDao + + @Singleton + @Provides + fun provideThroatDiagnosisAssessmentDao(database: InAppDb): ThroatDiagnosisAssessmentDao = + database.throatDiagnosisAssessmentDao + + @Singleton + @Provides + fun provideElderlyHealthAssessmentDao(database: InAppDb): ElderlyHealthAssessmentDao = database.elderlyHealthAssessmentDao + + } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/di/TokenRefreshModule.kt b/app/src/main/java/org/piramalswasthya/cho/di/TokenRefreshModule.kt new file mode 100644 index 000000000..4d31732bd --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/di/TokenRefreshModule.kt @@ -0,0 +1,18 @@ +package org.piramalswasthya.cho.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.piramalswasthya.cho.network.TokenRefreshProvider +import org.piramalswasthya.cho.network.TokenRefreshProviderImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class TokenRefreshModule { + + @Binds + @Singleton + abstract fun bindTokenRefreshProvider(impl: TokenRefreshProviderImpl): TokenRefreshProvider +} diff --git a/app/src/main/java/org/piramalswasthya/cho/facenet/Models.kt b/app/src/main/java/org/piramalswasthya/cho/facenet/Models.kt index 928c47e38..27f5bb7c7 100644 --- a/app/src/main/java/org/piramalswasthya/cho/facenet/Models.kt +++ b/app/src/main/java/org/piramalswasthya/cho/facenet/Models.kt @@ -7,7 +7,7 @@ class Models { "FaceNet" , "facenet.tflite" , 0.4f , - 10f , + 5.0f , 128 , 160 ) diff --git a/app/src/main/java/org/piramalswasthya/cho/helpers/CaseClosureManager.kt b/app/src/main/java/org/piramalswasthya/cho/helpers/CaseClosureManager.kt new file mode 100644 index 000000000..ee995bc30 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/helpers/CaseClosureManager.kt @@ -0,0 +1,197 @@ +package org.piramalswasthya.cho.helpers + +import org.piramalswasthya.cho.database.room.dao.CaseRecordeDao +import org.piramalswasthya.cho.database.room.dao.PrescriptionDao +import org.piramalswasthya.cho.model.PatientDisplayWithVisitInfo +import org.piramalswasthya.cho.model.PatientVisitInfoSync +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Centralized business logic for case closure workflow. + * + * RULES: + * 1. AUTO-CLOSE: Only medicine prescribed (no lab) + medicine dispensed + * 2. AUTO-CLOSE: Nothing prescribed (no lab, no medicine) + * 3. MANUAL-CLOSE: Lab test involved (with or without medicine) + * 4. CANNOT CLOSE: Pending lab test or undispensed medicine + */ +@Singleton +class CaseClosureManager @Inject constructor( + private val caseRecordeDao: CaseRecordeDao, + private val prescriptionDao: PrescriptionDao +) { + + /** + * Determines if case should auto-close based on prescription type. + * + * @return true if case should auto-close, false if manual closure required + */ + suspend fun shouldAutoClose(visitInfo: PatientVisitInfoSync): Boolean { + val hasMedicine = hasMedicinePrescribed(visitInfo.patientID, visitInfo.benVisitNo) + val hasLab = hasLabTestPrescribed(visitInfo.patientID, visitInfo.benVisitNo) + val medicineDispensed = visitInfo.pharmacist_flag == 9 + + return when { + // Scenario 1: Nothing prescribed -> auto close + !hasMedicine && !hasLab -> { + Timber.d("CaseClosureManager: Auto-close - nothing prescribed") + true + } + // Scenario 2: Only medicine + dispensed -> auto close + hasMedicine && !hasLab && medicineDispensed -> { + Timber.d("CaseClosureManager: Auto-close - only medicine dispensed") + true + } + // All other scenarios require manual closure + else -> { + Timber.d("CaseClosureManager: Manual close required - hasLab=$hasLab, hasMedicine=$hasMedicine, dispensed=$medicineDispensed") + false + } + } + } + + /** + * Checks if case can be manually closed by doctor. + * Validates that all prerequisites are met. + * + * @return Pair - (canClose, errorMessage) + */ + suspend fun canManuallyClose(visitInfo: PatientVisitInfoSync): Pair { + val hasPendingLab = hasLabTestPending(visitInfo) + val hasUndispensedMedicine = hasUndispensedMedicine(visitInfo) + + return when { + hasPendingLab -> { + Timber.w("CaseClosureManager: Cannot close - pending lab test") + false to "Cannot close case: Lab test is pending" + } + hasUndispensedMedicine -> { + Timber.w("CaseClosureManager: Cannot close - undispensed medicine") + false to "Cannot close case: Medicine has not been dispensed" + } + else -> { + Timber.d("CaseClosureManager: Can manually close") + true to null + } + } + } + + /** + * Checks if lab test was ever prescribed for this visit. + * Lab involvement determines if closure should be manual. + */ + suspend fun hasLabTestPrescribed(patientID: String, benVisitNo: Int): Boolean { + return try { + val investigation = caseRecordeDao.getPrescriptionCasesRecordByPatientIDAndBenVisitNo( + patientID, + benVisitNo + ) + val hasTests = !investigation?.previousTestIds.isNullOrBlank() || + !investigation?.newTestIds.isNullOrBlank() + Timber.d("CaseClosureManager: hasLabTestPrescribed=$hasTests for $patientID/$benVisitNo") + hasTests + } catch (e: Exception) { + Timber.e(e, "Error checking lab test prescription") + false + } + } + + /** + * Checks if medicine was prescribed for this visit. + */ + suspend fun hasMedicinePrescribed(patientID: String, benVisitNo: Int): Boolean { + return try { + val prescriptions = caseRecordeDao.getPrescriptionByPatientIDAndBenVisitNo( + patientID, + benVisitNo + ) + val hasMedicine = !prescriptions.isNullOrEmpty() + Timber.d("CaseClosureManager: hasMedicinePrescribed=$hasMedicine for $patientID/$benVisitNo") + hasMedicine + } catch (e: Exception) { + Timber.e(e, "Error checking medicine prescription") + false + } + } + + /** + * Checks if lab test is pending (prescribed but not completed). + */ + private suspend fun hasLabTestPending(visitInfo: PatientVisitInfoSync): Boolean { + val hasLab = hasLabTestPrescribed(visitInfo.patientID, visitInfo.benVisitNo) + // Backward compatibility: older records may stay at labtechFlag=1 even after lab submission, + // but doctorFlag=3 represents the post-lab review state. + val isLabCompleted = (visitInfo.labtechFlag ?: 0) == 9 || (visitInfo.doctorFlag ?: 0) == 3 + val isPending = hasLab && !isLabCompleted + Timber.d("CaseClosureManager: hasLabTestPending=$isPending (hasLab=$hasLab, labtechFlag=${visitInfo.labtechFlag}, doctorFlag=${visitInfo.doctorFlag})") + return isPending + } + + /** + * Checks if medicine is prescribed but not dispensed. + */ + private suspend fun hasUndispensedMedicine(visitInfo: PatientVisitInfoSync): Boolean { + val hasMedicine = hasMedicinePrescribed(visitInfo.patientID, visitInfo.benVisitNo) + // Medicine is undispensed if prescribed but pharmacist_flag != 9 (not dispensed) + val isUndispensed = hasMedicine && (visitInfo.pharmacist_flag ?: 0) != 9 + Timber.d("CaseClosureManager: hasUndispensedMedicine=$isUndispensed (hasMedicine=$hasMedicine, pharmacist_flag=${visitInfo.pharmacist_flag})") + return isUndispensed + } + + /** + * Determines if case requires manual closure confirmation dialog. + */ + suspend fun requiresManualClosureConfirmation(visitInfo: PatientVisitInfoSync): Boolean { + // Manual confirmation required if lab was ever involved + val hasLab = hasLabTestPrescribed(visitInfo.patientID, visitInfo.benVisitNo) + Timber.d("CaseClosureManager: requiresManualClosureConfirmation=$hasLab") + return hasLab + } + + /** + * Helper for PatientDisplayWithVisitInfo overload. + */ + suspend fun shouldAutoClose(benVisitInfo: PatientDisplayWithVisitInfo): Boolean { + val visitInfo = PatientVisitInfoSync( + patientID = benVisitInfo.patient.patientID, + benVisitNo = benVisitInfo.benVisitNo ?: 0, + pharmacist_flag = benVisitInfo.pharmacist_flag, + labtechFlag = benVisitInfo.labtechFlag, + doctorFlag = benVisitInfo.doctorFlag, + nurseFlag = benVisitInfo.nurseFlag + ) + return shouldAutoClose(visitInfo) + } + + /** + * Helper for PatientDisplayWithVisitInfo overload. + */ + suspend fun canManuallyClose(benVisitInfo: PatientDisplayWithVisitInfo): Pair { + val visitInfo = PatientVisitInfoSync( + patientID = benVisitInfo.patient.patientID, + benVisitNo = benVisitInfo.benVisitNo ?: 0, + pharmacist_flag = benVisitInfo.pharmacist_flag, + labtechFlag = benVisitInfo.labtechFlag, + doctorFlag = benVisitInfo.doctorFlag, + nurseFlag = benVisitInfo.nurseFlag + ) + return canManuallyClose(visitInfo) + } + + /** + * Helper for PatientDisplayWithVisitInfo overload. + */ + suspend fun requiresManualClosureConfirmation(benVisitInfo: PatientDisplayWithVisitInfo): Boolean { + val visitInfo = PatientVisitInfoSync( + patientID = benVisitInfo.patient.patientID, + benVisitNo = benVisitInfo.benVisitNo ?: 0, + pharmacist_flag = benVisitInfo.pharmacist_flag, + labtechFlag = benVisitInfo.labtechFlag, + doctorFlag = benVisitInfo.doctorFlag, + nurseFlag = benVisitInfo.nurseFlag + ) + return requiresManualClosureConfirmation(visitInfo) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/helpers/CommonUtils.kt b/app/src/main/java/org/piramalswasthya/cho/helpers/CommonUtils.kt index 3d0156c05..c95224d6e 100644 --- a/app/src/main/java/org/piramalswasthya/cho/helpers/CommonUtils.kt +++ b/app/src/main/java/org/piramalswasthya/cho/helpers/CommonUtils.kt @@ -12,6 +12,34 @@ import java.util.concurrent.TimeUnit fun getWeeksOfPregnancy(regLong: Long, lmpLong: Long) = (TimeUnit.MILLISECONDS.toDays(regLong - lmpLong) / 7).toInt() +/** Current gestational age in whole weeks — matches ANC visit list display. */ +fun getCurrentWeeksOfPregnancy(lmpLong: Long): Int { + if (lmpLong <= 0L) return 0 + return getWeeksOfPregnancy(getTodayMillis(), lmpLong).coerceAtLeast(0) +} + +/** Current gestational age formatted — matches ANC visit list, used on read-only form fields. */ +fun getCurrentGestationalAgeFormatted(lmpLong: Long): String { + if (lmpLong <= 0L) return "NA" + return getGestationalAgeFormatted(getTodayMillis(), lmpLong) +} + +/** + * Formats gestational age as "X weeks Y days" + * @param regLong Current date in milliseconds + * @param lmpLong LMP date in milliseconds + * @return Formatted string like "12 weeks 3 days" or "0 weeks" if regLong < lmpLong + */ +fun getGestationalAgeFormatted(regLong: Long, lmpLong: Long): String { + val diff = regLong - lmpLong + if (diff <= 0) { + return "0 weeks" + } + val totalDays = TimeUnit.MILLISECONDS.toDays(diff).toInt().coerceAtLeast(0) + val weeks = totalDays / 7 + return "$weeks weeks" +} + fun getTodayMillis() = Calendar.getInstance().setToStartOfTheDay().timeInMillis diff --git a/app/src/main/java/org/piramalswasthya/cho/helpers/Konstants.kt b/app/src/main/java/org/piramalswasthya/cho/helpers/Konstants.kt index 790c10e31..73589e252 100644 --- a/app/src/main/java/org/piramalswasthya/cho/helpers/Konstants.kt +++ b/app/src/main/java/org/piramalswasthya/cho/helpers/Konstants.kt @@ -52,12 +52,13 @@ object Konstants { const val maxAnc4Week = 40 const val minWeekToShowDelivered = 23 + const val maxWeekToShowAbortion = 24 const val babyLowWeight: Double = 2.5 - //PNC-EC cycle + //PNC-EC cycle const val pncEcGap : Long = 45 diff --git a/app/src/main/java/org/piramalswasthya/cho/helpers/Languages.kt b/app/src/main/java/org/piramalswasthya/cho/helpers/Languages.kt index 8bfb52a5c..e3db14078 100644 --- a/app/src/main/java/org/piramalswasthya/cho/helpers/Languages.kt +++ b/app/src/main/java/org/piramalswasthya/cho/helpers/Languages.kt @@ -2,5 +2,7 @@ package org.piramalswasthya.cho.helpers enum class Languages(val symbol: String) { ENGLISH("en"), - KANNADA("kn") + KANNADA("kn"), + ASSAMESE("as"), + HINDI("hi"), } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/BenDetailsDownsync.kt b/app/src/main/java/org/piramalswasthya/cho/model/BenDetailsDownsync.kt index 66d728030..a393dc937 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/BenDetailsDownsync.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/BenDetailsDownsync.kt @@ -27,6 +27,7 @@ data class BenAnthropometryDetail( val waistHipRatio: Double?, val deleted: Boolean?, val processed: String?, + val facilityID: Int?, ) @JsonClass(generateAdapter = true) @@ -57,6 +58,7 @@ data class GOPDNurseVisitDetail( val subVisitCategory: String?, val followUpForFpMethod: String?, val sideEffects: String?, + val facilityID: Int?, ) @JsonClass(generateAdapter = true) @@ -92,6 +94,7 @@ data class BenPhysicalVitalDetail( val hemoglobin: Double?, val deleted: Boolean?, val processed: String?, + val facilityID: Int?, ) @JsonClass(generateAdapter = true) @@ -108,7 +111,8 @@ data class BenChiefComplaints( val unitOfDuration: String?, val description: String?, val deleted: Boolean?, - val processed: String? + val processed: String?, + val facilityID: Int?, ) //{ diff --git a/app/src/main/java/org/piramalswasthya/cho/model/BenFlow.kt b/app/src/main/java/org/piramalswasthya/cho/model/BenFlow.kt index b9a893079..680786e44 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/BenFlow.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/BenFlow.kt @@ -2,8 +2,8 @@ package org.piramalswasthya.cho.model import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.ForeignKey import androidx.room.PrimaryKey +import com.google.gson.annotations.SerializedName import com.squareup.moshi.JsonClass @Entity(tableName = "BENFLOW") @@ -108,6 +108,10 @@ data class BenFlow( @ColumnInfo(name = "vanID") val vanID: Int?, + @ColumnInfo(name = "facilityID") + @SerializedName(value = "facilityID", alternate = ["facilityId", "FacilityID"]) + val facilityID: Int?, + // val masterVan: { // // "vanID": 4, @@ -129,6 +133,12 @@ data class BenFlow( @ColumnInfo(name = "beneficiaryID") val beneficiaryID: Long?, + @ColumnInfo(name = "reproductiveStatusId") + val reproductiveStatusId: Int?, + + @ColumnInfo(name = "reproductiveStatus") + val reproductiveStatus: String?, + @ColumnInfo(name = "parkingPlaceID") val parkingPlaceID: Int? = null, diff --git a/app/src/main/java/org/piramalswasthya/cho/model/BenNewFlow.kt b/app/src/main/java/org/piramalswasthya/cho/model/BenNewFlow.kt index 2196d4b9c..aa99e8422 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/BenNewFlow.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/BenNewFlow.kt @@ -38,7 +38,11 @@ data class BenNewFlow ( val ageUnits: String?, val emergencyRegistration: Boolean?, val providerServiceMapId: String?, - val vanID: Int? + val facilityID: Int?, + val facilityName: String?, + val facilityType: String?, + val employeeId: String?, + val locationType: String? ){ constructor(user: UserDomain?, patientDisplay: PatientDisplay?) : this( patientDisplay?.patient?.beneficiaryRegID, @@ -72,7 +76,11 @@ data class BenNewFlow ( ageUnits = patientDisplay?.ageUnit?.name?.lowercase(), false, user?.serviceMapId?.toString(), - user?.vanId + facilityID = user?.facilityID, + facilityName = user?.facilityName, + facilityType = user?.facilityType, + employeeId = user?.employeeId, + locationType = user?.locationType ) // val beneficiaryRegID: Long?, diff --git a/app/src/main/java/org/piramalswasthya/cho/model/CBAC.kt b/app/src/main/java/org/piramalswasthya/cho/model/CBAC.kt index 4cc1d75a2..59ddccedc 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/CBAC.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/CBAC.kt @@ -984,7 +984,7 @@ data class CbacRequest( val sessionID: Int?, val parkingPlaceID: Int?, val createdBy: String, - val vanID: Int?, + val facilityID: Int?, val beneficiaryRegID: Long, val benVisitID: Long?, val providerServiceMapID: Int? @@ -1011,7 +1011,7 @@ data class CbacVisitDetails( val healthFacilityLocation: String?=null, val reportFilePath: String?=null, val createdBy: String, - val vanID: Int, + val facilityID: Int?, val parkingPlaceID: Int, val fileIDs:String?=null ) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/DeliveryOutcome.kt b/app/src/main/java/org/piramalswasthya/cho/model/DeliveryOutcome.kt index 476fa9e8f..5245e55c9 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/DeliveryOutcome.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/DeliveryOutcome.kt @@ -41,6 +41,27 @@ data class DeliveryOutcomeCache ( var dateOfDischarge: Long? = null, var timeOfDischarge: String? = null, var isJSYBenificiary: Boolean? = null, + var gestationalAgeAtDelivery: String? = null, + var deliveryConductedBy: String? = null, + var modeOfDelivery: String? = null, + var indicationForLSCS: String? = null, + var indicationForLSCSOther: String? = null, + var privateHospitalName: String? = null, + + /** Mother's condition immediately post-delivery: Healthy/Stable, Complication (specify), Critical/ICU admission, Maternal Death */ + var motherCondition: String? = null, + /** Comma-separated maternal complications when condition is Complication or Critical */ + var maternalComplications: String? = null, + /** Mother currently admitted? Yes (Still in hospital) / No (Discharged) */ + var motherCurrentlyAdmitted: Boolean? = null, + + var isDeath: Boolean? = null, + var isDeathValue: String? = null, + var dateOfDeath: String? = null, + var placeOfDeath: String? = null, + var placeOfDeathId: Int? = null, + var otherPlaceOfDeath: String? = null, + // var isActive: Boolean? = true, var processed: String? = "N", var createdBy: String, @@ -79,6 +100,21 @@ data class DeliveryOutcomeCache ( dateOfDischarge = dateOfDischarge?.let { getDateStringFromLong(it) }, timeOfDischarge = timeOfDischarge, isJSYBenificiary = isJSYBenificiary, + gestationalAgeAtDelivery = gestationalAgeAtDelivery, + deliveryConductedBy = deliveryConductedBy, + modeOfDelivery = modeOfDelivery, + indicationForLSCS = indicationForLSCS, + indicationForLSCSOther = indicationForLSCSOther, + privateHospitalName = privateHospitalName, + motherCondition = motherCondition, + maternalComplications = maternalComplications, + motherCurrentlyAdmitted = motherCurrentlyAdmitted, + isDeath = isDeath, + isDeathValue = isDeathValue, + dateOfDeath = dateOfDeath, + placeOfDeath = placeOfDeath, + placeOfDeathId = placeOfDeathId?.takeIf { it > 0 }, + otherPlaceOfDeath = otherPlaceOfDeath, createdDate = getDateStringFromLong(createdDate), createdBy = createdBy, updatedDate = getDateStringFromLong(updatedDate), @@ -106,37 +142,67 @@ data class DeliveryOutcomePost ( val dateOfDischarge: String? = null, val timeOfDischarge: String? = null, val isJSYBenificiary: Boolean? = null, + val gestationalAgeAtDelivery: String? = null, + val deliveryConductedBy: String? = null, + val modeOfDelivery: String? = null, + val indicationForLSCS: String? = null, + val indicationForLSCSOther: String? = null, + val privateHospitalName: String? = null, + val motherCondition: String? = null, + val maternalComplications: String? = null, + val motherCurrentlyAdmitted: Boolean? = null, + val isDeath: Boolean? = null, + val isDeathValue: String? = null, + val dateOfDeath: String? = null, + val placeOfDeath: String? = null, + val placeOfDeathId: Int? = null, + val otherPlaceOfDeath: String? = null, val createdDate: String? = null, val createdBy: String, val updatedDate: String? = null, val updatedBy: String ) { -// fun toDeliveryCache(): DeliveryOutcomeCache { -// return DeliveryOutcomeCache( -// id = id, -// benId = benId, -// isActive = isActive, -// dateOfDelivery = getLongFromDate(dateOfDelivery), -// timeOfDelivery = timeOfDelivery, -// placeOfDelivery = placeOfDelivery, -// typeOfDelivery = typeOfDelivery, -// hadComplications = hadComplications, -// complication = complication, -// causeOfDeath = causeOfDeath, -// otherCauseOfDeath = otherCauseOfDeath, -// otherComplication = otherComplication, -// deliveryOutcome = deliveryOutcome, -// liveBirth = liveBirth, -// stillBirth = stillBirth, -// dateOfDischarge = getLongFromDate(dateOfDischarge), -// timeOfDischarge = timeOfDischarge, -// isJSYBenificiary = isJSYBenificiary, -// processed = "P", -// createdBy = createdBy, -// createdDate = getLongFromDate(createdDate), -// updatedBy = updatedBy, -// updatedDate = getLongFromDate(updatedDate), -// syncState = SyncState.SYNCED -// ) -// } -} \ No newline at end of file + fun toDeliveryCache(patientID: String): DeliveryOutcomeCache { + return DeliveryOutcomeCache( + id = id, + patientID = patientID, + isActive = isActive, + dateOfDelivery = dateOfDelivery?.let { getLongFromDate(it) }, + timeOfDelivery = timeOfDelivery, + placeOfDelivery = placeOfDelivery, + typeOfDelivery = typeOfDelivery, + hadComplications = hadComplications, + complication = complication, + causeOfDeath = causeOfDeath, + otherCauseOfDeath = otherCauseOfDeath, + otherComplication = otherComplication, + deliveryOutcome = deliveryOutcome, + liveBirth = liveBirth, + stillBirth = stillBirth, + dateOfDischarge = dateOfDischarge?.let { getLongFromDate(it) }, + timeOfDischarge = timeOfDischarge, + isJSYBenificiary = isJSYBenificiary, + gestationalAgeAtDelivery = gestationalAgeAtDelivery, + deliveryConductedBy = deliveryConductedBy, + modeOfDelivery = modeOfDelivery, + indicationForLSCS = indicationForLSCS, + indicationForLSCSOther = indicationForLSCSOther, + privateHospitalName = privateHospitalName, + motherCondition = motherCondition, + maternalComplications = maternalComplications, + motherCurrentlyAdmitted = motherCurrentlyAdmitted, + isDeath = isDeath, + isDeathValue = isDeathValue, + dateOfDeath = dateOfDeath, + placeOfDeath = placeOfDeath, + placeOfDeathId = placeOfDeathId, + otherPlaceOfDeath = otherPlaceOfDeath, + processed = "P", + createdBy = createdBy, + createdDate = createdDate?.let { getLongFromDate(it) } ?: System.currentTimeMillis(), + updatedBy = updatedBy, + updatedDate = updatedDate?.let { getLongFromDate(it) } ?: System.currentTimeMillis(), + syncState = SyncState.SYNCED + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisUpsync.kt b/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisUpsync.kt index 3eb018157..6fbf9a325 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisUpsync.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisUpsync.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class DiagnosisUpsync( val prescriptionID: Int?, - val vanID: Int?, + val facilityID: Int?, val parkingPlaceID: Int?, val provisionalDiagnosisList: List?, val beneficiaryRegID: String?, @@ -18,7 +18,7 @@ data class DiagnosisUpsync( ) { constructor(user: UserDomain?, benFlow: BenFlow?, diagnosisList: List?,prescriptionID:Int?) : this( prescriptionID = prescriptionID, - user?.vanId, + user?.facilityID, user?.parkingPlaceId, provisionalDiagnosisList = diagnosisList?.map { ProvisionalDiagnosisUpsync(term = it.diagnosis) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisValue.kt b/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisValue.kt index 544b340b7..22b222a38 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisValue.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/DiagnosisValue.kt @@ -2,5 +2,7 @@ package org.piramalswasthya.cho.model data class DiagnosisValue( var id: Int = -1, - var diagnosis: String = "" + var diagnosis: String = "", + /** True when loaded from DB (already saved); row is shown but not editable. */ + var isPreFilled: Boolean = false ) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/DoctorDataDownSync.kt b/app/src/main/java/org/piramalswasthya/cho/model/DoctorDataDownSync.kt index d0613586e..ba38c1317 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/DoctorDataDownSync.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/DoctorDataDownSync.kt @@ -30,6 +30,7 @@ data class ReferData( val createdDate: String?, val lastModDate: String?, val vanID: Int?, + val facilityID: Int?, val parkingPlaceID: Int?, val refrredToAdditionalServiceList: List, val referralReason: String?, diff --git a/app/src/main/java/org/piramalswasthya/cho/model/EarDiagnosisAssessment.kt b/app/src/main/java/org/piramalswasthya/cho/model/EarDiagnosisAssessment.kt new file mode 100644 index 000000000..d511db3c9 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/EarDiagnosisAssessment.kt @@ -0,0 +1,52 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity(tableName = "EAR_DIAGNOSIS_ASSESSMENT") +@JsonClass(generateAdapter = true) +data class EarDiagnosisAssessment( + + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "assessment_id") + val assessmentId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientId: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int?, + + + @ColumnInfo(name = "difficulty_hearing") + var difficultyHearing: Boolean? = null, + + @ColumnInfo(name = "whisper_test_response") + var whisperTestResponse: String? = null, + + @ColumnInfo(name = "hearing_test_outcome") + var hearingTestOutcome: String? = null, + + @ColumnInfo(name = "ear_pain") + var earPain: Boolean? = null, + + @ColumnInfo(name = "ear_discharge_present") + var earDischargePresent: Boolean? = null, + + @ColumnInfo(name = "foreign_body_in_ear") + var foreignBodyInEar: String? = null, + + @ColumnInfo(name = "ear_condition_type") + var earConditionType: String? = null, + + @ColumnInfo(name = "congenital_ear_malformation") + var congenitalEarMalformation: Boolean? = null, + + @ColumnInfo(name = "syncState") + var syncState: Int = 0 + +) : FormDataModel diff --git a/app/src/main/java/org/piramalswasthya/cho/model/ElderlyHealthAssessment.kt b/app/src/main/java/org/piramalswasthya/cho/model/ElderlyHealthAssessment.kt new file mode 100644 index 000000000..31016bdaf --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/ElderlyHealthAssessment.kt @@ -0,0 +1,102 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity(tableName = "ELDERLY_HEALTH_ASSESSMENT", + indices = [ + Index(name = "index_elderly_health_assessment_patient_visit", value = ["patient_id", "ben_visit_no"], unique = true) + ] +) +@JsonClass(generateAdapter = true) +data class ElderlyHealthAssessment( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "assessment_id") + val assessmentId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientId: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int = 0, + + @ColumnInfo(name = "geriatric_complaints") + var geriatricComplaints: Boolean? = null, + + @ColumnInfo(name = "multiple_chronic_conditions") + var multipleChronicConditions: Boolean? = null, + + @ColumnInfo(name = "recent_falls") + var recentFalls: Boolean? = null, + + @ColumnInfo(name = "difficulty_walking_balance") + var difficultyWalkingBalance: Boolean? = null, + + @ColumnInfo(name = "visual_hearing_difficulty") + var visualHearingDifficulty: Boolean? = null, + + @ColumnInfo(name = "functional_decline") + var functionalDecline: Boolean? = null, + + @ColumnInfo(name = "bathing") + var bathing: Int? = null, + + @ColumnInfo(name = "dressing") + var dressing: Int? = null, + + @ColumnInfo(name = "toileting") + var toileting: Int? = null, + + @ColumnInfo(name = "transferring") + var transferring: Int? = null, + + @ColumnInfo(name = "continence") + var continence: Int? = null, + + @ColumnInfo(name = "feeding") + var feeding: Int? = null, + + @ColumnInfo(name = "total_score") + var totalScore: Int? = null, + + @ColumnInfo(name = "functional_status") + var functionalStatus: String? = null, + + @ColumnInfo(name = "functional_decline_flag") + var functionalDeclineFlag: Boolean? = null, + + @ColumnInfo(name = "memory_loss") + var memoryLoss: Boolean? = null, + + @ColumnInfo(name = "dementia_memory_loss") + var dementiaMemoryLoss: Boolean? = null, + + @ColumnInfo(name = "dementia_disorientation") + var dementiaDisorientation: Boolean? = null, + + @ColumnInfo(name = "dementia_behavioural_changes") + var dementiaBehaviouralChanges: Boolean? = null, + + @ColumnInfo(name = "dementia_self_care_decline") + var dementiaSelfCareDecline: Boolean? = null, + + @ColumnInfo(name = "dementia_screening_outcome") + var dementiaScreeningOutcome: String? = null, + + @ColumnInfo(name = "dementia_referral_required") + var dementiaReferralRequired: Boolean? = null, + + + @ColumnInfo(name = "syncState") + var syncState: Int = 0, + + @Embedded + val referralFollowUp: ReferralFollowUpFields = ReferralFollowUpFields() + +) : FormDataModel, ReferralFollowUpModel by referralFollowUp \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/EligibleCoupleRegCache.kt b/app/src/main/java/org/piramalswasthya/cho/model/EligibleCoupleRegCache.kt new file mode 100644 index 000000000..d90584e21 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/EligibleCoupleRegCache.kt @@ -0,0 +1,186 @@ +package org.piramalswasthya.cho.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.squareup.moshi.Json +import org.piramalswasthya.cho.configuration.FormDataModel +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.utils.DateTimeUtil +import org.piramalswasthya.cho.utils.HelperUtil +import java.util.concurrent.TimeUnit + +/** + * Eligible Couple Registration Cache + * Stores registration data for eligible couples in the RMNCHA+ module + */ +@Entity( + tableName = "ELIGIBLE_COUPLE_REG", + foreignKeys = [ForeignKey( + entity = Patient::class, + parentColumns = arrayOf("patientID"), + childColumns = arrayOf("patientID"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(name = "ecrInd", value = ["patientID"])] +) +data class EligibleCoupleRegCache( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val patientID: String, + var dateOfReg: Long = System.currentTimeMillis(), + var lmpDate: Long? = null, + var noOfChildren: Int = 0, + var noOfLiveChildren: Int = 0, + var noOfMaleChildren: Int = 0, + var noOfFemaleChildren: Int = 0, + var isRegistered: Boolean = true, + var processed: String? = "N", + var createdBy: String, + var createdDate: Long = System.currentTimeMillis(), + var updatedBy: String, + val updatedDate: Long = System.currentTimeMillis(), + var syncState: SyncState +) : FormDataModel + +/** + * Patient with Eligible Couple Registration data + */ +data class PatientWithECRCache( + @Embedded + val patient: Patient, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val ecr: EligibleCoupleRegCache? +) { + fun asDomainModel(): PatientWithEcrDomain { + return PatientWithEcrDomain( + patient = patient, + ecr = ecr + ) + } +} + +/** + * Domain model for displaying patient with EC registration + */ +data class PatientWithEcrDomain( + val patient: Patient, + val ecr: EligibleCoupleRegCache?, + var syncState: SyncState? = ecr?.syncState +) { + /** + * Calculate EC status based on LMP date + * Returns "Missed Period" if LMP date is > 35 days ago, otherwise "Under Review" + */ + fun getECStatus(): String { + val finalLmpDate = ecr?.lmpDate ?: lmpDateFromTracking + return if (finalLmpDate != null && finalLmpDate > 0L) { + val daysSinceLMP = TimeUnit.MILLISECONDS.toDays( + System.currentTimeMillis() - finalLmpDate + ) + if (daysSinceLMP > 35) "Missed Period" else "Under Review" + } else { + "Under Review" + } + } + + /** + * Check if the missed period indicator should be shown (red icon) + */ + fun showMissedPeriodIndicator(): Boolean { + return ecr?.lmpDate?.let { + val daysSinceLMP = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - it) + daysSinceLMP > 35 + } ?: false + } + + /** + * Get formatted LMP date string + */ + fun getFormattedLMPDate(): String { + val finalLmpDate = ecr?.lmpDate ?: lmpDateFromTracking + return if (finalLmpDate != null && finalLmpDate > 0L) { + HelperUtil.getDateStringFromLong(finalLmpDate) ?: "NA" + } else { + "NA" + } + } + + /** + * Get patient's age string for display (e.g. "30 YEARS" or "NA"). + */ + fun getAgeString(): String { + return patient.dob?.let { DateTimeUtil.calculateAgeString(it) } ?: "NA" + } + + /** + * Get last visit date string for display. + * This will be set by the adapter/fragment after loading ECT data. + */ + var lastVisitDate: Long? = null + var methodOfContraception: String? = null + var antraNextDueDate: Long? = null + var antraInjectionDate: Long? = null + var lmpDateFromTracking: Long? = null + + /** + * Get formatted last visit date string + */ + fun getLastVisitDateString(): String { + return lastVisitDate?.let { visitDate -> + if (visitDate > 0L) HelperUtil.getDateStringFromLong(visitDate) ?: "NA" + else "NA" + } ?: "NA" + } + + /** + * Get formatted ANTRA next due date string (range if possible) + */ + fun getAntraDueDateString(): String { + // Create fresh each call so Locale.getDefault() reflects the current app language + val dueDateFormat = java.text.SimpleDateFormat("dd-MM-yyyy", java.util.Locale.getDefault()) + return antraInjectionDate?.let { injectionDate -> + if (injectionDate > 0L) { + val cal = java.util.Calendar.getInstance() + cal.timeInMillis = injectionDate + + cal.add(java.util.Calendar.DAY_OF_YEAR, 76) + val startDate = dueDateFormat.format(cal.timeInMillis) + + cal.timeInMillis = injectionDate + cal.add(java.util.Calendar.DAY_OF_YEAR, 120) + val endDate = dueDateFormat.format(cal.timeInMillis) + + "$startDate to $endDate" + } else "NA" + } ?: antraNextDueDate?.let { dueDate -> + if (dueDate > 0L) dueDateFormat.format(dueDate) else "NA" + } ?: "NA" + } +} + +/** + * Network model for syncing EC registration data + */ +data class EcrPost( + val benId: Long, + @Json(name = "registrationDate") + val dateOfReg: String? = null, + val lmpDate: String? = null, + val numChildren: Int? = null, + val numLiveChildren: Int? = null, + val numMaleChildren: Int? = null, + val numFemaleChildren: Int? = null, + var isRegistered: Boolean = true, + var createdBy: String, + val createdDate: String, + var updatedBy: String, + val updatedDate: String +) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/EligibleCoupleTrackingCache.kt b/app/src/main/java/org/piramalswasthya/cho/model/EligibleCoupleTrackingCache.kt index d858312f0..520c4917c 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/EligibleCoupleTrackingCache.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/EligibleCoupleTrackingCache.kt @@ -1,17 +1,15 @@ package org.piramalswasthya.cho.model -import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import androidx.room.Relation +import com.google.gson.annotations.SerializedName import com.squareup.moshi.JsonClass import org.piramalswasthya.cho.configuration.FormDataModel import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.utils.DateTimeUtil.Companion.getDateTimeStringFromLong import java.text.SimpleDateFormat -import java.util.Calendar import java.util.Locale @Entity( @@ -31,11 +29,21 @@ data class EligibleCoupleTrackingCache( val id: Int = 0, val patientID: String, var visitDate: Long = System.currentTimeMillis(), + var financialYear: String? = null, + var visitMonth: String? = null, + var lmpDate: Long? = null, var isPregnancyTestDone: String? = null, var pregnancyTestResult: String? = null, var isPregnant: String? = null, var usingFamilyPlanning: Boolean? = null, var methodOfContraception: String? = null, + var anyOtherMethod: String? = null, + // ANTRA Injection fields + var antraDose: String? = null, + var antraInjectionDate: Long? = null, + var antraDueDate: Long? = null, + // Sterilization fields + var dateOfSterilization: Long? = null, val createdBy: String, val createdDate: Long = System.currentTimeMillis(), val updatedBy: String, @@ -46,117 +54,124 @@ data class EligibleCoupleTrackingCache( ) : FormDataModel { companion object { - private val dateFormat = SimpleDateFormat("EEE, MMM dd yyyy", Locale.getDefault()) - fun getECTFilledDateFromLong(long: Long): String { + // Create fresh each call so Locale.getDefault() reflects the current app language + val dateFormat = SimpleDateFormat("EEE, MMM dd yyyy", Locale.getDefault()) return "Visited on ${dateFormat.format(long)}" } } + fun getAntraDueDateString(): String { + // Create fresh each call so Locale.getDefault() reflects the current app language + val dueDateFormat = SimpleDateFormat("dd-MM-yyyy", Locale.getDefault()) + return antraInjectionDate?.let { injectionDate -> + if (injectionDate > 0L) { + val cal = java.util.Calendar.getInstance() + cal.timeInMillis = injectionDate + + cal.add(java.util.Calendar.DAY_OF_YEAR, 76) + val startDate = dueDateFormat.format(cal.timeInMillis) + + cal.timeInMillis = injectionDate + cal.add(java.util.Calendar.DAY_OF_YEAR, 120) + val endDate = dueDateFormat.format(cal.timeInMillis) + + "$startDate to $endDate" + } else "NA" + } ?: antraDueDate?.let { dueDate -> + if (dueDate > 0L) dueDateFormat.format(dueDate) else "NA" + } ?: "NA" + } + fun asNetworkModel(benId: Long): ECTNetwork { return ECTNetwork( + id = 0, benId = benId, visitDate = getDateTimeStringFromLong(visitDate)!!, + financialYear = financialYear, + visitMonth = visitMonth, + lmpDate = lmpDate?.let { getDateTimeStringFromLong(it) }, isPregnancyTestDone = isPregnancyTestDone, pregnancyTestResult = pregnancyTestResult, isPregnant = isPregnant, usingFamilyPlanning = usingFamilyPlanning, methodOfContraception = methodOfContraception, + anyOtherMethod = anyOtherMethod, + antraDose = antraDose, + dateOfAntraInjection = antraInjectionDate?.let { getDateTimeStringFromLong(it) }, + dueDateOfAntraInjection = antraDueDate?.let { getDateTimeStringFromLong(it) }, + dateOfSterilization = dateOfSterilization?.let { getDateTimeStringFromLong(it) }, isActive = isActive, createdBy = createdBy, createdDate = getDateTimeStringFromLong(createdDate)!!, updatedBy = updatedBy, updatedDate = getDateTimeStringFromLong(updatedDate)!!, + mpaFile = null, + dischargeSummary1 = null, + dischargeSummary2 = null, ) } } @JsonClass(generateAdapter = true) data class ECTNetwork( - val benId: Long, - val visitDate: String, - val isPregnancyTestDone: String?, - val pregnancyTestResult: String?, - val isPregnant: String?, - val usingFamilyPlanning: Boolean?, - val methodOfContraception: String?, - var isActive: Boolean?, - val createdBy: String, - val createdDate: String, - val updatedBy: String, - val updatedDate: String, -) - -//data class BenWithEcTrackingCache( -//// @ColumnInfo(name = "benId") -//// val ecBenId: Long, -// -// @Embedded -// val ben: BenBasicCache, -// @Relation( -// parentColumn = "benId", entityColumn = "benId" -// ) -// val ecr: EligibleCoupleRegCache, -// -// @Relation( -// parentColumn = "benId", entityColumn = "benId", entity = EligibleCoupleTrackingCache::class -// ) -// val savedECTRecords: List -//) { -// -// companion object { -// private val dateFormat = SimpleDateFormat("EEE, MMM dd yyyy", Locale.getDefault()) -// -// private fun getECTFilledDateFromLong(long: Long): String { -// return "Visited on ${dateFormat.format(long)}" -// } -// } -// -// fun asDomainModel(): BenWithEctListDomain { -// val recentFill = savedECTRecords.maxByOrNull { it.visitDate } -// val allowFill = recentFill?.let { -// val cal = Calendar.getInstance() -// val currentMonth = cal.get(Calendar.MONTH) -// val currentYear = cal.get(Calendar.YEAR) -// cal.apply { timeInMillis = recentFill.visitDate } -// val lastVisitMonth = cal.get(Calendar.MONTH) -// val lastVisitYear = cal.get(Calendar.YEAR) -// !(currentYear==lastVisitYear && currentMonth == lastVisitMonth) -// } ?: true -// return BenWithEctListDomain( -//// ecBenId, -// ben.asBasicDomainModel(), -// ecr.noOfLiveChildren.toString(), -// allowFill, -// savedECTRecords.map { -// ECTDomain( -// it.benId, -// it.createdDate, -// it.visitDate, -// getECTFilledDateFromLong(it.visitDate), -// it.syncState -// ) -// } -// ) -// } -//} -// -//data class ECTDomain( -// val benId: Long, -// val created: Long, -// val visited: Long, -// val filledOnString: String, -// val syncState: SyncState -//) -// -//data class BenWithEctListDomain( -//// val benId: Long, -// val ben: BenBasicDomain, -// val numChildren: String, -// val allowFill: Boolean, -// val savedECTRecords: List, -// val allSynced: SyncState? = if (savedECTRecords.isEmpty()) null else -// if (savedECTRecords.map { it.syncState } -// .all { it == SyncState.SYNCED }) SyncState.SYNCED else SyncState.UNSYNCED -// -//) \ No newline at end of file + val id: Int? = null, + @SerializedName(value = "benId", alternate = ["beneficiaryID", "beneficiaryId", "beneficiaryRegID", "beneficiaryRegId", "benID"]) + val benId: Long? = null, + @SerializedName(value = "visitDate", alternate = ["visitdate"]) + val visitDate: String? = null, + val financialYear: String? = null, + val visitMonth: String? = null, + val lmpDate: String? = null, + val isPregnancyTestDone: String? = null, + val pregnancyTestResult: String? = null, + val isPregnant: String? = null, + val usingFamilyPlanning: Boolean? = null, + val methodOfContraception: String? = null, + val anyOtherMethod: String? = null, + val antraDose: String? = null, + @SerializedName(value = "dateOfAntraInjection", alternate = ["antraInjectionDate"]) + val dateOfAntraInjection: String? = null, + @SerializedName(value = "dueDateOfAntraInjection", alternate = ["antraDueDate"]) + val dueDateOfAntraInjection: String? = null, + val dateOfSterilization: String? = null, + var isActive: Boolean? = null, + val createdBy: String? = null, + val createdDate: String? = null, + val updatedBy: String? = null, + val updatedDate: String? = null, + val mpaFile: String? = null, + val dischargeSummary1: String? = null, + val dischargeSummary2: String? = null, +) { + /** + * Convert server response into a local [EligibleCoupleTrackingCache] entity. + * @param patientID local patient ID mapped from [benId]. + */ + fun toCache(patientID: String): EligibleCoupleTrackingCache { + return EligibleCoupleTrackingCache( + patientID = patientID, + visitDate = org.piramalswasthya.cho.network.getLongFromDate(visitDate), + financialYear = financialYear, + visitMonth = visitMonth, + lmpDate = lmpDate?.let { org.piramalswasthya.cho.network.getLongFromDate(it) }, + isPregnancyTestDone = isPregnancyTestDone, + pregnancyTestResult = pregnancyTestResult, + isPregnant = isPregnant, + usingFamilyPlanning = usingFamilyPlanning, + methodOfContraception = methodOfContraception, + anyOtherMethod = anyOtherMethod, + antraDose = antraDose, + antraInjectionDate = dateOfAntraInjection?.let { org.piramalswasthya.cho.network.getLongFromDate(it) }, + antraDueDate = dueDateOfAntraInjection?.let { org.piramalswasthya.cho.network.getLongFromDate(it) }, + dateOfSterilization = dateOfSterilization?.let { org.piramalswasthya.cho.network.getLongFromDate(it) }, + createdBy = createdBy ?: "system", + createdDate = org.piramalswasthya.cho.network.getLongFromDate(createdDate), + updatedBy = updatedBy ?: (createdBy ?: "system"), + updatedDate = org.piramalswasthya.cho.network.getLongFromDate(updatedDate), + processed = "P", + isActive = isActive ?: true, + syncState = org.piramalswasthya.cho.database.room.SyncState.SYNCED + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/model/ExaminationDetails.kt b/app/src/main/java/org/piramalswasthya/cho/model/ExaminationDetails.kt index 2151ce99e..07702175f 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/ExaminationDetails.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/ExaminationDetails.kt @@ -48,7 +48,7 @@ data class CardioVascularExamination( val pericardialRub: String?, val providerServiceMapID: String?, val secondHeartSound_S2: String?, - val vanID: Int? + val facilityID: Int? ){ constructor(user: UserDomain?, benFlow: BenFlow?) : this( null, @@ -64,7 +64,7 @@ data class CardioVascularExamination( null, user?.serviceMapId.toString(), null, - user?.vanId + user?.facilityID ) } @@ -83,7 +83,7 @@ data class CentralNervousSystemExamination( val sensorySystem: String?, val signsOfMeningealIrritation: String?, val skull: String?, - val vanID: Int?, + val facilityID: Int?, // autonomicSystem: null // benVisitID: null // beneficiaryRegID: "33195" @@ -113,7 +113,7 @@ data class CentralNervousSystemExamination( null, null, null, - user?.vanId + user?.facilityID ) } @@ -133,7 +133,7 @@ data class GastroIntestinalExamination( val parkingPlaceID: Int?, val percussion: String?, val providerServiceMapID: String?, - val vanID: Int?, + val facilityID: Int?, // analRegion: null // auscultation: null // benVisitID: null @@ -165,7 +165,7 @@ data class GastroIntestinalExamination( user?.parkingPlaceId, null, user?.serviceMapId.toString(), - user?.vanId + user?.facilityID ) } @@ -196,7 +196,7 @@ data class GeneralExamination( val quickening: String?, val typeOfDangerSigns: String?, val typeOfLymphadenopathy: String?, - val vanID: Int? + val facilityID: Int? // benVisitID: null // beneficiaryRegID: "33195" // builtAndAppearance:null @@ -250,7 +250,7 @@ data class GeneralExamination( null, null, null, - user?.vanId + user?.facilityID ) } @@ -264,7 +264,7 @@ data class GenitoUrinarySystemExamination( val providerServiceMapID: String?, val renalAngle: String?, val suprapubicRegion: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33195" // createdBy:"Pranathi" @@ -284,7 +284,7 @@ data class GenitoUrinarySystemExamination( user?.serviceMapId.toString(), null, null, - user?.vanId + user?.facilityID ) } @@ -310,7 +310,7 @@ data class HeadToToeExamination( val throat: String?, val trunk: String?, val upperLimbs: String?, - val vanID: Int? + val facilityID: Int? // benVisitID: null // beneficiaryRegID: "33195" // breastAndNipples:null @@ -354,7 +354,7 @@ data class HeadToToeExamination( null, null, null, - user?.vanId, + user?.facilityID, ) } @@ -374,7 +374,7 @@ data class MusculoskeletalSystemExamination( val spine: String?, val upperLimb_Abnormality: String?, val upperLimb_Laterality: String?, - val vanID: Int? + val facilityID: Int? // benVisitID: null // beneficiaryRegID: "33195" // chestWall:null @@ -406,7 +406,7 @@ data class MusculoskeletalSystemExamination( null, null, null, - user?.vanId + user?.facilityID ) } @@ -428,7 +428,7 @@ data class RespiratorySystemExamination( val providerServiceMapID: String?, val signsOfRespiratoryDistress: String?, val trachea: String?, - val vanID: Int? + val facilityID: Int? // auscultation_BreathSounds: null // auscultation_ConductedSounds: null // auscultation_Crepitations: null @@ -464,6 +464,6 @@ data class RespiratorySystemExamination( user?.serviceMapId.toString(), null, null, - user?.vanId + user?.facilityID ) } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/FormElement.kt b/app/src/main/java/org/piramalswasthya/cho/model/FormElement.kt index cd4fedecc..ae8c596d3 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/FormElement.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/FormElement.kt @@ -11,8 +11,8 @@ data class FormElement( @ArrayRes var arrayId : Int = -1, var entries: Array? = null, var doubleStar: Boolean = false, - val hasDependants: Boolean = false, - val hasAlertError: Boolean = false, + var hasDependants: Boolean = false, + var hasAlertError: Boolean = false, var value: String? = null, val regex: String? = null, val allCaps: Boolean = false, @@ -32,4 +32,18 @@ data class FormElement( var isEnabled: Boolean = true, var headingLine: Boolean = true, val showYearFirstInDatePicker : Boolean = false, + /** Date format for DATE_PICKER (e.g. "dd/MM/yyyy"). Null = use default dd-MM-yyyy. */ + var dateFormat: String? = null, + var booleanValue: Boolean? = null, + var trueIndex: Int? = null, + var falseIndex: Int? = null, + /** + * When true, editing this field rebinds the consecutive sibling rows that share a + * cross-field validation rule (e.g. Delivery Outcome = Live Birth + Still Birth) so + * their error states refresh together. Opt-in per field: do NOT key this off the + * element id, because ids are reused across datasets (id 15/16/17 are height/weight/bmi + * in the PW registration form) and rebinding the focused EditText on every keystroke + * steals focus mid-typing. + */ + val refreshSiblingsOnChange: Boolean = false, ) \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/HistoryDetails.kt b/app/src/main/java/org/piramalswasthya/cho/model/HistoryDetails.kt index 11f368bbe..b8990c3fb 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/HistoryDetails.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/HistoryDetails.kt @@ -53,7 +53,7 @@ data class ChildVaccineDetails( val createdBy: String?, val parkingPlaceID: Int?, val providerServiceMapID: String?, - val vanID: Int?, + val facilityID: Int?, //benVisitID: null //beneficiaryRegID: "33195" //childOptionalVaccineList:[{vaccineName: null, sctCode: null, sctTerm: null, otherVaccineName: null, ageUnitID: null,…}] @@ -69,7 +69,7 @@ data class ChildVaccineDetails( user?.userName, user?.parkingPlaceId, user?.serviceMapId.toString(), - user?.vanId + user?.facilityID ) } @@ -100,7 +100,7 @@ data class ComorbidConditions( val createdBy: String?, val parkingPlaceID: Int?, val providerServiceMapID: String?, - val vanID: Int? + val facilityID: Int? // benVisitID: null // beneficiaryRegID: "33195" //comorbidityConcurrentConditionsList:[{comorbidConditions: null, otherComorbidCondition: null}] @@ -116,7 +116,7 @@ data class ComorbidConditions( user?.userName, user?.parkingPlaceId, user?.serviceMapId.toString(), - user?.vanId + user?.facilityID ) } @@ -149,7 +149,7 @@ data class DevelopmentHistory( val parkingPlaceID: Int?, val providerServiceMapID: String?, val socialMilestones: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33195" // createdBy:"Pranathi" @@ -181,7 +181,7 @@ data class DevelopmentHistory( user?.parkingPlaceId, user?.serviceMapId.toString(), null, - user?.vanId + user?.facilityID ) } @@ -196,7 +196,7 @@ data class FamilyHistory( val isGeneticDisorder: String?, val parkingPlaceID: Int?, val providerServiceMapID: String?, - val vanID: Int? + val facilityID: Int? // benVisitID: null // beneficiaryRegID: "33195" // createdBy:"Pranathi" @@ -218,7 +218,7 @@ data class FamilyHistory( null, user?.parkingPlaceId, user?.serviceMapId.toString(), - user?.vanId + user?.facilityID ) } @@ -257,7 +257,7 @@ data class FeedingHistory( val providerServiceMapID: String?, val typeOfFeed: String?, val typeOfFoodIntolerances: String?, - val vanID: Int? + val facilityID: Int? // benVisitID: null // beneficiaryRegID: "33195" // compFeedStartAge:null @@ -283,7 +283,7 @@ data class FeedingHistory( user?.serviceMapId.toString(), null, null, - user?.vanId + user?.facilityID ) } @@ -297,7 +297,7 @@ data class FemaleObstetricHistory( val parkingPlaceID: Int?, val providerServiceMapID: String?, val totalNoOfPreg: Int?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33211" // complicationPregList:[] @@ -317,7 +317,7 @@ data class FemaleObstetricHistory( user?.parkingPlaceId, user?.serviceMapId.toString(), null, - user?.vanId + user?.facilityID ) } @@ -391,7 +391,7 @@ data class MedicationHistoryNetwork( val medicationHistoryList: List?, val parkingPlaceID: Int?, val providerServiceMapID: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33212" // createdBy:"Pranathi" @@ -407,7 +407,7 @@ data class MedicationHistoryNetwork( arrayListOf(MedicationHistoryList()), user?.parkingPlaceId, user?.serviceMapId.toString(), - user?.vanId + user?.facilityID ) } @@ -435,7 +435,7 @@ data class MenstrualHistory( val parkingPlaceID: Int?, val providerServiceMapID: String?, val regularity: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID:null // beneficiaryRegID:"33212" // bloodFlowDuration:null @@ -465,7 +465,7 @@ data class MenstrualHistory( user?.parkingPlaceId, user?.serviceMapId.toString(), null, - user?.vanId + user?.facilityID ) } @@ -478,7 +478,7 @@ data class PastHistory( val pastIllness: List?, val pastSurgery: List?, val providerServiceMapID: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33212" // createdBy:"Pranathi" @@ -496,7 +496,7 @@ data class PastHistory( arrayListOf(PastIllness()), arrayListOf(PastSurgery()), user?.serviceMapId.toString(), - user?.vanId + user?.facilityID ) } @@ -549,7 +549,7 @@ data class PerinatalHistroy( val placeOfDelivery: String?, val providerServiceMapID: String?, val typeOfDelivery: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33212" // birthWeightG:null @@ -583,7 +583,7 @@ data class PerinatalHistroy( null, user?.serviceMapId.toString(), null, - user?.vanId + user?.facilityID ) } @@ -603,7 +603,7 @@ data class PersonalHistory( val riskySexualPracticesStatus: String?, val tobaccoList: List?, val tobaccoUseStatus: String?, - val vanID: Int? + val facilityID: Int? // alcoholIntakeStatus: null // alcoholList: [{alcoholTypeID: null, typeOfAlcohol: null, otherAlcoholType: null, alcoholIntakeFrequency: null,…}] // allergicList:[{allergyType: null, allergyName: null, snomedTerm: null, snomedCode: null,…}] @@ -635,7 +635,7 @@ data class PersonalHistory( null, arrayListOf(Tobacco()), null, - user?.vanId + user?.facilityID ) } diff --git a/app/src/main/java/org/piramalswasthya/cho/model/Icon.kt b/app/src/main/java/org/piramalswasthya/cho/model/Icon.kt new file mode 100644 index 000000000..2a10e7d8e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/Icon.kt @@ -0,0 +1,13 @@ +package org.piramalswasthya.cho.model + +import androidx.navigation.NavDirections +import kotlinx.coroutines.flow.Flow + +data class Icon( + val icon: Int, + val title: String, + val count: Flow?, + val navAction: NavDirections, + val colorPrimary: Boolean = true, + val allowRedBorder: Boolean = false +) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/InfantReg.kt b/app/src/main/java/org/piramalswasthya/cho/model/InfantReg.kt new file mode 100644 index 000000000..296893e61 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/InfantReg.kt @@ -0,0 +1,494 @@ +package org.piramalswasthya.cho.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import org.piramalswasthya.cho.configuration.FormDataModel +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.network.getLongFromDate +import org.piramalswasthya.cho.utils.DateTimeUtil +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +private fun formatMotherFullName(patient: Patient): String { + return "${patient.firstName} ${patient.lastName ?: ""}".trim() +} + +private fun formatBabyOrdinalName(motherFirstName: String?, babyIndex: Int): String { + return when (babyIndex) { + 0 -> "1st baby of $motherFirstName" + 1 -> "2nd baby of $motherFirstName" + 2 -> "3rd baby of $motherFirstName" + else -> "${babyIndex + 1}th baby of $motherFirstName" + } +} + +@Entity( + tableName = "INFANT_REG", + foreignKeys = [ForeignKey( + entity = Patient::class, + parentColumns = arrayOf("patientID"), + childColumns = arrayOf("motherPatientID"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(name = "infRegInd", value = ["motherPatientID"])] +) +data class InfantRegCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + var childPatientID: String? = null, + val motherPatientID: String, + var isActive: Boolean, + var babyName: String? = null, + var babyIndex: Int, + var infantTerm: String? = null, + var corticosteroidGiven: String? = null, + var genderID: Int? = null, + var babyCriedAtBirth: Boolean? = null, + var resuscitation: Boolean? = null, + var referred: String? = null, + var hadBirthDefect: String? = null, + var birthDefect: String? = null, + var otherDefect: String? = null, + var weight: Double? = null, + var breastFeedingStarted: Boolean? = null, + var opv0Dose: Long? = null, + var bcgDose: Long? = null, + var hepBDose: Long? = null, + var vitkDose: Long? = null, + + // BRD Neonatal Outcome fields + /** Q2: Live Birth / Still Birth (Macerated) / Still Birth (Fresh) / Died during delivery */ + var outcomeAtBirth: String? = null, + /** Q5: Comma-separated multi-select resuscitation types */ + var typeOfResuscitation: String? = null, + /** Q10: Comma-separated multi-select newborn complications */ + var newbornComplications: String? = null, + /** Q11: Healthy and with mother / Admitted (SNCU/NICU) / Admitted (General ward) / Died */ + var currentStatusOfBaby: String? = null, + /** Q12: Comma-separated multi-select cause of death */ + var causeOfDeath: String? = null, + /** Q13: Other cause of death free text */ + var otherCauseOfDeath: String? = null, + /** Q14: Comma-separated multi-select vaccines (BCG, Hepatitis B, OPV-0, None) */ + var birthDoseVaccinesGiven: String? = null, + /** Q15: Reason for not giving vaccines */ + var reasonForNoVaccines: String? = null, + /** Q16: Vitamin K injection given */ + var vitaminKInjectionGiven: Boolean? = null, + /** Q17: Reason for not giving Vitamin K */ + var reasonForNoVitaminK: String? = null, + /** Q18: Yes / In process / No (Not applied) */ + var birthCertificateIssued: String? = null, + + var processed: String? = "N", + var createdBy: String, + val createdDate: Long = System.currentTimeMillis(), + var updatedBy: String, + var updatedDate: Long = System.currentTimeMillis(), + var syncState: SyncState +) : FormDataModel { + fun hasRegistrationData(): Boolean { + return !infantTerm.isNullOrBlank() || + !corticosteroidGiven.isNullOrBlank() || + genderID != null || + babyCriedAtBirth != null || + resuscitation != null || + !referred.isNullOrBlank() || + !hadBirthDefect.isNullOrBlank() || + !birthDefect.isNullOrBlank() || + !otherDefect.isNullOrBlank() || + weight != null || + breastFeedingStarted != null || + opv0Dose != null || + bcgDose != null || + hepBDose != null || + vitkDose != null || + !outcomeAtBirth.isNullOrBlank() || + !typeOfResuscitation.isNullOrBlank() || + !newbornComplications.isNullOrBlank() || + !currentStatusOfBaby.isNullOrBlank() || + !causeOfDeath.isNullOrBlank() || + !otherCauseOfDeath.isNullOrBlank() || + !birthDoseVaccinesGiven.isNullOrBlank() || + !reasonForNoVaccines.isNullOrBlank() || + vitaminKInjectionGiven != null || + !reasonForNoVitaminK.isNullOrBlank() || + !birthCertificateIssued.isNullOrBlank() + } + + private fun getDateStringFromLong(dateLong: Long?): String? { + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + dateLong?.let { + return dateFormat.format(dateLong) + } ?: run { + return null + } + } + + fun asPostModel(): InfantRegPost { + return InfantRegPost( + id = id, + patientID = motherPatientID, + childPatientID = childPatientID, + isActive = isActive, + babyName = babyName, + babyIndex = babyIndex, + infantTerm = infantTerm, + corticosteroidGiven = corticosteroidGiven, + genderID = genderID, + babyCriedAtBirth = babyCriedAtBirth, + resuscitation = resuscitation, + referred = referred, + hadBirthDefect = hadBirthDefect, + birthDefect = birthDefect, + otherDefect = otherDefect, + weight = weight, + breastFeedingStarted = breastFeedingStarted, + opv0Dose = opv0Dose?.let { getDateStringFromLong(it) }, + bcgDose = bcgDose?.let { getDateStringFromLong(it) }, + hepBDose = hepBDose?.let { getDateStringFromLong(it) }, + vitkDose = vitkDose?.let { getDateStringFromLong(it) }, + outcomeAtBirth = outcomeAtBirth, + typeOfResuscitation = typeOfResuscitation, + newbornComplications = newbornComplications, + currentStatusOfBaby = currentStatusOfBaby, + causeOfDeath = causeOfDeath, + otherCauseOfDeath = otherCauseOfDeath, + birthDoseVaccinesGiven = birthDoseVaccinesGiven, + reasonForNoVaccines = reasonForNoVaccines, + vitaminKInjectionGiven = vitaminKInjectionGiven, + reasonForNoVitaminK = reasonForNoVitaminK, + birthCertificateIssued = birthCertificateIssued, + createdDate = getDateStringFromLong(createdDate), + createdBy = createdBy, + updatedDate = getDateStringFromLong(updatedDate), + updatedBy = updatedBy + ) + } +} + +/** + * Patient with Delivery Outcome and Infant Registration data + */ +data class PatientWithDeliveryOutcomeAndInfantRegCache( + @Embedded + val patient: Patient, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val deliveryOutcome: List?, + @Relation( + parentColumn = "patientID", + entityColumn = "motherPatientID" + ) + val savedInfantRegRecords: List +) { + fun asDomainModel(): List { + val activeDo = deliveryOutcome?.firstOrNull { it.isActive } ?: return emptyList() + val activeIr = savedInfantRegRecords.filter { it.isActive } + val list = mutableListOf() + val totalBirths = (activeDo.deliveryOutcome ?: ((activeDo.liveBirth ?: 0) + (activeDo.stillBirth ?: 0))) + .coerceAtLeast(0) + if (totalBirths == 0) return emptyList() + val numLiveBirth = (activeDo.liveBirth ?: 0).coerceAtLeast(0) + + for (i in 0 until totalBirths) { + list.add( + InfantRegDomain( + motherPatient = patient, + babyIndex = i, + deliveryOutcome = activeDo, + savedIr = activeIr.firstOrNull { it.babyIndex == i }, + isLiveBirth = i < numLiveBirth + ) + ) + } + return list + } +} + +/** + * Domain model for displaying infant registration list + */ +data class InfantRegDomain( + val motherPatient: Patient, + val babyIndex: Int, + var babyName: String = "Baby ${babyIndex + 1} of ${motherPatient.firstName}", + val deliveryOutcome: DeliveryOutcomeCache, + val savedIr: InfantRegCache?, + val isLiveBirth: Boolean = true, + val syncState: SyncState? = savedIr?.syncState +) { + /** + * Get formatted baby name (1st baby, 2nd baby, etc.) + */ + val customName: String + get() = savedIr?.babyName?.takeIf { it.isNotBlank() } + ?: formatBabyOrdinalName(motherPatient.firstName, babyIndex) + + /** + * Get mother's full name + */ + fun getMotherFullName(): String { + return formatMotherFullName(motherPatient) + } + + /** + * Get mother's age string for display (e.g. "29 YEARS" or "NA"). + */ + fun getMotherAgeString(): String { + return motherPatient.dob?.let { DateTimeUtil.calculateAgeString(it) } ?: "NA" + } + + /** + * Check if infant is registered + */ + fun isRegistered(): Boolean = savedIr?.hasRegistrationData() == true + + /** + * Only live-birth rows should show Register when not yet saved. + * Non-live-birth rows are always View state in list. + */ + fun shouldShowRegisterAction(): Boolean = isLiveBirth && !isRegistered() + + /** + * Guard clicks for non-live-birth rows with no saved record. + */ + fun isActionEnabled(): Boolean = isLiveBirth || isRegistered() +} + +data class InfantRegPost( + val id: Long = 0, + val patientID: String, + val childPatientID: String?, + val isActive: Boolean, + val babyName: String? = null, + val babyIndex: Int, + val infantTerm: String? = null, + val corticosteroidGiven: String? = null, + val genderID: Int? = null, + val babyCriedAtBirth: Boolean? = null, + val resuscitation: Boolean? = null, + val referred: String? = null, + val hadBirthDefect: String? = null, + val birthDefect: String? = null, + val otherDefect: String? = null, + val weight: Double? = null, + val breastFeedingStarted: Boolean? = null, + val opv0Dose: String? = null, + val bcgDose: String? = null, + val hepBDose: String? = null, + val vitkDose: String? = null, + val outcomeAtBirth: String? = null, + val typeOfResuscitation: String? = null, + val newbornComplications: String? = null, + val currentStatusOfBaby: String? = null, + val causeOfDeath: String? = null, + val otherCauseOfDeath: String? = null, + val birthDoseVaccinesGiven: String? = null, + val reasonForNoVaccines: String? = null, + val vitaminKInjectionGiven: Boolean? = null, + val reasonForNoVitaminK: String? = null, + val birthCertificateIssued: String? = null, + val createdDate: String? = null, + val createdBy: String, + val updatedDate: String? = null, + val updatedBy: String +) { + fun toCacheModel(): InfantRegCache { + return InfantRegCache( + id = id, + motherPatientID = patientID, + childPatientID = childPatientID, + isActive = isActive, + babyName = babyName, + babyIndex = babyIndex, + infantTerm = infantTerm, + corticosteroidGiven = corticosteroidGiven, + genderID = genderID, + babyCriedAtBirth = babyCriedAtBirth, + resuscitation = resuscitation, + referred = referred, + hadBirthDefect = hadBirthDefect, + birthDefect = birthDefect, + otherDefect = otherDefect, + weight = weight, + breastFeedingStarted = breastFeedingStarted, + opv0Dose = getLongFromDate(opv0Dose), + bcgDose = getLongFromDate(bcgDose), + hepBDose = getLongFromDate(hepBDose), + vitkDose = getLongFromDate(vitkDose), + outcomeAtBirth = outcomeAtBirth, + typeOfResuscitation = typeOfResuscitation, + newbornComplications = newbornComplications, + currentStatusOfBaby = currentStatusOfBaby, + causeOfDeath = causeOfDeath, + otherCauseOfDeath = otherCauseOfDeath, + birthDoseVaccinesGiven = birthDoseVaccinesGiven, + reasonForNoVaccines = reasonForNoVaccines, + vitaminKInjectionGiven = vitaminKInjectionGiven, + reasonForNoVitaminK = reasonForNoVitaminK, + birthCertificateIssued = birthCertificateIssued, + processed = "P", + createdBy = createdBy, + createdDate = getLongFromDate(createdDate), + updatedBy = updatedBy, + updatedDate = getLongFromDate(updatedDate), + syncState = SyncState.SYNCED + ) + } +} + +/** + * FLW API payload for infant registration sync. + * Uses beneficiary IDs (benId/childBenId) to align with server contract. + */ +data class InfantRegApiPost( + val id: Long = 0, + val benId: Long, + val childBenId: Long, + val isActive: Boolean, + val babyName: String? = null, + val babyIndex: Int, + val infantTerm: String? = null, + val corticosteroidGiven: String? = null, + val gender: String? = null, + val babyCriedAtBirth: Boolean? = null, + val resuscitation: Boolean? = null, + val referred: String? = null, + val hadBirthDefect: String? = null, + val birthDefect: String? = null, + val otherDefect: String? = null, + val weight: Double = 0.0, + val breastFeedingStarted: Boolean? = null, + val opv0Dose: String? = null, + val bcgDose: String? = null, + val hepBDose: String? = null, + val vitkDose: String? = null, + val deliveryDischargeSummary1: String? = null, + val deliveryDischargeSummary2: String? = null, + val deliveryDischargeSummary3: String? = null, + val deliveryDischargeSummary4: String? = null, + val isSNCU: String? = null, + val outcomeAtBirth: String? = null, + val typeOfResuscitation: String? = null, + val newbornComplications: String? = null, + val currentStatusOfBaby: String? = null, + val causeOfDeath: String? = null, + val otherCauseOfDeath: String? = null, + val birthDoseVaccinesGiven: String? = null, + val reasonForNoVaccines: String? = null, + val vitaminKInjectionGiven: Boolean? = null, + val reasonForNoVitaminK: String? = null, + val birthCertificateIssued: String? = null, + val createdDate: String? = null, + val createdBy: String? = null, + val updatedDate: String? = null, + val updatedBy: String? = null +) + +/** + * HWC Child API payload (`/child/saveAll`, `/child/getAll`). + */ +data class ChildApiPost( + val id: Long = 0, + val benId: Long, + val babyName: String? = null, + val infantTerm: String? = null, + val corticosteroidGiven: String? = null, + val gender: String? = null, + val babyCriedAtBirth: Boolean? = null, + val resuscitation: Boolean? = null, + val referred: String? = null, + val hadBirthDefect: String? = null, + val birthDefect: String? = null, + val otherDefect: String? = null, + val weight: Double = 0.0, + val breastFeedingStarted: Boolean? = null, + val opv0Dose: String? = null, + val bcgDose: String? = null, + val hepBDose: String? = null, + val vitkDose: String? = null, + val createdDate: String? = null, + val createdBy: String? = null, + val updatedDate: String? = null, + val updatedBy: String? = null +) + +fun getIsoDateTimeStringFromLong(dateLong: Long?): String? { + if (dateLong == null) return null + return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(dateLong) +} + +/** + * Infant Registration with Patient (mother and child) relation + * Used for Child Registration list + */ +data class InfantRegWithPatient( + @Embedded + val infant: InfantRegCache, + @Relation( + parentColumn = "motherPatientID", + entityColumn = "patientID" + ) + val motherPatient: Patient, + @Relation( + parentColumn = "childPatientID", + entityColumn = "patientID" + ) + val childPatient: Patient? +) { + fun asDomainModel(): ChildRegDomain { + return ChildRegDomain( + motherPatient = motherPatient, + infant = infant, + childPatient = childPatient + ) + } +} + +/** + * Domain model for displaying child registration list + */ +data class ChildRegDomain( + val motherPatient: Patient, + val infant: InfantRegCache, + val childPatient: Patient?, + val syncState: SyncState? = infant.syncState, + val displaySyncState: SyncState? = if (infant.processed == "C" || childPatient != null) infant.syncState else null +) { + /** + * Get formatted baby name (baby 0, baby 1, etc.) + */ + val customName: String + get() { + val childFullName = childPatient?.let { + "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() + }?.takeIf { it.isNotBlank() } + + return childFullName + ?: infant.babyName?.takeIf { it.isNotBlank() } + ?: formatBabyOrdinalName(motherPatient.firstName, infant.babyIndex) + } + + /** + * Get mother's full name + */ + fun getMotherFullName(): String { + return formatMotherFullName(motherPatient) + } + + /** + * Check if child patient is registered + */ + fun isChildRegistered(): Boolean = infant.processed == "C" || childPatient != null +} diff --git a/app/src/main/java/org/piramalswasthya/cho/model/InputType.kt b/app/src/main/java/org/piramalswasthya/cho/model/InputType.kt index 8dd9e0f13..2ae50cf7e 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/InputType.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/InputType.kt @@ -11,5 +11,8 @@ package org.piramalswasthya.cho.model CHECKBOXES, TIME_PICKER, HEADLINE, - AGE_PICKER + AGE_PICKER, + FILE_UPLOAD + + } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/LoginSettingsData.kt b/app/src/main/java/org/piramalswasthya/cho/model/LoginSettingsData.kt index 7ceca6248..636e49c31 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/LoginSettingsData.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/LoginSettingsData.kt @@ -30,4 +30,4 @@ data class LoginSettingsData( ) -data class LocationRequest(val vanID: Int, val spPSMID: String, val userID: Int) \ No newline at end of file +data class LocationRequest(val facilityID: Int, val spPSMID: String, val userID: Int) \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/MaternalHealth.kt b/app/src/main/java/org/piramalswasthya/cho/model/MaternalHealth.kt index 9267a8e72..280fbe48c 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/MaternalHealth.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/MaternalHealth.kt @@ -10,10 +10,14 @@ import org.piramalswasthya.cho.configuration.FormDataModel import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.helpers.Konstants import org.piramalswasthya.cho.helpers.getDateString +import org.piramalswasthya.cho.helpers.getCurrentWeeksOfPregnancy import org.piramalswasthya.cho.helpers.getTodayMillis import org.piramalswasthya.cho.helpers.getWeeksOfPregnancy +import android.content.Context import org.piramalswasthya.cho.network.getLongFromDate +import org.piramalswasthya.cho.utils.DateTimeUtil import org.piramalswasthya.cho.utils.HelperUtil.getDateStringFromLong +import org.piramalswasthya.cho.utils.ImgUtils import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -93,6 +97,8 @@ data class PregnantWomanRegistrationCache( val id: Long = 0, val patientID: String, var dateOfRegistration: Long = System.currentTimeMillis(), + var pregnancyTestAtFacility: String? = null, + var uptResult: String? = null, var mcpCardNumber: Long? = 0, var rchId: Long? = 0, var lmpDate: Long = 0, @@ -106,6 +112,8 @@ data class PregnantWomanRegistrationCache( var vdrlRprTestResult: String? = null, var vdrlRprTestResultId: Int = 0, var dateOfVdrlRprTest: Long? = null, + var historyOfAbortions: Boolean? = null, + var previousLSCS: Boolean? = null, var hivTestResult: String? = null, var hivTestResultId: Int = 0, @@ -119,6 +127,7 @@ data class PregnantWomanRegistrationCache( var otherPastIllness: String? = null, var is1st: Boolean = true, var numPrevPregnancy: Int? = null, + var para: Int? = null, var complicationPrevPregnancy: String? = null, var complicationPrevPregnancyId: Int? = null, var otherComplication: String? = null, @@ -134,6 +143,7 @@ data class PregnantWomanRegistrationCache( var createdDate: Long = System.currentTimeMillis(), var updatedBy: String, var updatedDate: Long = System.currentTimeMillis(), + var isFirstAncSubmitted: Boolean = false, var syncState: SyncState ) : FormDataModel { @@ -183,55 +193,351 @@ data class PregnantWomanRegistrationCache( } } -//data class BenWithPwrCache( -// @Embedded -// val ben: BenBasicCache, -// @Relation( -// parentColumn = "benId", entityColumn = "benId" -// ) -// val pwr: List, -// -// ) { -// fun asPwrDomainModel(): BenWithPwrDomain { -// return BenWithPwrDomain( -// ben = ben.asBasicDomainModel(), -// pwr = pwr.firstOrNull { it.active } -// ) -// } -// -// fun asBenBasicDomainModelForHRPPregAssessmentForm(): BenBasicDomainForForm { -// -// return BenBasicDomainForForm( -// benId = ben.benId, -// hhId = ben.hhId, -// regDate = BenBasicCache.dateFormat.format(Date(ben.regDate)), -// benName = ben.benName, -// benSurname = ben.benSurname ?: "", -// gender = ben.gender.name, -// dob = ben.dob, -// mobileNo = ben.mobileNo.toString(), -// fatherName = ben.fatherName, -// familyHeadName = ben.familyHeadName ?: "", -// spouseName = ben.spouseName ?: "", -// lastMenstrualPeriod = getDateStringFromLong(ben.lastMenstrualPeriod), -// edd = getEddFromLmp(ben.lastMenstrualPeriod), -//// typeOfList = typeOfList.name, -// rchId = ben.rchId ?: "Not Available", -// hrpStatus = ben.hrpStatus, -// form1Filled = ben.hrppaFilled, -// syncState = ben.hrppaSyncState, -// form2Enabled = true, -// form2Filled = ben.hrpmbpFilled -// ) -// } -// -//} +/** + * Patient with Pregnant Woman Registration data. + * Relation is a list; the active/most-recent record is selected when building the domain. + */ +data class PatientWithPwrCache( + @Embedded + val patient: Patient, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val pwr: List, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val ancRecords: List +) { + /** + * Active PWR record, or if none active the most recent by createdDate. + */ + fun getActiveOrLatestPwr(): PregnantWomanRegistrationCache? = + pwr.filter { it.active }.maxByOrNull { it.createdDate } + ?: pwr.maxByOrNull { it.createdDate } -//data class BenWithPwrDomain( -//// val benId: Long, -// val ben: BenBasicDomain, -// val pwr: PregnantWomanRegistrationCache? -//) + fun asDomainModel(): PatientWithPwrDomain { + return PatientWithPwrDomain( + patient = patient, + pwr = getActiveOrLatestPwr(), + ancRecords = ancRecords + ) + } +} + +/** + * Domain model for displaying patient with pregnancy registration + */ +data class PatientWithPwrDomain( + val patient: Patient, + val pwr: PregnantWomanRegistrationCache?, + val ancRecords: List = emptyList(), + val syncState: SyncState? = pwr?.syncState, + val lastAnc: PregnantWomanAncCache? = ancRecords.maxByOrNull { it.visitNumber }, + var deliveryOutcomeSyncState: SyncState? = null +) { + /** + * Get weeks of pregnancy + * Returns 0 if LMP date is missing or invalid (null or <= 0) + */ + fun getWeeksOfPregnancy(): Int { + return if (pwr?.lmpDate != null && pwr.lmpDate > 0L) { + getCurrentWeeksOfPregnancy(pwr.lmpDate) + } else { + 0 + } + } + + /** + * Get EDD (Expected Date of Delivery) - LMP + 280 days + * Returns 0L if LMP date is missing or invalid (null or <= 0) + */ + fun getEDD(): Long { + return if (pwr?.lmpDate != null && pwr.lmpDate > 0L) { + pwr.lmpDate + TimeUnit.DAYS.toMillis(280) + } else { + 0L + } + } + + /** + * Get formatted LMP date string + * Returns "NA" if LMP date is missing or invalid (null or <= 0) + */ + fun getFormattedLMPDate(): String { + return if (pwr?.lmpDate != null && pwr.lmpDate > 0L) { + getDateStringFromLong(pwr.lmpDate) ?: "NA" + } else { + "NA" + } + } + + /** + * Get formatted EDD string + * Returns "NA" if LMP date is missing or invalid (null or <= 0) + */ + fun getFormattedEDD(): String { + val edd = getEDD() + return if (edd > 0L) { + getDateStringFromLong(edd) ?: "NA" + } else { + "NA" + } + } + + /** + * Get patient's age string for display (e.g. "29 YEARS" or "NA"). + */ + fun getAgeString(): String { + return patient.dob?.let { DateTimeUtil.calculateAgeString(it) } ?: "NA" + } + + /** + * Indicates if the Delivery Outcome form has been filled and submitted for the patient. + * Default is false, but gets assigned when cross-referenced against the outcomes repo. + */ + var isDeliveryOutcomeFilled: Boolean = false + + /** + * Check if pregnancy is active + */ + fun isActive(): Boolean { + return pwr?.active ?: false + } + + /** + * Check if this is high-risk pregnancy + */ + fun isHighRisk(): Boolean { + return pwr?.isHrp ?: false + } +} + +private fun List.resolvePregnancyLmpDate( + fallbackLmp: Long? = null +): Long = + filter { it.active && it.lmpDate > 0L }.maxByOrNull { it.createdDate }?.lmpDate + ?: filter { it.lmpDate > 0L }.maxByOrNull { it.createdDate }?.lmpDate + ?: fallbackLmp?.takeIf { it > 0 } + ?: 0L + +private fun resolveAbortionLmpDate( + pwrList: List, + abortionRecord: PregnantWomanAncCache? +): Long = pwrList.resolvePregnancyLmpDate(abortionRecord?.lmpDate) + +/** + * Patient with PWR and ANC records (for abortion list). + * PWR is a list; active and latest-by-createdDate are selected when building the domain. + */ +data class PatientWithPwrAndAncCache( + @Embedded + val patient: Patient, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val pwr: List, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val ancRecords: List +) { + fun asAbortionDomainModel(): AbortionDomain { + val abortionRecord = ancRecords.firstOrNull { it.isAborted && it.abortionDate != null } + val activePwr = pwr.filter { it.active }.maxByOrNull { it.createdDate } + + val lmpDateToUse = resolveAbortionLmpDate(pwr, abortionRecord) + + val eddDateToUse = if (lmpDateToUse != 0L) { + lmpDateToUse + TimeUnit.DAYS.toMillis(280) + } else 0L + + // Same current GA as ANC visit list (today vs LMP), not GA at abortion/visit date. + val weekOfPregnancyToUse = if (lmpDateToUse != 0L) { + getCurrentWeeksOfPregnancy(lmpDateToUse).takeIf { it in 1..40 } + } else null + + return AbortionDomain( + patient = patient, + pwr = activePwr, + abortionRecord = abortionRecord, + lmpDate = lmpDateToUse, + eddDate = eddDateToUse, + weekOfPregnancy = weekOfPregnancyToUse, + abortionDate = abortionRecord?.abortionDate + ) + } +} + +/** + * Domain model for displaying abortion list + */ +data class AbortionDomain( + val patient: Patient, + val pwr: PregnantWomanRegistrationCache?, + val abortionRecord: PregnantWomanAncCache?, + val lmpDate: Long, + val eddDate: Long, + val weekOfPregnancy: Int?, + val abortionDate: Long?, + val syncState: SyncState? = abortionRecord?.syncState +) { + /** + * Get formatted LMP date string + */ + fun getFormattedLMPDate(): String { + return if (lmpDate != 0L) { + getDateStringFromLong(lmpDate) ?: "NA" + } else "NA" + } + + /** + * Get formatted EDD string + */ + fun getFormattedEDD(): String { + return if (eddDate != 0L) { + getDateStringFromLong(eddDate) ?: "NA" + } else "NA" + } + + /** + * Get formatted abortion date string + */ + fun getFormattedAbortionDate(): String { + return abortionDate?.let { getDateStringFromLong(it) ?: "NA" } ?: "NA" + } + + /** + * Get weeks of pregnancy string + */ + fun getWeeksOfPregnancyString(): String { + return weekOfPregnancy?.takeIf { it <= 40 }?.toString() ?: "NA" + } + + /** + * Get age string from patient DOB for display (e.g. "25 YEARS" or "NA"). + */ + fun getAgeString(): String { + return patient.dob?.let { DateTimeUtil.calculateAgeString(it) } ?: "NA" + } + + /** + * Check if the dedicated abortion form has been submitted. abortionType and + * abortionFacility get set during the ANC visit when the pregnancy outcome + * is marked aborted, so they cannot indicate this form's submission state. + * methodOfTermination and terminationDoneBy are required fields on this + * form and are only written by PregnantWomanAncAbortionDataset.mapValues. + */ + val isAbortionFormFilled: Boolean + get() = abortionRecord?.let { + it.methodOfTermination != null && it.terminationDoneBy != null + } ?: false +} + +/** + * Patient with PWR for PMSMA list + * Shows women registered for pregnancy (eligible for PMSMA) + */ +data class PatientWithPwrForPmsmaCache( + @Embedded + val patient: Patient, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val pwr: PregnantWomanRegistrationCache? +) { + fun asPmsmaDomainModel(): PmsmaDomain { + val activePwr = pwr?.takeIf { it.active } + + val lmpDateToUse = activePwr?.lmpDate ?: 0L + val eddDateToUse = if (lmpDateToUse != 0L) { + lmpDateToUse + TimeUnit.DAYS.toMillis(280) + } else 0L + + val weekOfPregnancyToUse = if (lmpDateToUse != 0L) { + (TimeUnit.MILLISECONDS.toDays(getTodayMillis() - lmpDateToUse) / 7).toInt() + } else null + + return PmsmaDomain( + patient = patient, + pwr = activePwr, + lmpDate = lmpDateToUse, + eddDate = eddDateToUse, + weekOfPregnancy = weekOfPregnancyToUse + ) + } +} + +/** + * Domain model for displaying PMSMA list + */ +data class PmsmaDomain( + val patient: Patient, + val pwr: PregnantWomanRegistrationCache?, + val lmpDate: Long, + val eddDate: Long, + val weekOfPregnancy: Int? +) { + /** + * Get formatted LMP date string + */ + fun getFormattedLMPDate(): String { + return if (lmpDate != 0L) { + getDateStringFromLong(lmpDate) ?: "NA" + } else "NA" + } + + /** + * Get formatted EDD string + */ + fun getFormattedEDD(): String { + return if (eddDate != 0L) { + getDateStringFromLong(eddDate) ?: "NA" + } else "NA" + } + + /** + * Get weeks of pregnancy string + */ + fun getWeeksOfPregnancyString(): String { + return weekOfPregnancy?.takeIf { it <= 40 }?.toString() ?: "NA" + } + + /** + * Get patient's age string for display (e.g. "30 YEARS" or "NA"). + */ + fun getAgeString(): String { + return patient.dob?.let { DateTimeUtil.calculateAgeString(it) } ?: "NA" + } +} + +/** + * Build a PmsmaDomain from the ANC-list source shape (PatientWithPwrCache). + * Mirrors PatientWithPwrForPmsmaCache.asPmsmaDomainModel so the e-PMSMA list can + * derive from the same Flow used by ANCVisitsFragment / getANCCount. + */ +fun PatientWithPwrCache.asPmsmaDomainModel(): PmsmaDomain { + val activePwr = getActiveOrLatestPwr()?.takeIf { it.active } + val lmpDateToUse = activePwr?.lmpDate ?: 0L + val eddDateToUse = + if (lmpDateToUse != 0L) lmpDateToUse + TimeUnit.DAYS.toMillis(280) else 0L + val weekOfPregnancyToUse = + if (lmpDateToUse != 0L) + (TimeUnit.MILLISECONDS.toDays(getTodayMillis() - lmpDateToUse) / 7).toInt() + else null + return PmsmaDomain( + patient = patient, + pwr = activePwr, + lmpDate = lmpDateToUse, + eddDate = eddDateToUse, + weekOfPregnancy = weekOfPregnancyToUse + ) +} data class PwrPost( val id: Long = 0, @@ -333,6 +639,30 @@ data class PregnantWomanAncCache( var visitNumber: Int, var isActive: Boolean = true, var ancDate: Long = System.currentTimeMillis(), + var lmpDate: Long? = null, + var pregnancyTestAtFacility: Boolean? = null, + var uptResult: String? = null, + var uptResultId: Int = -1, + + var visitDate: Long? = null, + var weekOfPregnancy: Int? = null, + + var serialNo: String? = null, + var methodOfTermination: String? = null, + var methodOfTerminationId: Int? = 0, + var terminationDoneBy: String? = null, + var terminationDoneById: Int? = 0, + var isPaiucdId: Int? = 0, + var isYesOrNo: Boolean? = false, + var isPaiucd: String? = null, + var dateSterilisation: Long? = null, + var remarks: String? = null, + var abortionImg1: String? = null, + var abortionImg2: String? = null, + var placeOfDeath: String? = null, + var placeOfDeathId: Int? = 0, + var otherPlaceOfDeath: String? = null, + var isAborted: Boolean = false, var abortionType: String? = null, var abortionTypeId: Int = 0, @@ -340,18 +670,36 @@ data class PregnantWomanAncCache( var abortionFacilityId: Int = 0, var abortionDate: Long? = null, var weight: Int? = null, + var height: Int? = null, + val gravida: Int? = null, + val para: Int? = null, + val historyOfAbortions: Boolean? = null, + val previousLSCS: Boolean? = null, + val complicationsPrevPregnancy: String? = null, + val pastIllness: String? = null, + val vdrlRprTestResult: String? = null, + val vdrlRprTestResultId: Int? = null, + val dateOfVdrlRprTest: Long? = null, + val hivTestResult: String? = null, + val hivTestResultId: Int? = null, + val dateOfHivTest: Long? = null, + val hbsAgTestResult: String? = null, + val hbsAgTestResultId: Int? = null, + val dateOfHbsAgTest: Long? = null, + var numPrevPregnancy: Int? = null, + var otherComplication: String? = null, var bpSystolic: Int? = null, var bpDiastolic: Int? = null, var pulseRate: String? = null, var hb: Double? = null, var fundalHeight: Int? = null, + var anyHighRisk: Boolean? = null, var urineAlbumin: String? = null, var urineAlbuminId: Int = 0, var randomBloodSugarTest: String? = null, var randomBloodSugarTestId: Int = 0, var numFolicAcidTabGiven: Int = 0, var numIfaAcidTabGiven: Int = 0, - var anyHighRisk: Boolean? = null, var highRisk: String? = null, var highRiskId: Int = 0, var otherHighRisk: String? = null, @@ -371,12 +719,39 @@ data class PregnantWomanAncCache( val createdDate: Long = System.currentTimeMillis(), var updatedBy: String, var updatedDate: Long = System.currentTimeMillis(), - var syncState: SyncState + var isFirstAncSubmitted: Boolean = false, + var syncState: SyncState, + var frontFilePath : String? = null, + var backFilePath : String? = null, + + var bloodSugarFasting: Int? = null, // Blood Sugar (Fasting) mg/dL + var urineSugar: String? = null, // Urine Sugar dropdown value + var urineSugarId: Int = 0, // Urine Sugar dropdown ID + var fetalHeartRate: Double? = null, // FHR in bpm + var calciumGiven: Int = 0, // Calcium tablets given + var dangerSigns: String? = null, // Danger Signs dropdown value + var dangerSignsId: Int = 0, // Danger Signs dropdown ID + var counsellingProvided: Boolean? = null, // Counselling Yes/No + var counsellingTopics: String? = null, // Counselling Topics value + var counsellingTopicsId: Int = 0, // Counselling Topics ID + var nextAncVisitDate: Long? = null // Next ANC Visit Date ) : FormDataModel { - fun asPostModel(benId: Long): ANCPost { + fun asPostModel( + benId: Long, + benRegId: Long? = null, + providerServiceMapID: Int? = null, + context: Context? = null, + ): ANCPost { + // abortionImg1/2 hold an internal-storage file path. The server expects + // base64 — encode here, lazily, so the DB row stays small. When no + // context is supplied (callers that aren't uploading), pass through as-is. + val img1ForUpload = context?.let { ImgUtils.encodeLocalImageValueForUpload(it, abortionImg1) } ?: abortionImg1 + val img2ForUpload = context?.let { ImgUtils.encodeLocalImageValueForUpload(it, abortionImg2) } ?: abortionImg2 return ANCPost( benId = benId, + benRedId = benRegId, ancDate = getDateStringFromLong(ancDate), + visitDate = getDateStringFromLong(visitDate ?: ancDate), isActive = true, ancVisit = visitNumber, isAborted = isAborted, @@ -384,12 +759,18 @@ data class PregnantWomanAncCache( abortionFacility = abortionFacility, abortionDate = abortionDate?.let { getDateStringFromLong(it) }, weightOfPW = weight, + pregnancyTestAtFacility = pregnancyTestAtFacility, + uptResult = uptResult, bpSystolic = bpSystolic, bpDiastolic = bpDiastolic, pulseRate = pulseRate?.toInt(), hb = hb, fundalHeight = fundalHeight, - urineAlbuminPresent = urineAlbumin == "Present", + urineAlbuminPresent = when (urineAlbumin) { + null, "Negative", "Trace", "Absent" -> false + "Present", "+", "++", "+++" -> true + else -> null + }, bloodSugarTestDone = randomBloodSugarTest == "Done", folicAcidTabs = numFolicAcidTabGiven, ifaTabs = numIfaAcidTabGiven, @@ -407,7 +788,29 @@ data class PregnantWomanAncCache( createdDate = getDateStringFromLong(createdDate), createdBy = createdBy, updatedDate = getDateStringFromLong(updatedDate), - updatedBy = updatedBy + updatedBy = updatedBy, + providerServiceMapID = providerServiceMapID, + filePath = frontFilePath, + serialNo = serialNo, + methodOfTermination = methodOfTermination, + methodOfTerminationId = methodOfTerminationId, + terminationDoneBy = terminationDoneBy, + terminationDoneById = terminationDoneById, + isPaiucdId = isPaiucdId, + isPaiucd = isPaiucd, + remarks = remarks, + abortionImg1 = img1ForUpload, + abortionImg2 = img2ForUpload, + dateSterilisation = dateSterilisation?.let { getDateStringFromLong(it) }, + isYesOrNo = isYesOrNo +// bloodSugarFasting = bloodSugarFasting, +// urineSugar = urineSugar, +// fetalHeartRate = fetalHeartRate, +// calciumGiven = calciumGiven ?: 0, +// dangerSigns = dangerSigns, +// counsellingProvided = counsellingProvided, +// counsellingTopics = counsellingTopics, +// nextAncVisitDate = nextAncVisitDate?.let { getDateStringFromLong(it) } ) } } @@ -415,9 +818,13 @@ data class PregnantWomanAncCache( data class ANCPost( val id: Long = 0, val benId: Long = 0, + val benRedId: Long? = null, val ancDate: String? = null, + val visitDate: String? = null, val isActive: Boolean, val ancVisit: Int, + val pregnancyTestAtFacility: Boolean? = null, + val uptResult: String? = null, val isAborted: Boolean = false, val abortionType: String? = null, val abortionFacility: String? = null, @@ -447,32 +854,97 @@ data class ANCPost( val createdDate: String? = null, val createdBy: String, val updatedDate: String? = null, - val updatedBy: String + val updatedBy: String, + val lmpDate: String? = null, + val providerServiceMapID: Int? = null, + val filePath: String? = null, + val serialNo: String? = null, + val methodOfTermination: String? = null, + val methodOfTerminationId: Int? = null, + val terminationDoneBy: String? = null, + val terminationDoneById: Int? = null, + val isPaiucdId: Int? = null, + val isPaiucd: String? = null, + val remarks: String? = null, + val abortionImg1: String? = null, + val abortionImg2: String? = null, + val placeOfDeath: String? = null, + val placeOfDeathId: Int? = null, + val otherPlaceOfDeath: String? = null, + val dateSterilisation: String? = null, + val isYesOrNo: Boolean? = null, + val placeOfAnc: String? = null, + val placeOfAncId: Int? = null, + // New ANC fields +// val bloodSugarFasting: Int? = null, +// val urineSugar: String? = null, +// val fetalHeartRate: Double? = null, +// val calciumGiven: Int? = null, +// val dangerSigns: String? = null, +// val counsellingProvided: Boolean? = null, +// val counsellingTopics: String? = null, +// val nextAncVisitDate: String? = null ) { fun toAncCache(): PregnantWomanAncCache { + val resolvedAbortionType = abortionType ?: methodOfTermination + val resolvedAbortionFacility = abortionFacility ?: terminationDoneBy + return PregnantWomanAncCache( id = id, patientID = "", visitNumber = ancVisit, - ancDate = getLongFromDate(ancDate), + ancDate = getLongFromDate(ancDate ?: visitDate), + pregnancyTestAtFacility = pregnancyTestAtFacility, + uptResult = uptResult, + uptResultId = when(uptResult) { + "Positive" -> 0 + "Negative" -> 1 + else -> -1 + }, isAborted = isAborted, - abortionType = abortionType, -// abortionTypeId = - abortionFacility = abortionFacility, -// abortionFacilityId + abortionType = resolvedAbortionType, + abortionTypeId = when(resolvedAbortionType) { + "Induced" -> 0 + "Spontaneous" -> 1 + else -> methodOfTerminationId ?: -1 + }.let { if (it < 0) 0 else it }, + abortionFacility = resolvedAbortionFacility, + abortionFacilityId = when(resolvedAbortionFacility) { + "Govt. Hospital" -> 0 + "Pvt. Hospital" -> 1 + else -> terminationDoneById ?: -1 + }.let { if (it < 0) 0 else it }, abortionDate = getLongFromDate(abortionDate), weight = weightOfPW, bpSystolic = bpSystolic, bpDiastolic = bpDiastolic, - pulseRate = pulseRate.toString(), + pulseRate = pulseRate?.toString(), hb = hb, fundalHeight = fundalHeight, - urineAlbumin = if (urineAlbuminPresent == true) "Present" else "Absent", + urineAlbumin = when (urineAlbuminPresent) { + true -> "+" + false -> "Negative" + null -> null + }, // urineAlbuminId randomBloodSugarTest = if (bloodSugarTestDone == true) "Done" else "Not Done", // randomBloodSugarTestId numFolicAcidTabGiven = folicAcidTabs, numIfaAcidTabGiven = ifaTabs, + serialNo = serialNo, + methodOfTermination = methodOfTermination, + methodOfTerminationId = methodOfTerminationId ?: 0, + terminationDoneBy = terminationDoneBy, + terminationDoneById = terminationDoneById ?: 0, + isPaiucdId = isPaiucdId ?: 0, + isPaiucd = isPaiucd, + remarks = remarks, + visitDate = getLongFromDate(visitDate).takeIf { it > 0L } + ?: getLongFromDate(ancDate ?: visitDate), + isYesOrNo = isYesOrNo, + abortionImg1 = abortionImg1, + abortionImg2 = abortionImg2, + dateSterilisation = getLongFromDate(dateSterilisation), anyHighRisk = isHighRisk, highRisk = highRiskCondition, // highRiskId @@ -493,7 +965,18 @@ data class ANCPost( createdDate = getLongFromDate(createdDate), updatedBy = updatedBy, updatedDate = getLongFromDate(updatedDate), - syncState = SyncState.SYNCED + syncState = SyncState.SYNCED, + frontFilePath = filePath, + backFilePath = null, + // Map new ANC fields +// bloodSugarFasting = bloodSugarFasting, +// urineSugar = urineSugar, +// fetalHeartRate = fetalHeartRate, +// calciumGiven = calciumGiven ?: 0, +// dangerSigns = dangerSigns, +// counsellingProvided = counsellingProvided, +// counsellingTopics = counsellingTopics, +// nextAncVisitDate = getLongFromDate(nextAncVisitDate) ) } } @@ -579,4 +1062,40 @@ data class ANCPost( // val hasPmsma: Boolean, // val showViewAnc: Boolean = anc.isEmpty(), // val syncState: SyncState? -//) \ No newline at end of file +//) + +data class AncDueListItem( + val patientID: String, + val gestationalAgeWeeks: Int, + val ancStage: Int +) + +data class AncCompletedListItem( + val patientID: String, + val ancStage: Int, + val visitNumber: Int +) + + +@Entity( + tableName = "ASHA_DUE_LIST", + foreignKeys = [ForeignKey( + entity = Patient::class, + parentColumns = arrayOf("patientID"), + childColumns = arrayOf("patientID"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(name = "ind_asha_due", value = ["patientID", "listType"], unique = true)] +) +data class AshaDueListCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val patientID: String, + val beneficiaryID: Long? = null, + val listType: String = "ANC", + val addedDate: Long = System.currentTimeMillis(), + val ashaId: Int = 0, + val createdBy: String, + var syncState: SyncState = SyncState.UNSYNCED +) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/MentalHealthScreeningCache.kt b/app/src/main/java/org/piramalswasthya/cho/model/MentalHealthScreeningCache.kt new file mode 100644 index 000000000..798df822f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/MentalHealthScreeningCache.kt @@ -0,0 +1,269 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import androidx.room.Index +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity( + tableName = "MENTAL_HEALTH_SCREENING", + indices = [ + Index(value = ["patient_id"], name = "index_mhs_patient_id"), + Index(value = ["patient_id", "ben_visit_no"], name = "index_mhs_patient_visit") + ] +) +@JsonClass(generateAdapter = true) +data class MentalHealthScreeningCache( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "screening_id") + val screeningId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientId: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int?, + + + @ColumnInfo(name = "emotional_behavioural_concerns") + var emotionalBehaviouralConcerns: Boolean? = null, + + + @ColumnInfo(name = "substance_use_concerns") + var substanceUseConcerns: Boolean? = null, + + + @ColumnInfo(name = "self_harm_suicide_thoughts") + var selfHarmSuicideThoughts: Boolean? = null, + + + @ColumnInfo(name = "memory_loss_confusion") + var memoryLossConfusion: Boolean? = null, + + + @ColumnInfo(name = "seizures_fits_loc") + var seizuresFitsLoc: Boolean? = null, + + + @ColumnInfo(name = "is_postpartum") + var isPostpartum: Boolean? = null, + + @ColumnInfo(name = "phq9_little_interest") + var phq9LittleInterest: Int? = null, + + @ColumnInfo(name = "phq9_feeling_down") + var phq9FeelingDown: Int? = null, + + @ColumnInfo(name = "phq9_sleep_trouble") + var phq9SleepTrouble: Int? = null, + + @ColumnInfo(name = "phq9_feeling_tired") + var phq9FeelingTired: Int? = null, + + @ColumnInfo(name = "phq9_appetite") + var phq9Appetite: Int? = null, + + @ColumnInfo(name = "phq9_feeling_bad") + var phq9FeelingBad: Int? = null, + + @ColumnInfo(name = "phq9_concentration") + var phq9Concentration: Int? = null, + + @ColumnInfo(name = "phq9_moving_slowly") + var phq9MovingSlowly: Int? = null, + + @ColumnInfo(name = "phq9_self_harm_thoughts") + var phq9SelfHarmThoughts: Int? = null, + + @ColumnInfo(name = "phq9_total_score") + var phq9TotalScore: Int? = null, + + @ColumnInfo(name = "phq9_depression_severity") + var phq9DepressionSeverity: String? = null, + + @ColumnInfo(name = "phq9_system_action") + var phq9SystemAction: String? = null, + + @ColumnInfo(name = "substance_current_tobacco_use") + var substanceCurrentTobaccoUse: Boolean? = null, + + @ColumnInfo(name = "substance_tobacco_type") + var substanceTobaccoType: String? = null, + + @ColumnInfo(name = "substance_tobacco_frequency") + var substanceTobaccoFrequency: String? = null, + + @ColumnInfo(name = "substance_tobacco_outcome") + var substanceTobaccoOutcome: String? = null, + + @ColumnInfo(name = "substance_system_action") + var substanceSystemAction: String? = null, + + @ColumnInfo(name = "substance_alcohol_use") + var substanceAlcoholUse: Boolean? = null, + + @ColumnInfo(name = "substance_tobacco_use") + var substanceTobaccoUse: Boolean? = null, + + @ColumnInfo(name = "substance_other_use") + var substanceOtherUse: Boolean? = null, + + @ColumnInfo(name = "substance_other_specify") + var substanceOtherSpecify: String? = null, + + @ColumnInfo(name = "substance_frequency") + var substanceFrequency: String? = null, + + @ColumnInfo(name = "brief_intervention_given") + var briefInterventionGiven: Boolean? = null, + + + @ColumnInfo(name = "suicide_current_thoughts") + var suicideCurrentThoughts: Boolean? = null, + + @ColumnInfo(name = "suicide_plan") + var suicidePlan: Boolean? = null, + + @ColumnInfo(name = "suicide_previous_attempt") + var suicidePreviousAttempt: Boolean? = null, + + @ColumnInfo(name = "suicide_hopelessness") + var suicideHopelessness: Boolean? = null, + + @ColumnInfo(name = "suicide_immediate_assess") + var suicideImmediateAssess: Boolean? = null, + + @ColumnInfo(name = "suicide_risk_level") + var suicideRiskLevel: String? = null, + + + @ColumnInfo(name = "dementia_progressive_memory_loss") + var dementiaProgressiveMemoryLoss: Boolean? = null, + + @ColumnInfo(name = "dementia_forgetting_recent") + var dementiaForgettingRecent: Boolean? = null, + + @ColumnInfo(name = "dementia_disorientation") + var dementiaDisorientation: Boolean? = null, + + @ColumnInfo(name = "dementia_daily_activities") + var dementiaDailyActivities: Boolean? = null, + + @ColumnInfo(name = "dementia_behavioural_changes") + var dementiaBehaviouralChanges: Boolean? = null, + + @ColumnInfo(name = "epilepsy_recurrent_seizures") + var epilepsyRecurrentSeizures: Boolean? = null, + + @ColumnInfo(name = "epilepsy_jerky_movements") + var epilepsyJerkyMovements: Boolean? = null, + + @ColumnInfo(name = "epilepsy_tongue_bite") + var epilepsyTongueBite: Boolean? = null, + + @ColumnInfo(name = "epilepsy_confusion_after") + var epilepsyConfusionAfter: Boolean? = null, + + @ColumnInfo(name = "epilepsy_loc_duration") + var epilepsyLocDuration: String? = null, + + @ColumnInfo(name = "substance_alcohol_loss") + var substance_alcohol_loss: Boolean? = null, + + @ColumnInfo(name = "substance_alcohol_impact") + var substanceAlcoholImpact: Boolean? = null, + + @ColumnInfo(name = "substance_alcohol_withdrawal") + var substanceAlcoholWithdrawal: Boolean? = null, + + @ColumnInfo(name = "substance_alcohol_problematic") + var substanceAlcoholProblematic: Boolean? = null, + + @ColumnInfo(name = "substance_alcohol_classification") + var substanceAlcoholClassification: String? = null, + + @ColumnInfo(name = "substance_alcohol_system_action") + var substanceAlcoholSystemAction: String? = null, + + @ColumnInfo(name = "substance_alcohol_frequency") + var substance_alcohol_frequency: String? = null, + + @ColumnInfo(name = "edRecurrentEpisodeloss") + var edRecurrentEpisodeloss: Boolean? = null, + @ColumnInfo(name = "ed_recurrent_jerky_movements") + var edRecurrentJerkyMovements: Boolean? = null, + @ColumnInfo(name = "ed_confusion_ordrowsiness") + var edConfusionordrowsiness: Boolean? = null, + + @ColumnInfo(name = "ed_progressive_memory_loss") + var edProgressiveMemoryLoss: Boolean? = null, + + @ColumnInfo(name = "ed_confusion_disorientation") + var edConfusionDisorientation: Boolean? = null, + + @ColumnInfo(name = "ed_functional_decline") + var edFunctionalDecline: Boolean? = null, + + @ColumnInfo(name = "ed_screening_outcome") + var edScreeningOutcome: String? = null, + + @ColumnInfo(name = "ed_psychosocial_intervention_provided") + var edPsychosocialInterventionProvided: Boolean? = null, + + @ColumnInfo(name = "ed_intervention_type") + var edInterventionType: String? = null, + + @ColumnInfo(name = "ed_session_date") + var edSessionDate: String? = null, + + @ColumnInfo(name = "ed_duration_minutes") + var edDurationMinutes: Int? = null, + + @ColumnInfo(name = "ed_remarks") + var edRemarks: String? = null, + + @ColumnInfo(name = "ed_referral_required") + var edReferralRequired: String? = null, + + @ColumnInfo(name = "ed_reason") + var edReason: String? = null, + + @ColumnInfo(name = "referral_required") + var referralRequired: Boolean? = null, + + @ColumnInfo(name = "referral_level") + var referralLevel: String? = null, + + @ColumnInfo(name = "reason_for_referral") + var reasonForReferral: String? = null, + + @ColumnInfo(name = "referral_date") + var referralDate: String? = null, + + @ColumnInfo(name = "follow_up_required") + var followUpRequired: Boolean? = null, + + @ColumnInfo(name = "follow_up_date") + var followUpDate: String? = null, + + @ColumnInfo(name = "improvement_noted") + var improvementNoted: String? = null, + + @ColumnInfo(name = "adherence_to_advice") + var adherenceToAdvice: String? = null, + + @ColumnInfo(name = "referral_escalation_required") + var referralEscalationRequired: Boolean? = null, + + @ColumnInfo(name = "case_closure_reason") + var caseClosureReason: String? = null, + + + @ColumnInfo(name = "syncState") + var syncState: Int = 0, + +) : FormDataModel diff --git a/app/src/main/java/org/piramalswasthya/cho/model/NeonatalOutcome.kt b/app/src/main/java/org/piramalswasthya/cho/model/NeonatalOutcome.kt new file mode 100644 index 000000000..5be3776f1 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/NeonatalOutcome.kt @@ -0,0 +1,212 @@ +package org.piramalswasthya.cho.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import org.piramalswasthya.cho.configuration.FormDataModel +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.network.getLongFromDate +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * Neonatal Outcome entity to track detailed newborn health information + * Linked to DeliveryOutcomeCache via deliveryOutcomeId + * Supports multiple neonates per delivery via neonateIndex + */ +@Entity( + tableName = "NEONATAL_OUTCOME", + foreignKeys = [ForeignKey( + entity = DeliveryOutcomeCache::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("deliveryOutcomeId"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )], + indices = [Index(name = "neonatalOutcomeInd", value = ["deliveryOutcomeId"])] +) +data class NeonatalOutcomeCache( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** Foreign key to DELIVERY_OUTCOME table */ + val deliveryOutcomeId: Long, + + /** Index of this neonate (1 for 1st baby, 2 for 2nd baby in twins, etc.) */ + val neonateIndex: Int, + + /** Unique Neonate ID generated for tracking (optional, for frontend display) */ + var neonateUniqueId: String? = null, + + // Q1: Number of neonates is stored in delivery outcome (liveBirth + stillBirth) + + // Q2: Outcome at Birth + /** Live Birth, Still Birth (Macerated), Still Birth (Fresh), Died during delivery */ + var outcomeAtBirth: String? = null, + var outcomeAtBirthId: Int? = null, + + // Q3: Sex + /** Male, Female, Ambiguous */ + var sex: String? = null, + var sexId: Int? = null, + + // Q4: Cried immediately after birth? + /** Immediate cry, Cried after resuscitation, Not applicable (Stillbirth) */ + var criedImmediately: String? = null, + var criedImmediatelyId: Int? = null, + + // Q5: Type of resuscitation (multi-select, comma-separated) + /** Stimulation, Suctioning, Bag and mask ventilation, Oxygen, Intubation, Chest compressions, Medications */ + var typeOfResuscitation: String? = null, + + // Q6: Birth Weight (in grams) + var birthWeight: Int? = null, + + // Q7: Any congenital anomaly detected? + /** Yes, No, Suspected (under evaluation) */ + var congenitalAnomalyDetected: String? = null, + var congenitalAnomalyDetectedId: Int? = null, + + // Q8: Type of congenital anomaly (multi-select, comma-separated) + /** Neural tube defect, Cleft lip/palate, Club foot, Down syndrome, Congenital heart defect, etc. */ + var typeOfCongenitalAnomaly: String? = null, + + // Q9: Other congenital anomaly (if "Other" selected in Q8) + var otherCongenitalAnomaly: String? = null, + + // Q10: Newborn Complications (multi-select, comma-separated) + /** Birth asphyxia, Respiratory distress, Neonatal jaundice, Sepsis, Hypothermia, etc. */ + var newbornComplications: String? = null, + + // Q11: Current Status of Baby + /** Healthy and with mother, Admitted (SNCU/NICU), Admitted (General ward), Died */ + var currentStatusOfBaby: String? = null, + var currentStatusOfBabyId: Int? = null, + + // Q12: If baby died, cause of death (multi-select, comma-separated) + /** Birth asphyxia, Prematurity, Low birth weight complications, Sepsis, etc. */ + var causeOfDeath: String? = null, + + // Q13: Other cause of death (if "Other" selected in Q12) + var otherCauseOfDeath: String? = null, + + // Q14: Birth dose vaccines given (multi-select, comma-separated) + /** BCG, Hepatitis B (Birth dose), OPV-0, None */ + var birthDoseVaccinesGiven: String? = null, + + // Q15: Reason for not giving birth dose vaccines (if Q14 = None) + var reasonForNoVaccines: String? = null, + + // Q16: Vitamin K injection given? + /** Yes, No */ + var vitaminKInjectionGiven: Boolean? = null, + + // Q17: Reason for not giving Vitamin K injection (if Q16 = No) + var reasonForNoVitaminK: String? = null, + + // Q18: Birth Certificate issued? + /** Yes, In process, No (Not applied) */ + var birthCertificateIssued: String? = null, + var birthCertificateIssuedId: Int? = null, + + // Audit flags + /** True if stillbirth or died during delivery (triggers stillbirth audit) */ + var isStillbirth: Boolean? = null, + + /** True if baby died after birth (triggers neonatal death audit) */ + var isNeonatalDeath: Boolean? = null, + + // Sync and metadata fields + var processed: String? = "N", + var createdBy: String, + val createdDate: Long = System.currentTimeMillis(), + var updatedBy: String, + var updatedDate: Long = System.currentTimeMillis(), + var syncState: SyncState +) : FormDataModel { + + fun getDateStringFromLong(dateLong: Long?): String? { + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + return dateLong?.let { dateFormat.format(it) } + } + + fun asPostModel(benId: Long): NeonatalOutcomePost { + return NeonatalOutcomePost( + id = id, + benId = benId, + deliveryOutcomeId = deliveryOutcomeId, + neonateIndex = neonateIndex, + neonateUniqueId = neonateUniqueId, + outcomeAtBirth = outcomeAtBirth, + outcomeAtBirthId = outcomeAtBirthId, + sex = sex, + sexId = sexId, + criedImmediately = criedImmediately, + criedImmediatelyId = criedImmediatelyId, + typeOfResuscitation = typeOfResuscitation, + birthWeight = birthWeight, + congenitalAnomalyDetected = congenitalAnomalyDetected, + congenitalAnomalyDetectedId = congenitalAnomalyDetectedId, + typeOfCongenitalAnomaly = typeOfCongenitalAnomaly, + otherCongenitalAnomaly = otherCongenitalAnomaly, + newbornComplications = newbornComplications, + currentStatusOfBaby = currentStatusOfBaby, + currentStatusOfBabyId = currentStatusOfBabyId, + causeOfDeath = causeOfDeath, + otherCauseOfDeath = otherCauseOfDeath, + birthDoseVaccinesGiven = birthDoseVaccinesGiven, + reasonForNoVaccines = reasonForNoVaccines, + vitaminKInjectionGiven = vitaminKInjectionGiven, + reasonForNoVitaminK = reasonForNoVitaminK, + birthCertificateIssued = birthCertificateIssued, + birthCertificateIssuedId = birthCertificateIssuedId, + isStillbirth = isStillbirth, + isNeonatalDeath = isNeonatalDeath, + createdDate = getDateStringFromLong(createdDate), + createdBy = createdBy, + updatedDate = getDateStringFromLong(updatedDate), + updatedBy = updatedBy + ) + } +} + +/** + * Post model for syncing to server + */ +data class NeonatalOutcomePost( + val id: Long = 0, + val benId: Long, + val deliveryOutcomeId: Long, + val neonateIndex: Int, + val neonateUniqueId: String? = null, + val outcomeAtBirth: String? = null, + val outcomeAtBirthId: Int? = null, + val sex: String? = null, + val sexId: Int? = null, + val criedImmediately: String? = null, + val criedImmediatelyId: Int? = null, + val typeOfResuscitation: String? = null, + val birthWeight: Int? = null, + val congenitalAnomalyDetected: String? = null, + val congenitalAnomalyDetectedId: Int? = null, + val typeOfCongenitalAnomaly: String? = null, + val otherCongenitalAnomaly: String? = null, + val newbornComplications: String? = null, + val currentStatusOfBaby: String? = null, + val currentStatusOfBabyId: Int? = null, + val causeOfDeath: String? = null, + val otherCauseOfDeath: String? = null, + val birthDoseVaccinesGiven: String? = null, + val reasonForNoVaccines: String? = null, + val vitaminKInjectionGiven: Boolean? = null, + val reasonForNoVitaminK: String? = null, + val birthCertificateIssued: String? = null, + val birthCertificateIssuedId: Int? = null, + val isStillbirth: Boolean? = null, + val isNeonatalDeath: Boolean? = null, + val createdDate: String? = null, + val createdBy: String, + val updatedDate: String? = null, + val updatedBy: String +) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/NoseDiagnosisAssessment.kt b/app/src/main/java/org/piramalswasthya/cho/model/NoseDiagnosisAssessment.kt new file mode 100644 index 000000000..28f66cf17 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/NoseDiagnosisAssessment.kt @@ -0,0 +1,49 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity(tableName = "NOSE_DIAGNOSIS_ASSESSMENT") +@JsonClass(generateAdapter = true) +data class NoseDiagnosisAssessment( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "assessment_id") + val assessmentId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientId: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int?, + + @ColumnInfo(name = "difficulty_breathing") + var difficultyBreathing: Boolean? = null, + + @ColumnInfo(name = "open_mouth_breathing") + var openMouthBreathing: Boolean? = null, + + @ColumnInfo(name = "nose_bleed") + var noseBleed: Boolean? = null, + + @ColumnInfo(name = "systolic_bp") + var systolicBp: Int? = null, + + @ColumnInfo(name = "diastolic_bp") + var diastolicBp: Int? = null, + + @ColumnInfo(name = "foreign_body_nose") + var foreignBodyNose: String? = null, + + @ColumnInfo(name = "sinusitis") + var sinusitis: Boolean? = null, + + @ColumnInfo(name = "syncState") + var syncState: Int = 0 + + + +) : FormDataModel diff --git a/app/src/main/java/org/piramalswasthya/cho/model/OccupationMaster.kt b/app/src/main/java/org/piramalswasthya/cho/model/OccupationMaster.kt index 045109079..e577d3c0c 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/OccupationMaster.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/OccupationMaster.kt @@ -1,4 +1,4 @@ -package org.piramalswasthya.cho.moddel +package org.piramalswasthya.cho.model import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/app/src/main/java/org/piramalswasthya/cho/model/OphthalmicVisit.kt b/app/src/main/java/org/piramalswasthya/cho/model/OphthalmicVisit.kt new file mode 100644 index 000000000..b7d4320c6 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/OphthalmicVisit.kt @@ -0,0 +1,103 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.utils.generateUuid +import java.io.Serializable + +@Entity( + tableName = "OPHTHALMIC_VISIT", + foreignKeys = [ + ForeignKey( + entity = Patient::class, + parentColumns = ["patientID"], + childColumns = ["patientID"], + onDelete = ForeignKey.CASCADE + ) + ] +) +@JsonClass(generateAdapter = true) +data class OphthalmicVisit( + @PrimaryKey + val visitId: String = generateUuid(), + + @ColumnInfo(name = "patientID") + val patientID: String, + + @ColumnInfo(name = "benVisitNo") + val benVisitNo: Int, + + // Ticket 1: Diabetic Screening + @ColumnInfo(name = "isDiabetic") + var isDiabetic: Boolean? = null, + + @ColumnInfo(name = "screeningPerformed") + var screeningPerformed: Boolean? = null, + + @ColumnInfo(name = "visualAcuityChartUsed") + var visualAcuityChartUsed: String? = null, + + @ColumnInfo(name = "distVARight") + var distVARight: String? = null, + + @ColumnInfo(name = "distVALeft") + var distVALeft: String? = null, + + @ColumnInfo(name = "nearVA") + var nearVA: String? = null, + + // Ticket 4 & 5: Case Identification + @ColumnInfo(name = "caseIdConditions") + var caseIdConditions: String? = null, // JSON list of conditions + + // Ticket 6: Symptoms + @ColumnInfo(name = "cataractSymptoms") + var cataractSymptoms: Boolean? = null, + + @ColumnInfo(name = "glaucomaSymptoms") + var glaucomaSymptoms: Boolean? = null, + + @ColumnInfo(name = "diabeticRetinopathySymptoms") + var diabeticRetinopathySymptoms: Boolean? = null, + + @ColumnInfo(name = "presbyopiaSymptoms") + var presbyopiaSymptoms: Boolean? = null, + + @ColumnInfo(name = "trachomaStatus") + var trachomaStatus: String? = null, + + @ColumnInfo(name = "cornealDiseaseType") + var cornealDiseaseType: String? = null, + + @ColumnInfo(name = "vitaminADeficiency") + var vitaminADeficiency: Boolean? = null, + + // Ticket 8 & 9: Injury and Trauma + @ColumnInfo(name = "injuryType") + var injuryType: String? = null, // JSON list of injury types + + @ColumnInfo(name = "foreignBodyRemoval") + var foreignBodyRemoval: String? = null, + + @ColumnInfo(name = "chemicalExposure") + var chemicalExposure: Boolean? = null, + + // Audit Fields + @ColumnInfo(name = "createdBy") + var createdBy: String, + + @ColumnInfo(name = "createdDate") + var createdDate: Long, + + @ColumnInfo(name = "updatedBy") + var updatedBy: String, + + @ColumnInfo(name = "updatedDate") + var updatedDate: Long, + + @ColumnInfo(name = "syncState") + var syncState: Int +) : Serializable diff --git a/app/src/main/java/org/piramalswasthya/cho/model/OralHealth.kt b/app/src/main/java/org/piramalswasthya/cho/model/OralHealth.kt new file mode 100644 index 000000000..788713dbe --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/OralHealth.kt @@ -0,0 +1,71 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity( + tableName = "ORAL_HEALTH", + indices = [ + Index(value = ["patient_id"], name = "index_oral_health_patient_id"), + Index(value = ["patient_id", "ben_visit_no"], name = "index_oral_health_patient_visit", unique = true) + ] +) +@JsonClass(generateAdapter = true) +data class OralHealth( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "oral_health_id") + val oralHealthId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientID: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int?, + + // Ticket 1 + @ColumnInfo(name = "tooth_decay_present") + var toothDecayPresent: Boolean? = null, + + @ColumnInfo(name = "tooth_decay_symptoms") + var toothDecaySymptoms: String? = null, + + // Ticket 2 + @ColumnInfo(name = "gum_disease_present") + var gumDiseasePresent: Boolean? = null, + + @ColumnInfo(name = "gum_disease_symptoms") + var gumDiseaseSymptoms: String? = null, + + // Ticket 3 + @ColumnInfo(name = "irregular_teeth_jaws") + var irregularTeethJaws: Boolean? = null, + + @ColumnInfo(name = "abnormal_growth_ulcer") + var abnormalGrowthUlcer: Boolean? = null, + + @ColumnInfo(name = "cleft_lip_palate") + var cleftLipPalate: Boolean? = null, + + @ColumnInfo(name = "dental_fluorosis") + var dentalFluorosis: Boolean? = null, + + // Ticket 4 + @ColumnInfo(name = "dental_emergency") + var dentalEmergency: String? = null, + + @ColumnInfo(name = "created_date") + var createdDate: Long? = null, + + @ColumnInfo(name = "created_by") + var createdBy: String? = null, + + @ColumnInfo(name = "syncState") + var syncState: Int = 0, + +) : FormDataModel + diff --git a/app/src/main/java/org/piramalswasthya/cho/model/OtherCphcMentalHealthNetworkMapper.kt b/app/src/main/java/org/piramalswasthya/cho/model/OtherCphcMentalHealthNetworkMapper.kt new file mode 100644 index 000000000..bd16f42a3 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/OtherCphcMentalHealthNetworkMapper.kt @@ -0,0 +1,180 @@ +package org.piramalswasthya.cho.model + +import org.piramalswasthya.cho.database.room.SyncState + +internal fun MentalHealthScreeningCache.toMentalHealthNetworkModelInternal( + beneficiaryID: String, + beneficiaryRegID: String +): MentalHealthNetwork { + return MentalHealthNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + screeningId = screeningId, + patientID = patientId, + benVisitNo = benVisitNo, + emotionalBehaviouralConcerns = emotionalBehaviouralConcerns, + substanceUseConcerns = substanceUseConcerns, + selfHarmSuicideThoughts = selfHarmSuicideThoughts, + memoryLossConfusion = memoryLossConfusion, + seizuresFitsLoc = seizuresFitsLoc, + isPostpartum = isPostpartum, + phq9LittleInterest = phq9LittleInterest, + phq9FeelingDown = phq9FeelingDown, + phq9SleepTrouble = phq9SleepTrouble, + phq9FeelingTired = phq9FeelingTired, + phq9Appetite = phq9Appetite, + phq9FeelingBad = phq9FeelingBad, + phq9Concentration = phq9Concentration, + phq9MovingSlowly = phq9MovingSlowly, + phq9SelfHarmThoughts = phq9SelfHarmThoughts, + phq9TotalScore = phq9TotalScore, + phq9DepressionSeverity = phq9DepressionSeverity, + phq9SystemAction = phq9SystemAction, + substanceCurrentTobaccoUse = substanceCurrentTobaccoUse, + substanceTobaccoType = substanceTobaccoType, + substanceTobaccoFrequency = substanceTobaccoFrequency, + substanceTobaccoOutcome = substanceTobaccoOutcome, + substanceSystemAction = substanceSystemAction, + substanceAlcoholUse = substanceAlcoholUse, + substanceTobaccoUse = substanceTobaccoUse, + substanceOtherUse = substanceOtherUse, + substanceOtherSpecify = substanceOtherSpecify, + substanceFrequency = substanceFrequency, + briefInterventionGiven = briefInterventionGiven, + suicideCurrentThoughts = suicideCurrentThoughts, + suicidePlan = suicidePlan, + suicidePreviousAttempt = suicidePreviousAttempt, + suicideHopelessness = suicideHopelessness, + suicideImmediateAssess = suicideImmediateAssess, + suicideRiskLevel = suicideRiskLevel, + dementiaProgressiveMemoryLoss = dementiaProgressiveMemoryLoss, + dementiaForgettingRecent = dementiaForgettingRecent, + dementiaDisorientation = dementiaDisorientation, + dementiaDailyActivities = dementiaDailyActivities, + dementiaBehaviouralChanges = dementiaBehaviouralChanges, + epilepsyRecurrentSeizures = epilepsyRecurrentSeizures, + epilepsyJerkyMovements = epilepsyJerkyMovements, + epilepsyTongueBite = epilepsyTongueBite, + epilepsyConfusionAfter = epilepsyConfusionAfter, + epilepsyLocDuration = epilepsyLocDuration, + substanceAlcoholLoss = substance_alcohol_loss, + substanceAlcoholImpact = substanceAlcoholImpact, + substanceAlcoholWithdrawal = substanceAlcoholWithdrawal, + substanceAlcoholProblematic = substanceAlcoholProblematic, + substanceAlcoholClassification = substanceAlcoholClassification, + substanceAlcoholSystemAction = substanceAlcoholSystemAction, + substanceAlcoholFrequency = substance_alcohol_frequency, + edRecurrentEpisodeloss = edRecurrentEpisodeloss, + edRecurrentJerkyMovements = edRecurrentJerkyMovements, + edConfusionordrowsiness = edConfusionordrowsiness, + edProgressiveMemoryLoss = edProgressiveMemoryLoss, + edConfusionDisorientation = edConfusionDisorientation, + edFunctionalDecline = edFunctionalDecline, + edScreeningOutcome = edScreeningOutcome, + edPsychosocialInterventionProvided = edPsychosocialInterventionProvided, + edInterventionType = edInterventionType, + edSessionDate = edSessionDate, + edDurationMinutes = edDurationMinutes, + edRemarks = edRemarks, + edReferralRequired = edReferralRequired, + edReason = edReason, + referralRequired = referralRequired, + referralLevel = referralLevel, + reasonForReferral = reasonForReferral, + referralDate = referralDate, + followUpRequired = followUpRequired, + followUpDate = followUpDate, + improvementNoted = improvementNoted, + adherenceToAdvice = adherenceToAdvice, + referralEscalationRequired = referralEscalationRequired, + caseClosureReason = caseClosureReason, + syncState = syncState + ) +} + +internal fun MentalHealthNetwork.toMentalHealthCacheModelInternal( + patientID: String +): MentalHealthScreeningCache { + return MentalHealthScreeningCache( + screeningId = 0L, + patientId = patientID, + syncState = syncState ?: SyncState.SYNCED.ordinal, + caseClosureReason = caseClosureReason, + referralEscalationRequired = referralEscalationRequired, + adherenceToAdvice = adherenceToAdvice, + improvementNoted = improvementNoted, + followUpDate = followUpDate, + followUpRequired = followUpRequired, + referralDate = referralDate, + reasonForReferral = reasonForReferral, + referralLevel = referralLevel, + referralRequired = referralRequired, + edReason = edReason, + edReferralRequired = edReferralRequired, + edRemarks = edRemarks, + edDurationMinutes = edDurationMinutes, + edSessionDate = edSessionDate, + edInterventionType = edInterventionType, + edPsychosocialInterventionProvided = edPsychosocialInterventionProvided, + edScreeningOutcome = edScreeningOutcome, + edFunctionalDecline = edFunctionalDecline, + edConfusionDisorientation = edConfusionDisorientation, + edProgressiveMemoryLoss = edProgressiveMemoryLoss, + edConfusionordrowsiness = edConfusionordrowsiness, + edRecurrentJerkyMovements = edRecurrentJerkyMovements, + edRecurrentEpisodeloss = edRecurrentEpisodeloss, + substance_alcohol_frequency = substanceAlcoholFrequency, + substanceAlcoholSystemAction = substanceAlcoholSystemAction, + substanceAlcoholClassification = substanceAlcoholClassification, + substanceAlcoholProblematic = substanceAlcoholProblematic, + substanceAlcoholWithdrawal = substanceAlcoholWithdrawal, + substanceAlcoholImpact = substanceAlcoholImpact, + substance_alcohol_loss = substanceAlcoholLoss, + epilepsyLocDuration = epilepsyLocDuration, + epilepsyConfusionAfter = epilepsyConfusionAfter, + epilepsyTongueBite = epilepsyTongueBite, + epilepsyJerkyMovements = epilepsyJerkyMovements, + epilepsyRecurrentSeizures = epilepsyRecurrentSeizures, + dementiaBehaviouralChanges = dementiaBehaviouralChanges, + dementiaDailyActivities = dementiaDailyActivities, + dementiaDisorientation = dementiaDisorientation, + dementiaForgettingRecent = dementiaForgettingRecent, + dementiaProgressiveMemoryLoss = dementiaProgressiveMemoryLoss, + suicideRiskLevel = suicideRiskLevel, + suicideImmediateAssess = suicideImmediateAssess, + suicideHopelessness = suicideHopelessness, + suicidePreviousAttempt = suicidePreviousAttempt, + suicidePlan = suicidePlan, + suicideCurrentThoughts = suicideCurrentThoughts, + briefInterventionGiven = briefInterventionGiven, + substanceFrequency = substanceFrequency, + substanceOtherSpecify = substanceOtherSpecify, + substanceOtherUse = substanceOtherUse, + substanceTobaccoUse = substanceTobaccoUse, + substanceAlcoholUse = substanceAlcoholUse, + substanceSystemAction = substanceSystemAction, + substanceTobaccoOutcome = substanceTobaccoOutcome, + substanceTobaccoFrequency = substanceTobaccoFrequency, + substanceTobaccoType = substanceTobaccoType, + substanceCurrentTobaccoUse = substanceCurrentTobaccoUse, + phq9SystemAction = phq9SystemAction, + phq9DepressionSeverity = phq9DepressionSeverity, + phq9TotalScore = phq9TotalScore, + phq9SelfHarmThoughts = phq9SelfHarmThoughts, + phq9MovingSlowly = phq9MovingSlowly, + phq9Concentration = phq9Concentration, + phq9FeelingBad = phq9FeelingBad, + phq9Appetite = phq9Appetite, + phq9FeelingTired = phq9FeelingTired, + phq9SleepTrouble = phq9SleepTrouble, + phq9FeelingDown = phq9FeelingDown, + phq9LittleInterest = phq9LittleInterest, + isPostpartum = isPostpartum, + seizuresFitsLoc = seizuresFitsLoc, + memoryLossConfusion = memoryLossConfusion, + selfHarmSuicideThoughts = selfHarmSuicideThoughts, + substanceUseConcerns = substanceUseConcerns, + emotionalBehaviouralConcerns = emotionalBehaviouralConcerns, + benVisitNo = benVisitNo, + ) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/model/OtherCphcNetworkModels.kt b/app/src/main/java/org/piramalswasthya/cho/model/OtherCphcNetworkModels.kt new file mode 100644 index 000000000..09db25562 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/OtherCphcNetworkModels.kt @@ -0,0 +1,670 @@ +package org.piramalswasthya.cho.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class EarDiagnosisNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val assessmentId: Long? = null, + val patientId: String? = null, + val benVisitNo: Int? = null, + val difficultyHearing: Boolean? = null, + val whisperTestResponse: String? = null, + val hearingTestOutcome: String? = null, + val earPain: Boolean? = null, + val earDischargePresent: Boolean? = null, + val foreignBodyInEar: String? = null, + val earConditionType: String? = null, + val congenitalEarMalformation: Boolean? = null, + val syncState: Int? = null +) + +fun EarDiagnosisAssessment.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): EarDiagnosisNetwork { + return EarDiagnosisNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + assessmentId = assessmentId, + patientId = patientId, + benVisitNo = benVisitNo, + difficultyHearing = difficultyHearing, + whisperTestResponse = whisperTestResponse, + hearingTestOutcome = hearingTestOutcome, + earPain = earPain, + earDischargePresent = earDischargePresent, + foreignBodyInEar = foreignBodyInEar, + earConditionType = earConditionType, + congenitalEarMalformation = congenitalEarMalformation, + syncState = syncState + ) +} + +fun EarDiagnosisNetwork.toCacheModel(patientID: String): EarDiagnosisAssessment { + return EarDiagnosisAssessment( + assessmentId = 0L, + patientId = patientID, + benVisitNo = benVisitNo, + difficultyHearing = difficultyHearing, + whisperTestResponse = whisperTestResponse, + hearingTestOutcome = hearingTestOutcome, + earPain = earPain, + earDischargePresent = earDischargePresent, + foreignBodyInEar = foreignBodyInEar, + earConditionType = earConditionType, + congenitalEarMalformation = congenitalEarMalformation, + syncState = syncState ?: 0 + ) +} + +@JsonClass(generateAdapter = true) +data class OphthalmicNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val visitId: String? = null, + val patientID: String? = null, + val benVisitNo: Int? = null, + val isDiabetic: Boolean? = null, + val screeningPerformed: Boolean? = null, + val visualAcuityChartUsed: String? = null, + val distVARight: String? = null, + val distVALeft: String? = null, + val nearVA: String? = null, + val caseIdConditions: String? = null, + val cataractSymptoms: Boolean? = null, + val glaucomaSymptoms: Boolean? = null, + val diabeticRetinopathySymptoms: Boolean? = null, + val presbyopiaSymptoms: Boolean? = null, + val trachomaStatus: String? = null, + val cornealDiseaseType: String? = null, + val vitaminADeficiency: Boolean? = null, + val injuryType: String? = null, + val foreignBodyRemoval: String? = null, + val chemicalExposure: Boolean? = null, + val createdBy: String? = null, + val createdDate: Long? = null, + val updatedBy: String? = null, + val updatedDate: Long? = null, + val syncState: Int? = null +) + +fun OphthalmicVisit.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): OphthalmicNetwork { + return OphthalmicNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + visitId = visitId, + patientID = patientID, + benVisitNo = benVisitNo, + isDiabetic = isDiabetic, + screeningPerformed = screeningPerformed, + visualAcuityChartUsed = visualAcuityChartUsed, + distVARight = distVARight, + distVALeft = distVALeft, + nearVA = nearVA, + caseIdConditions = caseIdConditions, + cataractSymptoms = cataractSymptoms, + glaucomaSymptoms = glaucomaSymptoms, + diabeticRetinopathySymptoms = diabeticRetinopathySymptoms, + presbyopiaSymptoms = presbyopiaSymptoms, + trachomaStatus = trachomaStatus, + cornealDiseaseType = cornealDiseaseType, + vitaminADeficiency = vitaminADeficiency, + injuryType = injuryType, + foreignBodyRemoval = foreignBodyRemoval, + chemicalExposure = chemicalExposure, + createdBy = createdBy, + createdDate = createdDate, + updatedBy = updatedBy, + updatedDate = updatedDate, + syncState = syncState + ) +} + +fun OphthalmicNetwork.toCacheModel(patientID: String): OphthalmicVisit { + return OphthalmicVisit( + visitId = visitId ?: "", + patientID = patientID, + benVisitNo = benVisitNo ?: 0, + isDiabetic = isDiabetic, + screeningPerformed = screeningPerformed, + visualAcuityChartUsed = visualAcuityChartUsed, + distVARight = distVARight, + distVALeft = distVALeft, + nearVA = nearVA, + caseIdConditions = caseIdConditions, + cataractSymptoms = cataractSymptoms, + glaucomaSymptoms = glaucomaSymptoms, + diabeticRetinopathySymptoms = diabeticRetinopathySymptoms, + presbyopiaSymptoms = presbyopiaSymptoms, + trachomaStatus = trachomaStatus, + cornealDiseaseType = cornealDiseaseType, + vitaminADeficiency = vitaminADeficiency, + injuryType = injuryType, + foreignBodyRemoval = foreignBodyRemoval, + chemicalExposure = chemicalExposure, + createdBy = createdBy ?: "", + createdDate = createdDate ?: 0L, + updatedBy = updatedBy ?: "", + updatedDate = updatedDate ?: 0L, + syncState = syncState ?: 0 + ) +} + +@JsonClass(generateAdapter = true) +data class OralHealthNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val oralHealthId: Long? = null, + val patientID: String? = null, + val benVisitNo: Int? = null, + val toothDecayPresent: Boolean? = null, + val toothDecaySymptoms: String? = null, + val gumDiseasePresent: Boolean? = null, + val gumDiseaseSymptoms: String? = null, + val irregularTeethJaws: Boolean? = null, + val abnormalGrowthUlcer: Boolean? = null, + val cleftLipPalate: Boolean? = null, + val dentalFluorosis: Boolean? = null, + val dentalEmergency: String? = null, + val createdDate: Long? = null, + val createdBy: String? = null, + val syncState: Int? = null +) + +fun OralHealth.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): OralHealthNetwork { + return OralHealthNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + oralHealthId = oralHealthId, + patientID = patientID, + benVisitNo = benVisitNo, + toothDecayPresent = toothDecayPresent, + toothDecaySymptoms = toothDecaySymptoms, + gumDiseasePresent = gumDiseasePresent, + gumDiseaseSymptoms = gumDiseaseSymptoms, + irregularTeethJaws = irregularTeethJaws, + abnormalGrowthUlcer = abnormalGrowthUlcer, + cleftLipPalate = cleftLipPalate, + dentalFluorosis = dentalFluorosis, + dentalEmergency = dentalEmergency, + createdDate = createdDate, + createdBy = createdBy, + syncState = syncState + ) +} + +fun OralHealthNetwork.toCacheModel(patientID: String): OralHealth { + return OralHealth( + oralHealthId = 0L, + patientID = patientID, + benVisitNo = benVisitNo, + toothDecayPresent = toothDecayPresent, + toothDecaySymptoms = toothDecaySymptoms, + gumDiseasePresent = gumDiseasePresent, + gumDiseaseSymptoms = gumDiseaseSymptoms, + irregularTeethJaws = irregularTeethJaws, + abnormalGrowthUlcer = abnormalGrowthUlcer, + cleftLipPalate = cleftLipPalate, + dentalFluorosis = dentalFluorosis, + dentalEmergency = dentalEmergency, + createdDate = createdDate, + createdBy = createdBy, + syncState = syncState ?: 0 + ) +} + +@JsonClass(generateAdapter = true) +data class PainAssessmentNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val assessmentId: Long? = null, + val patientID: String? = null, + val benVisitNo: Int? = null, + val painSeverity: String? = null, + val painDuration: String? = null, + val symptomsPresent: Boolean? = null, + val otherSymptomsSeverity: String? = null, + val immediateReliefProvided: Boolean? = null, + val persistentPainPresent: Boolean? = null, + val painAssessmentEnabled: Boolean? = null, + val distressingSymptoms: String? = null, + val bedriddenOrSeverelyDependent: Boolean? = null, + val lifeLimitingIllnessKnown: Boolean? = null, + val caregiverSupportRequired: Boolean? = null, + val palliativeCareEligible: Boolean? = null, + val basicSymptomsSelected: String? = null, + val basicSymptomReliefProvided: Boolean? = null, + val basicPsychosocialSupportProvided: Boolean? = null, + val basicCaregiverCounsellingProvided: Boolean? = null, + val basicManagementRemarks: String? = null, + val syncState: Int? = null, + val referralFollowUp: ReferralFollowUpFields? = null +) + +fun PainAndSymptomAssessment.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): PainAssessmentNetwork { + return PainAssessmentNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + assessmentId = assessmentId, + patientID = patientID, + benVisitNo = benVisitNo, + painSeverity = painSeverity, + painDuration = painDuration, + symptomsPresent = symptomsPresent, + otherSymptomsSeverity = otherSymptomsSeverity, + immediateReliefProvided = immediateReliefProvided, + persistentPainPresent = persistentPainPresent, + painAssessmentEnabled = painAssessmentEnabled, + distressingSymptoms = distressingSymptoms, + bedriddenOrSeverelyDependent = bedriddenOrSeverelyDependent, + lifeLimitingIllnessKnown = lifeLimitingIllnessKnown, + caregiverSupportRequired = caregiverSupportRequired, + palliativeCareEligible = palliativeCareEligible, + basicSymptomsSelected = basicSymptomsSelected, + basicSymptomReliefProvided = basicSymptomReliefProvided, + basicPsychosocialSupportProvided = basicPsychosocialSupportProvided, + basicCaregiverCounsellingProvided = basicCaregiverCounsellingProvided, + basicManagementRemarks = basicManagementRemarks, + syncState = syncState, + referralFollowUp = referralFollowUp + ) +} + +fun PainAssessmentNetwork.toCacheModel(patientID: String): PainAndSymptomAssessment { + return PainAndSymptomAssessment( + assessmentId = 0L, + patientID = patientID, + benVisitNo = benVisitNo, + painSeverity = painSeverity, + painDuration = painDuration, + symptomsPresent = symptomsPresent, + otherSymptomsSeverity = otherSymptomsSeverity, + immediateReliefProvided = immediateReliefProvided, + persistentPainPresent = persistentPainPresent, + painAssessmentEnabled = painAssessmentEnabled, + distressingSymptoms = distressingSymptoms, + bedriddenOrSeverelyDependent = bedriddenOrSeverelyDependent, + lifeLimitingIllnessKnown = lifeLimitingIllnessKnown, + caregiverSupportRequired = caregiverSupportRequired, + palliativeCareEligible = palliativeCareEligible, + basicSymptomsSelected = basicSymptomsSelected, + basicSymptomReliefProvided = basicSymptomReliefProvided, + basicPsychosocialSupportProvided = basicPsychosocialSupportProvided, + basicCaregiverCounsellingProvided = basicCaregiverCounsellingProvided, + basicManagementRemarks = basicManagementRemarks, + syncState = syncState ?: 0, + referralFollowUp = referralFollowUp ?: ReferralFollowUpFields() + ) +} + +@JsonClass(generateAdapter = true) +data class PsychosocialCaregiverSupportNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val assessmentId: Long? = null, + val patientId: String? = null, + val benVisitNo: Int? = null, + val psychosocialCounsellingProvided: Boolean? = null, + val caregiverCounsellingProvided: Boolean? = null, + val caregiverDistressIdentified: Boolean? = null, + val counsellingRemarks: String? = null, + val syncState: Int? = null, + val referralFollowUp: ReferralFollowUpFields? = null +) + +fun PsychosocialCaregiverSupport.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): PsychosocialCaregiverSupportNetwork { + return PsychosocialCaregiverSupportNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + assessmentId = assessmentId, + patientId = patientId, + benVisitNo = benVisitNo, + psychosocialCounsellingProvided = psychosocialCounsellingProvided, + caregiverCounsellingProvided = caregiverCounsellingProvided, + caregiverDistressIdentified = caregiverDistressIdentified, + counsellingRemarks = counsellingRemarks, + syncState = syncState, + referralFollowUp = referralFollowUp + ) +} + +fun PsychosocialCaregiverSupportNetwork.toCacheModel(patientID: String): PsychosocialCaregiverSupport { + return PsychosocialCaregiverSupport( + assessmentId = 0L, + patientId = patientID, + benVisitNo = benVisitNo, + psychosocialCounsellingProvided = psychosocialCounsellingProvided, + caregiverCounsellingProvided = caregiverCounsellingProvided, + caregiverDistressIdentified = caregiverDistressIdentified, + counsellingRemarks = counsellingRemarks, + syncState = syncState ?: 0, + referralFollowUp = referralFollowUp ?: ReferralFollowUpFields() + ) +} + +@JsonClass(generateAdapter = true) +data class NoseDiagnosisNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val assessmentId: Long? = null, + val patientID: String? = null, + val benVisitNo: Int? = null, + val difficultyBreathing: Boolean? = null, + val openMouthBreathing: Boolean? = null, + val noseBleed: Boolean? = null, + val systolicBp: Int? = null, + val diastolicBp: Int? = null, + val foreignBodyNose: String? = null, + val sinusitis: Boolean? = null, + val syncState: Int? = null +) + +fun NoseDiagnosisAssessment.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): NoseDiagnosisNetwork { + return NoseDiagnosisNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + assessmentId = assessmentId, + patientID = patientId, + benVisitNo = benVisitNo, + difficultyBreathing = difficultyBreathing, + openMouthBreathing = openMouthBreathing, + noseBleed = noseBleed, + systolicBp = systolicBp, + diastolicBp = diastolicBp, + foreignBodyNose = foreignBodyNose, + sinusitis = sinusitis, + syncState = syncState + ) +} + +fun NoseDiagnosisNetwork.toCacheModel(patientID: String): NoseDiagnosisAssessment { + return NoseDiagnosisAssessment( + assessmentId = 0L, + patientId = patientID, + benVisitNo = benVisitNo, + difficultyBreathing = difficultyBreathing, + openMouthBreathing = openMouthBreathing, + noseBleed = noseBleed, + systolicBp = systolicBp, + diastolicBp = diastolicBp, + foreignBodyNose = foreignBodyNose, + sinusitis = sinusitis, + syncState = syncState ?: 0 + ) +} + +@JsonClass(generateAdapter = true) +data class ThroatDiagnosisNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val assessmentId: Long? = null, + val patientID: String? = null, + val benVisitNo: Int? = null, + val symptoms: List? = null, + val neckSwelling: Boolean? = null, + val difficultySwallowing: Boolean? = null, + val tonsillitis: Boolean? = null, + val pharyngitis: Boolean? = null, + val laryngitis: Boolean? = null, + val sinusitis: Boolean? = null, + val cleftLip: Boolean? = null, + val cleftPalate: Boolean? = null, + val syncState: Int? = null +) + +fun ThroatDiagnosisAssessment.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): ThroatDiagnosisNetwork { + return ThroatDiagnosisNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + assessmentId = assessmentId, + patientID = patientId, + benVisitNo = benVisitNo, + symptoms = symptoms, + neckSwelling = neckSwelling, + difficultySwallowing = difficultySwallowing, + tonsillitis = tonsillitis, + pharyngitis = pharyngitis, + laryngitis = laryngitis, + sinusitis = sinusitis, + cleftLip = cleftLip, + cleftPalate = cleftPalate, + syncState = syncState + ) +} + +fun ThroatDiagnosisNetwork.toCacheModel(patientID: String): ThroatDiagnosisAssessment { + return ThroatDiagnosisAssessment( + assessmentId = 0L, + patientId = patientID, + benVisitNo = benVisitNo, + symptoms = symptoms, + neckSwelling = neckSwelling, + difficultySwallowing = difficultySwallowing, + tonsillitis = tonsillitis, + pharyngitis = pharyngitis, + laryngitis = laryngitis, + sinusitis = sinusitis, + cleftLip = cleftLip, + cleftPalate = cleftPalate, + syncState = syncState ?: 0 + ) +} + +@JsonClass(generateAdapter = true) +data class ElderlyHealthNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val assessmentId: Long? = null, + val patientID: String? = null, + val benVisitNo: Int? = null, + val geriatricComplaints: Boolean? = null, + val multipleChronicConditions: Boolean? = null, + val recentFalls: Boolean? = null, + val difficultyWalkingBalance: Boolean? = null, + val visualHearingDifficulty: Boolean? = null, + val functionalDecline: Boolean? = null, + val bathing: Int? = null, + val dressing: Int? = null, + val toileting: Int? = null, + val transferring: Int? = null, + val continence: Int? = null, + val feeding: Int? = null, + val totalScore: Int? = null, + val functionalStatus: String? = null, + val functionalDeclineFlag: Boolean? = null, + val memoryLoss: Boolean? = null, + val dementiaMemoryLoss: Boolean? = null, + val dementiaDisorientation: Boolean? = null, + val dementiaBehaviouralChanges: Boolean? = null, + val dementiaSelfCareDecline: Boolean? = null, + val dementiaScreeningOutcome: String? = null, + val dementiaReferralRequired: Boolean? = null, + val syncState: Int? = null, + val referralFollowUp: ReferralFollowUpFields? = null +) + +fun ElderlyHealthAssessment.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): ElderlyHealthNetwork { + return ElderlyHealthNetwork( + beneficiaryID = beneficiaryID, + beneficiaryRegID = beneficiaryRegID, + assessmentId = assessmentId, + patientID = patientId, + benVisitNo = benVisitNo, + geriatricComplaints = geriatricComplaints, + multipleChronicConditions = multipleChronicConditions, + recentFalls = recentFalls, + difficultyWalkingBalance = difficultyWalkingBalance, + visualHearingDifficulty = visualHearingDifficulty, + functionalDecline = functionalDecline, + bathing = bathing, + dressing = dressing, + toileting = toileting, + transferring = transferring, + continence = continence, + feeding = feeding, + totalScore = totalScore, + functionalStatus = functionalStatus, + functionalDeclineFlag = functionalDeclineFlag, + memoryLoss = memoryLoss, + dementiaMemoryLoss = dementiaMemoryLoss, + dementiaDisorientation = dementiaDisorientation, + dementiaBehaviouralChanges = dementiaBehaviouralChanges, + dementiaSelfCareDecline = dementiaSelfCareDecline, + dementiaScreeningOutcome = dementiaScreeningOutcome, + dementiaReferralRequired = dementiaReferralRequired, + syncState = syncState, + referralFollowUp = referralFollowUp + ) +} + +fun ElderlyHealthNetwork.toCacheModel(patientID: String): ElderlyHealthAssessment { + return ElderlyHealthAssessment( + assessmentId = 0L, + patientId = patientID, + benVisitNo = benVisitNo ?: 0, + geriatricComplaints = geriatricComplaints, + multipleChronicConditions = multipleChronicConditions, + recentFalls = recentFalls, + difficultyWalkingBalance = difficultyWalkingBalance, + visualHearingDifficulty = visualHearingDifficulty, + functionalDecline = functionalDecline, + bathing = bathing, + dressing = dressing, + toileting = toileting, + transferring = transferring, + continence = continence, + feeding = feeding, + totalScore = totalScore, + functionalStatus = functionalStatus, + functionalDeclineFlag = functionalDeclineFlag, + memoryLoss = memoryLoss, + dementiaMemoryLoss = dementiaMemoryLoss, + dementiaDisorientation = dementiaDisorientation, + dementiaBehaviouralChanges = dementiaBehaviouralChanges, + dementiaSelfCareDecline = dementiaSelfCareDecline, + dementiaScreeningOutcome = dementiaScreeningOutcome, + dementiaReferralRequired = dementiaReferralRequired, + syncState = syncState ?: 0, + referralFollowUp = referralFollowUp ?: ReferralFollowUpFields() + ) +} + +@JsonClass(generateAdapter = true) +data class MentalHealthNetwork( + val beneficiaryID: String, + val beneficiaryRegID: String, + val screeningId: Long? = null, + val patientID: String? = null, + val benVisitNo: Int? = null, + val emotionalBehaviouralConcerns: Boolean? = null, + val substanceUseConcerns: Boolean? = null, + val selfHarmSuicideThoughts: Boolean? = null, + val memoryLossConfusion: Boolean? = null, + val seizuresFitsLoc: Boolean? = null, + val isPostpartum: Boolean? = null, + val phq9LittleInterest: Int? = null, + val phq9FeelingDown: Int? = null, + val phq9SleepTrouble: Int? = null, + val phq9FeelingTired: Int? = null, + val phq9Appetite: Int? = null, + val phq9FeelingBad: Int? = null, + val phq9Concentration: Int? = null, + val phq9MovingSlowly: Int? = null, + val phq9SelfHarmThoughts: Int? = null, + val phq9TotalScore: Int? = null, + val phq9DepressionSeverity: String? = null, + val phq9SystemAction: String? = null, + val substanceCurrentTobaccoUse: Boolean? = null, + val substanceTobaccoType: String? = null, + val substanceTobaccoFrequency: String? = null, + val substanceTobaccoOutcome: String? = null, + val substanceSystemAction: String? = null, + val substanceAlcoholUse: Boolean? = null, + val substanceTobaccoUse: Boolean? = null, + val substanceOtherUse: Boolean? = null, + val substanceOtherSpecify: String? = null, + val substanceFrequency: String? = null, + val briefInterventionGiven: Boolean? = null, + val suicideCurrentThoughts: Boolean? = null, + val suicidePlan: Boolean? = null, + val suicidePreviousAttempt: Boolean? = null, + val suicideHopelessness: Boolean? = null, + val suicideImmediateAssess: Boolean? = null, + val suicideRiskLevel: String? = null, + val dementiaProgressiveMemoryLoss: Boolean? = null, + val dementiaForgettingRecent: Boolean? = null, + val dementiaDisorientation: Boolean? = null, + val dementiaDailyActivities: Boolean? = null, + val dementiaBehaviouralChanges: Boolean? = null, + val epilepsyRecurrentSeizures: Boolean? = null, + val epilepsyJerkyMovements: Boolean? = null, + val epilepsyTongueBite: Boolean? = null, + val epilepsyConfusionAfter: Boolean? = null, + val epilepsyLocDuration: String? = null, + val substanceAlcoholLoss: Boolean? = null, + val substanceAlcoholImpact: Boolean? = null, + val substanceAlcoholWithdrawal: Boolean? = null, + val substanceAlcoholProblematic: Boolean? = null, + val substanceAlcoholClassification: String? = null, + val substanceAlcoholSystemAction: String? = null, + val substanceAlcoholFrequency: String? = null, + val edRecurrentEpisodeloss: Boolean? = null, + val edRecurrentJerkyMovements: Boolean? = null, + val edConfusionordrowsiness: Boolean? = null, + val edProgressiveMemoryLoss: Boolean? = null, + val edConfusionDisorientation: Boolean? = null, + val edFunctionalDecline: Boolean? = null, + val edScreeningOutcome: String? = null, + val edPsychosocialInterventionProvided: Boolean? = null, + val edInterventionType: String? = null, + val edSessionDate: String? = null, + val edDurationMinutes: Int? = null, + val edRemarks: String? = null, + val edReferralRequired: String? = null, + val edReason: String? = null, + val referralRequired: Boolean? = null, + val referralLevel: String? = null, + val reasonForReferral: String? = null, + val referralDate: String? = null, + val followUpRequired: Boolean? = null, + val followUpDate: String? = null, + val improvementNoted: String? = null, + val adherenceToAdvice: String? = null, + val referralEscalationRequired: Boolean? = null, + val caseClosureReason: String? = null, + val syncState: Int? = null +) + +fun MentalHealthScreeningCache.toNetworkModel( + beneficiaryID: String, + beneficiaryRegID: String +): MentalHealthNetwork { + return toMentalHealthNetworkModelInternal(beneficiaryID, beneficiaryRegID) +} + +fun MentalHealthNetwork.toCacheModel(patientID: String): MentalHealthScreeningCache { + return toMentalHealthCacheModelInternal(patientID) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PNC.kt b/app/src/main/java/org/piramalswasthya/cho/model/PNC.kt index 5b80ba0fd..0e8ab5f25 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/PNC.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/PNC.kt @@ -33,17 +33,25 @@ data class PNCVisitCache( var isActive: Boolean, var pncDate: Long = System.currentTimeMillis(), var ifaTabsGiven: Int? = 0, + var calciumSupplementation: Int? = 0, var anyContraceptionMethod: Boolean? = null, var contraceptionMethod: String? = null, + var sterilisationDate: Long? = System.currentTimeMillis(), var otherPpcMethod: String? = null, + var anyDangerSign: String? = null, var motherDangerSign: String? = null, var otherDangerSign: String? = null, + var maternalSymptoms: String? = null, + var otherMaternalSymptoms: String? = null, + var pallor: String? = null, + var vaginalBleeding: String? = null, var referralFacility: String? = null, var motherDeath: Boolean = false, var deathDate: Long? = System.currentTimeMillis(), var causeOfDeath: String? = null, var otherDeathCause: String? = null, var placeOfDeath: String? = null, + var otherPlaceOfDeath: String? = null, var remarks: String? = null, var processed: String? = "N", var createdBy: String, @@ -68,17 +76,25 @@ data class PNCVisitCache( isActive = isActive, pncDate = getDateTimeStringFromLong(pncDate)!!, ifaTabsGiven = ifaTabsGiven, + calciumSupplementation = calciumSupplementation, anyContraceptionMethod = anyContraceptionMethod, contraceptionMethod = contraceptionMethod, + sterilisationDate = sterilisationDate?.let { getDateTimeStringFromLong(it) }, otherPpcMethod = otherPpcMethod, + anyDangerSign = anyDangerSign, motherDangerSign = motherDangerSign, otherDangerSign = otherDangerSign, + maternalSymptoms = maternalSymptoms, + otherMaternalSymptoms = otherMaternalSymptoms, + pallor = pallor, + vaginalBleeding = vaginalBleeding, referralFacility = referralFacility, motherDeath = motherDeath, deathDate = deathDate?.let { getDateTimeStringFromLong(it) }, causeOfDeath = causeOfDeath, otherDeathCause = otherDeathCause, placeOfDeath = placeOfDeath, + otherPlaceOfDeath = otherPlaceOfDeath, remarks = remarks, createdBy = createdBy, createdDate = getDateTimeStringFromLong(createdDate)!!, @@ -96,18 +112,30 @@ data class PNCNetwork( var isActive: Boolean, var pncDate: String, var ifaTabsGiven: Int?, + var calciumSupplementation: Int?, var anyContraceptionMethod: Boolean?, var contraceptionMethod: String?, + var sterilisationDate: String?, var otherPpcMethod: String?, + var anyDangerSign: String?, var motherDangerSign: String?, var otherDangerSign: String?, + var maternalSymptoms: String?, + var otherMaternalSymptoms: String?, + var pallor: String?, + var vaginalBleeding: String?, var referralFacility: String?, var motherDeath: Boolean, var deathDate: String?, var causeOfDeath: String?, var otherDeathCause: String?, var placeOfDeath: String?, + var otherPlaceOfDeath: String?, var remarks: String?, + var deliveryDischargeSummary1: String? = null, + var deliveryDischargeSummary2: String? = null, + var deliveryDischargeSummary3: String? = null, + var deliveryDischargeSummary4: String? = null, var createdBy: String, val createdDate: String, var updatedBy: String, @@ -213,4 +241,88 @@ data class PncDomain( val benId: Long, val visitNumber: Int, val syncState: SyncState? = null -) \ No newline at end of file +) + +/** + * Patient with Delivery Outcome and PNC data + */ +data class PatientWithDeliveryOutcomeAndPncCache( + @Embedded + val patient: Patient, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val deliveryOutcome: List?, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val pncRecords: List +) { + fun asDomainModel(): PatientWithPncDomain { + val activeDo = deliveryOutcome?.firstOrNull { it.isActive } + val latestPnc = pncRecords.maxByOrNull { it.pncPeriod } + + return PatientWithPncDomain( + patient = patient, + deliveryOutcome = activeDo, + latestPnc = latestPnc, + allPncRecords = pncRecords + ) + } +} + +/** + * Domain model for displaying patient with PNC data + */ +data class PatientWithPncDomain( + val patient: Patient, + val deliveryOutcome: DeliveryOutcomeCache?, + val latestPnc: PNCVisitCache?, + val allPncRecords: List, + val syncState: SyncState? = resolvePncSyncState(allPncRecords) +) { + /** + * Get formatted delivery date string + */ + fun getFormattedDeliveryDate(): String { + return deliveryOutcome?.dateOfDelivery?.let { + org.piramalswasthya.cho.utils.HelperUtil.getDateStringFromLong(it) ?: "NA" + } ?: "NA" + } + + /** + * Get days since delivery + * Returns null if deliveryOutcome or dateOfDelivery is missing + */ + fun getDaysSinceDelivery(): Long? { + return deliveryOutcome?.dateOfDelivery?.let { + TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - it) + } + } + + /** + * Check if eligible for PNC (within 42 days or not completed all visits) + */ + fun isEligibleForPNC(): Boolean { + // Return false if there is no delivery date + val dateOfDelivery = deliveryOutcome?.dateOfDelivery ?: return false + + val daysSinceDelivery = getDaysSinceDelivery() ?: return false + val lastPncPeriod = latestPnc?.pncPeriod ?: 0 + + // Eligible if within 42 days OR haven't completed all PNC visits + return daysSinceDelivery <= 42 || lastPncPeriod < 42 + } +} + +private fun resolvePncSyncState(records: List): SyncState? { + if (records.isEmpty()) return null + return when { + records.any { it.syncState == SyncState.SYNCING } -> SyncState.SYNCING + records.any { it.syncState == SyncState.UNSYNCED } -> SyncState.UNSYNCED + records.all { it.syncState == SyncState.SYNCED } -> SyncState.SYNCED + else -> SyncState.UNSYNCED + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PainAndSymptomAssessment.kt b/app/src/main/java/org/piramalswasthya/cho/model/PainAndSymptomAssessment.kt new file mode 100644 index 000000000..6e24b084f --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/PainAndSymptomAssessment.kt @@ -0,0 +1,92 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity(tableName = "PAIN_SYMPTOM_ASSESSMENT") +@JsonClass(generateAdapter = true) +data class PainAndSymptomAssessment( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "assessment_id") + val assessmentId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientID: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int?, + + + @ColumnInfo(name = "pain_severity") + var painSeverity: String? = null, + + @ColumnInfo(name = "pain_duration") + var painDuration: String? = null, + + + @ColumnInfo(name = "symptoms_present") + var symptomsPresent: Boolean? = null, + + @ColumnInfo(name = "other_symptoms_severity") + var otherSymptomsSeverity: String? = null, + + // ---------------- Relief ---------------- + + @ColumnInfo(name = "immediate_relief_provided") + var immediateReliefProvided: Boolean? = null, + + // ---- Section C: Palliative Care Identification ---- + + @ColumnInfo(name = "persistent_pain_present") + var persistentPainPresent: Boolean? = null, + + @ColumnInfo(name = "pain_assessment_enabled") + var painAssessmentEnabled: Boolean? = null, + + @ColumnInfo(name = "distressing_symptoms_present") + var distressingSymptoms: String? = null, + + @ColumnInfo(name = "bedridden_or_severely_dependent") + var bedriddenOrSeverelyDependent: Boolean? = null, + + @ColumnInfo(name = "life_limiting_illness_known") + var lifeLimitingIllnessKnown: Boolean? = null, + + @ColumnInfo(name = "caregiver_support_required") + var caregiverSupportRequired: Boolean? = null, + + @ColumnInfo(name = "palliative_care_eligible") + var palliativeCareEligible: Boolean? = null, + + // ---- Symptom Assessment (Basic) ---- + @ColumnInfo(name = "basic_symptoms_selected") + var basicSymptomsSelected: String? = null, + + // ---- Basic Management (CHO Level) ---- + @ColumnInfo(name = "basic_symptom_relief_provided") + var basicSymptomReliefProvided: Boolean? = null, + + @ColumnInfo(name = "basic_psychosocial_support_provided") + var basicPsychosocialSupportProvided: Boolean? = null, + + @ColumnInfo(name = "basic_caregiver_counselling_provided") + var basicCaregiverCounsellingProvided: Boolean? = null, + + @ColumnInfo(name = "basic_management_remarks") + var basicManagementRemarks: String? = null, + + @ColumnInfo(name = "syncState") + var syncState: Int = 0, + + + // ---------------- Referral & Follow-up (Section F) ---------------- + + @Embedded + val referralFollowUp: ReferralFollowUpFields = ReferralFollowUpFields() + +) : FormDataModel, ReferralFollowUpModel by referralFollowUp \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/Patient.kt b/app/src/main/java/org/piramalswasthya/cho/model/Patient.kt index caef03a5f..61b504a4a 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/Patient.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/Patient.kt @@ -149,6 +149,9 @@ data class Patient ( @ColumnInfo(name="benImageString") var benImage: String? = null, + @ColumnInfo(name = "statusOfWomanID") + var statusOfWomanID: Int? = null, + @ColumnInfo(name="isNewAbha") var isNewAbha: Boolean? = false, @@ -161,6 +164,7 @@ data class Patient ( @ColumnInfo(name = "faceEmbedding") var faceEmbedding: List? = null, + // @ColumnInfo(name = "referDate") // var referDate: String? = null, // @@ -229,6 +233,11 @@ data class PatientDisplay( entityColumn = "religionID" ) val religion: ReligionMaster?, + @Relation( + parentColumn = "statusOfWomanID", + entityColumn = "statusID" + ) + val statusOfWoman: StatusOfWomanMaster?, ) data class PatientDisplayWithVisitInfo( @@ -313,6 +322,8 @@ data class PatientNetwork( val ageAtMarriage: Int?, val bankName: String?, val benImage: String?, + val beneficiaryID: String?, + val beneficiaryRegID: Long?, val benPhoneMaps: List?, val beneficiaryConsent: Boolean?, val beneficiaryIdentities: List?, @@ -338,9 +349,11 @@ data class PatientNetwork( val parkingPlaceID: Int?, val providerServiceMapID: String?, val providerServiceMapId: String?, + val reproductiveStatusId: Int?, + val reproductiveStatus: String?, val spouseName: String?, val titleId: String?, - val vanID: Int?, + val facilityID: Int?, val faceEmbedding: List? ){ @@ -385,6 +398,8 @@ data class PatientNetwork( patientDisplay.patient.ageAtMarriage, null, patientDisplay.patient.benImage, + patientDisplay.patient.beneficiaryID?.toString(), + patientDisplay.patient.beneficiaryRegID, arrayListOf(BenPhone(patientDisplay.patient, user)), true, emptyList(), @@ -410,14 +425,29 @@ data class PatientNetwork( user?.parkingPlaceId, user?.serviceMapId.toString(), user?.serviceMapId.toString(), + patientDisplay.patient.statusOfWomanID, + mapReproductiveStatusName(patientDisplay.patient.statusOfWomanID) ?: patientDisplay.statusOfWoman?.statusName, patientDisplay.patient.spouseName, null, - user?.vanId, + user?.facilityID, patientDisplay.patient.faceEmbedding ) } +private fun mapReproductiveStatusName(statusOfWomanID: Int?): String? { + return when (statusOfWomanID) { + 1 -> "Eligible Couple" + 2 -> "Pregnant Woman" + 3 -> "Post Natal Mother" + 4 -> "Elderly" + 5 -> "Adolescent" + 6 -> "Permanent Sterilization" + 7 -> "Not Applicable" + else -> null + } +} + @JsonClass(generateAdapter = true) data class BenPhone( val alternateContactNumber: String?, @@ -427,16 +457,16 @@ data class BenPhone( val parkingPlaceID: Int?, val phoneNo: String?, val phoneTypeID: Int?, - val vanID: Int? + val facilityID: Int? ){ -// alternateContactNumber:null + // alternateContactNumber:null // benRelationshipID:11 // createdBy:"Pranathi" // parentBenRegID:877 // parkingPlaceID:10 // phoneNo:"8989898989" // phoneTypeID:1 -// vanID:61 +// facilityID:61 constructor(patient: Patient, user: UserDomain?) : this( null, 11, @@ -445,7 +475,7 @@ data class BenPhone( user?.parkingPlaceId, patient.phoneNo, 1, - user?.vanId + user?.facilityID ) } @@ -477,7 +507,8 @@ data class Bendemographics( val religionName: String?, val servicePointID: String?, val servicePointName: String?, - val stateID: Int? + val stateID: Int?, + val stateName: String? ){ constructor(patientDisplay: PatientDisplay, user: UserDomain?) : this( null, @@ -513,6 +544,7 @@ data class Bendemographics( user?.servicePointId.toString(), user?.servicePointName, stateID = patientDisplay.state?.stateID, + stateName = patientDisplay.state?.stateName, // patientDisplay.patient.stateID ) // addressLine1:null @@ -665,7 +697,10 @@ data class BeneficiariesDTO( val beneficiaryAge: Int?, val sourceOfInformation: String?, val isHIVPos: String?, - val faceEmbedding: List? + val faceEmbedding: List?, + val benImage: String?, + val reproductiveStatusId: Int?, + val reproductiveStatus: String? ) @@ -756,7 +791,8 @@ data class BenDetailDTO( val familyId: String?, val other: String?, val headOfFamily_Relation: String?, - val faceEmbedding: List? + val faceEmbedding: List?, + val benImage: String? ) @@ -848,4 +884,3 @@ data class PatientAadhaarDetails( val mobileNumber: String?, val dateOfBirth: String?, ) - diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInfoSync.kt b/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInfoSync.kt index a19221405..7296e21c4 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInfoSync.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInfoSync.kt @@ -102,7 +102,7 @@ data class PatientVisitInfoSync( doctorDataSynced = SyncState.SYNCED, labDataSynced = SyncState.SYNCED, pharmacistDataSynced = SyncState.SYNCED, - visitCategory = benFlow.VisitCategory ?: "", + visitCategory = benFlow.VisitCategory?.takeIf { it.isNotBlank() } ?: "General OPD", visitDate = benFlow.visitDate?.let { try { val inputFormat = SimpleDateFormat("MMM d, yyyy, h:mm:ss a", Locale.ENGLISH) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInformation.kt b/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInformation.kt index 4337b136e..4ae1d7fe8 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInformation.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/PatientVisitInformation.kt @@ -15,7 +15,7 @@ data class PatientVisitInformation( val serviceID: String?, val sessionID: String?, val tcRequest: String?, - val vanID: Int?, + val facilityID: Int?, val visitDetails: VisitDetails?, val vitalDetails: VitalDetails?, // benFlowID: "20307" @@ -51,7 +51,7 @@ data class PatientVisitInformation( user?.serviceId?.toString(), "3", null, - user?.vanId, + user?.facilityID, VisitDetails( user = user, visit = visit, @@ -84,7 +84,7 @@ data class PatientDoctorFormUpsync( val pharmacist_flag: String?, val sessionID: String?, val parkingPlaceID: Int?, - val vanID: Int?, + val facilityID: Int?, val beneficiaryRegID: String?, val providerServiceMapID: String?, val visitCode: String?, @@ -109,7 +109,7 @@ data class PatientDoctorFormUpsync( benFlow?.pharmacist_flag.toString(), "3", user?.parkingPlaceId, - user?.vanId, + user?.facilityID, benFlow?.beneficiaryRegID.toString(), user?.serviceMapId.toString(), benFlow?.visitCode.toString(), diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PncPatient.kt b/app/src/main/java/org/piramalswasthya/cho/model/PncPatient.kt new file mode 100644 index 000000000..1f250226e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/PncPatient.kt @@ -0,0 +1,34 @@ +package org.piramalswasthya.cho.model + +import androidx.room.Embedded +import androidx.room.Relation + +/** + * Simplified patient data class for PNC without delivery outcome + */ +data class PncPatientCache( + @Embedded + val patient: Patient, + @Relation( + parentColumn = "patientID", + entityColumn = "patientID" + ) + val pncRecords: List +) { + fun asDomainModel(): PncPatientDomain { + return PncPatientDomain( + patient = patient, + latestPnc = pncRecords.maxByOrNull { it.pncPeriod }, + allPncRecords = pncRecords + ) + } +} + +/** + * Simplified domain model for displaying patient with PNC data + */ +data class PncPatientDomain( + val patient: Patient, + val latestPnc: PNCVisitCache?, + val allPncRecords: List +) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/Prescription.kt b/app/src/main/java/org/piramalswasthya/cho/model/Prescription.kt index 1c1f14b0e..0edea61c1 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/Prescription.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/Prescription.kt @@ -170,7 +170,7 @@ data class PharmacistItemStockExitDataRequest( data class PharmacistPatientIssueDataRequest( val issuedBy: String, val visitCode: Long?, - val facilityID: Int, + val facilityID: Int?, val age: Int?, val beneficiaryID: Long?, val benRegID: Long, @@ -185,7 +185,6 @@ data class PharmacistPatientIssueDataRequest( val visitID: Long?, val visitDate: String?, val parkingPlaceID: Int?, - val vanID: Int?, var itemStockExit: List ) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionUpsync.kt b/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionUpsync.kt index 385734a1a..dab68c959 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionUpsync.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionUpsync.kt @@ -18,7 +18,7 @@ data class PrescriptionUpsync( val sctCode: String?, val sctTerm: String?, val createdBy: String?, - val vanID: Int?, + val facilityID: Int?, val parkingPlaceID: Int?, val isEDL: Boolean?, ){ @@ -53,7 +53,7 @@ data class PrescriptionUpsync( null, null, user?.userName, - user?.vanId, + user?.facilityID, user?.parkingPlaceId, true ) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionValues.kt b/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionValues.kt index 27d005b3e..8906ad803 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionValues.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/PrescriptionValues.kt @@ -10,7 +10,8 @@ data class PrescriptionValues( var dosage: String = "", var duration: String = "", var instructions: String = "", - var unit: String = "", + var unit: String = "Day(s)", + var isDispensed: Boolean = false, @Ignore var title: String = "Medicine-1" ) : Serializable diff --git a/app/src/main/java/org/piramalswasthya/cho/model/Procedure.kt b/app/src/main/java/org/piramalswasthya/cho/model/Procedure.kt index aedd0b11e..cb8302072 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/Procedure.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/Procedure.kt @@ -136,6 +136,6 @@ data class LabResultDTO( val visitCode: Long?, val providerServiceMapID: Int?, val specialist_flag: String?, - val vanID: Int?, + val facilityID: Int?, val parkingPlaceID:Int? ) \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/ProcedureMaster.kt b/app/src/main/java/org/piramalswasthya/cho/model/ProcedureMaster.kt index 3f27156a5..ce1547cab 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/ProcedureMaster.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/ProcedureMaster.kt @@ -5,6 +5,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.ForeignKey.Companion.CASCADE import androidx.room.PrimaryKey +import com.google.gson.annotations.SerializedName import com.squareup.moshi.JsonClass @@ -24,7 +25,7 @@ data class ProcedureMaster ( @Entity(tableName = "component_details_master", foreignKeys = [ForeignKey( - entity = Procedure::class, + entity = ProcedureMaster::class, childColumns = ["procedure_id"], parentColumns = ["id"], onDelete = CASCADE @@ -92,3 +93,16 @@ data class ComponentDetailMasterDTO( data class ComponentOptionsMasterDTO( val name: String?, ) + +/** Response item for [org.piramalswasthya.cho.network.AmritApiService.getProcedureFields]. */ +data class ProcedureFieldApiItem( + @SerializedName("id") val id: Long, + @SerializedName("inputType") val inputType: String, + @SerializedName("range_min") val rangeMin: Double?, + @SerializedName("range_max") val rangeMax: Double?, + @SerializedName("measurement_nit") val measurementNit: String?, + @SerializedName("isRequired") val isRequired: Boolean, + @SerializedName("test_component_name") val testComponentName: String, + @SerializedName("test_component_desc") val testComponentDesc: String, + @SerializedName("procedureID") val procedureID: Long, +) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/PsychosocialCaregiverSupport.kt b/app/src/main/java/org/piramalswasthya/cho/model/PsychosocialCaregiverSupport.kt new file mode 100644 index 000000000..f86473f7d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/PsychosocialCaregiverSupport.kt @@ -0,0 +1,50 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity(tableName = "PSYCHOSOCIAL_CAREGIVER_SUPPORT") +@JsonClass(generateAdapter = true) +data class PsychosocialCaregiverSupport( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "assessment_id") + val assessmentId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientId: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int?, + + // ---------------- Counselling ---------------- + + @ColumnInfo(name = "psychosocial_counselling_provided") + var psychosocialCounsellingProvided: Boolean? = null, + + @ColumnInfo(name = "caregiver_counselling_provided") + var caregiverCounsellingProvided: Boolean? = null, + + // ---------------- Caregiver ---------------- + + @ColumnInfo(name = "caregiver_distress_identified") + var caregiverDistressIdentified: Boolean? = null, + + // ---------------- Remarks ---------------- + + @ColumnInfo(name = "counselling_remarks") + var counsellingRemarks: String? = null, + + @ColumnInfo(name = "syncState") + var syncState: Int = 0, + + // ---------------- Referral & Follow-up (Section F) ---------------- + + @Embedded + val referralFollowUp: ReferralFollowUpFields = ReferralFollowUpFields() + +) : FormDataModel, ReferralFollowUpModel by referralFollowUp diff --git a/app/src/main/java/org/piramalswasthya/cho/model/ReferUpsync.kt b/app/src/main/java/org/piramalswasthya/cho/model/ReferUpsync.kt index a5373a381..e738e0451 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/ReferUpsync.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/ReferUpsync.kt @@ -9,7 +9,7 @@ data class ReferUpsync( val referralReasonList: List?, val otherReferralReason: String?, val revisitDate: String?, - val vanID: Int?, + val facilityID: Int?, val parkingPlaceID: Int?, val beneficiaryRegID: String?, val benVisitID: String?, @@ -45,7 +45,7 @@ data class ReferUpsync( null, null, null, - user?.vanId, + user?.facilityID, user?.parkingPlaceId, benFlow?.beneficiaryRegID.toString(), benFlow?.benVisitID.toString(), diff --git a/app/src/main/java/org/piramalswasthya/cho/model/ReferralFollowUpFields.kt b/app/src/main/java/org/piramalswasthya/cho/model/ReferralFollowUpFields.kt new file mode 100644 index 000000000..af0976136 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/ReferralFollowUpFields.kt @@ -0,0 +1,38 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import com.squareup.moshi.JsonClass + +/** + * Reusable set of referral & follow-up columns shared by every assessment entity. + * Embed with `@Embedded` in Room entities and delegate `ReferralFollowUpModel` to this instance. + */ +@JsonClass(generateAdapter = true) +data class ReferralFollowUpFields( + + @ColumnInfo(name = "referral_required") + override var referralRequired: Boolean? = null, + + @ColumnInfo(name = "referral_level") + override var referralLevel: String? = null, + + @ColumnInfo(name = "reason_for_referral") + override var reasonForReferral: String? = null, + + @ColumnInfo(name = "follow_up_required") + override var followUpRequired: Boolean? = null, + + @ColumnInfo(name = "follow_up_date") + override var followUpDate: String? = null, + + @ColumnInfo(name = "case_status") + override var caseStatus: String? = null, + + @ColumnInfo(name = "date_of_death") + override var dateOfDeath: String? = null, + + @ColumnInfo(name = "remarks") + override var remarks: String? = null + +) : ReferralFollowUpModel + diff --git a/app/src/main/java/org/piramalswasthya/cho/model/ReferralFollowUpModel.kt b/app/src/main/java/org/piramalswasthya/cho/model/ReferralFollowUpModel.kt new file mode 100644 index 000000000..06570e179 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/ReferralFollowUpModel.kt @@ -0,0 +1,15 @@ +package org.piramalswasthya.cho.model + +interface ReferralFollowUpModel { + var referralRequired: Boolean? + var referralLevel: String? + var reasonForReferral: String? + var followUpRequired: Boolean? + var followUpDate: String? + + var caseStatus: String? + + var dateOfDeath: String? + + var remarks: String? +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/StatusOfWomanMaster.kt b/app/src/main/java/org/piramalswasthya/cho/model/StatusOfWomanMaster.kt new file mode 100644 index 000000000..a6271c6c8 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/StatusOfWomanMaster.kt @@ -0,0 +1,12 @@ +package org.piramalswasthya.cho.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "STATUS_OF_WOMAN_MASTER") +data class StatusOfWomanMaster( + @PrimaryKey + val statusID: Int, + val statusName: String, + val statusCode: String +) diff --git a/app/src/main/java/org/piramalswasthya/cho/model/ThroatDiagnosisAssessment.kt b/app/src/main/java/org/piramalswasthya/cho/model/ThroatDiagnosisAssessment.kt new file mode 100644 index 000000000..39357bd04 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/model/ThroatDiagnosisAssessment.kt @@ -0,0 +1,55 @@ +package org.piramalswasthya.cho.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.squareup.moshi.JsonClass +import org.piramalswasthya.cho.configuration.FormDataModel + +@Entity(tableName = "THROAT_DIAGNOSIS_ASSESSMENT") +@JsonClass(generateAdapter = true) +data class ThroatDiagnosisAssessment( + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "assessment_id") + val assessmentId: Long = 0L, + + @ColumnInfo(name = "patient_id") + val patientId: String, + + @ColumnInfo(name = "ben_visit_no") + val benVisitNo: Int?, + + /* -------------------- THROAT DIAGNOSIS FIELDS -------------------- */ + + @ColumnInfo(name = "symptoms") + var symptoms: List? = null, + + @ColumnInfo(name = "neck_swelling") + var neckSwelling: Boolean? = null, + + @ColumnInfo(name = "difficulty_swallowing") + var difficultySwallowing: Boolean? = null, + + @ColumnInfo(name = "tonsillitis") + var tonsillitis: Boolean? = null, + + @ColumnInfo(name = "pharyngitis") + var pharyngitis: Boolean? = null, + + @ColumnInfo(name = "laryngitis") + var laryngitis: Boolean? = null, + + @ColumnInfo(name = "sinusitis") + var sinusitis: Boolean? = null, + + @ColumnInfo(name = "cleft_lip") + var cleftLip: Boolean? = null, + + @ColumnInfo(name = "cleft_palate") + var cleftPalate: Boolean? = null, + + @ColumnInfo(name = "syncState") + var syncState: Int = 0 + +) : FormDataModel \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/model/User.kt b/app/src/main/java/org/piramalswasthya/cho/model/User.kt index 7813cf052..5ce9106f0 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/User.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/User.kt @@ -77,6 +77,14 @@ data class UserCache( @ColumnInfo(name = "facilityID") val facilityID: Int?, + @ColumnInfo(name = "facilityType") + val facilityType: String?, + @ColumnInfo(name = "facilityName") + val facilityName: String?, + @ColumnInfo(name = "employeeId") + val employeeId: String?, + @ColumnInfo(name = "locationType") + val locationType: String?, @ColumnInfo(name = "zone_name") val zoneName : String, @@ -170,6 +178,10 @@ data class UserCache( servicePointName = servicePointName, vanId = vanId, facilityID = facilityID, + facilityName = facilityName, + facilityType = facilityType, + employeeId = employeeId, + locationType = locationType, zoneId = zoneId, zoneName = zoneName, parkingPlaceId = parkingPlaceId, @@ -234,6 +246,10 @@ data class UserDomain( val zoneName: String, val vanId: Int, val facilityID : Int?, + val facilityName : String? = null, + val facilityType : String? = null, + val employeeId : String? = null, + val locationType : String? = null, val country: LocationEntity, val states : List, val districts : List, @@ -289,6 +305,10 @@ data class UserNetwork( var zoneId: Int = -1, var vanId : Int = -1, var facilityID:Int=-1, + var facilityType: String?=null, + var facilityName: String?=null, + var employeeId: String?=null, + var locationType: String?=null, var parkingPlaceName: String?=null, var servicePointName: String?=null, var zoneName : String?=null, @@ -356,6 +376,10 @@ data class UserNetwork( vanId = vanId, zoneId = zoneId, facilityID = facilityID, + facilityName = facilityName, + facilityType = facilityType, + employeeId = employeeId, + locationType = locationType, zoneName = zoneName?:"", parkingPlaceId = parkingPlaceId, parkingPlaceName = parkingPlaceName?:"", diff --git a/app/src/main/java/org/piramalswasthya/cho/model/VisitDetails.kt b/app/src/main/java/org/piramalswasthya/cho/model/VisitDetails.kt index c2f9fac97..59f4fe278 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/VisitDetails.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/VisitDetails.kt @@ -37,7 +37,7 @@ data class Adherence( val referralReason: String?, val toDrugs: String?, val toReferral: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33140" // createdBy:"Pranathi" @@ -61,7 +61,7 @@ data class Adherence( null, null, null, - user?.vanId + user?.facilityID ) } @@ -77,7 +77,7 @@ data class ChiefComplaintsNetwork( val parkingPlaceID: Int?, val providerServiceMapID: String?, val unitOfDuration: String?, - val vanID: Int?, + val facilityID: Int?, // benVisitID: null // beneficiaryRegID: "33140" // chiefComplaint:null @@ -98,7 +98,7 @@ data class ChiefComplaintsNetwork( parkingPlaceID = user?.parkingPlaceId, providerServiceMapID = user?.serviceMapId?.toString(), unitOfDuration = chiefComplaint.durationUnit, - vanID = user?.vanId, + facilityID = user?.facilityID, ) } @@ -120,7 +120,7 @@ data class VisitDetailsNetwork( val reportFilePath: String?, val sideEffects: String?, val subVisitCategory: String?, - val vanID: Int?, + val facilityID: Int?, val visitCategory: String?, val visitNo: String?, val visitReason: String?, @@ -162,7 +162,7 @@ data class VisitDetailsNetwork( null, null, visit?.subCategory, - user?.vanId, + user?.facilityID, visit?.category, null, visit?.reasonForVisit, diff --git a/app/src/main/java/org/piramalswasthya/cho/model/VitalDetails.kt b/app/src/main/java/org/piramalswasthya/cho/model/VitalDetails.kt index 92734f04c..6e28bf237 100644 --- a/app/src/main/java/org/piramalswasthya/cho/model/VitalDetails.kt +++ b/app/src/main/java/org/piramalswasthya/cho/model/VitalDetails.kt @@ -32,7 +32,7 @@ data class VitalDetails( val sputumChecked: String?, val systolicBP_1stReading: String?, val temperature: String?, - val vanID: Int?, + val facilityID: Int?, val waistCircumference_cm: String?, val waistHipRatio: String?, val weight_Kg: String?, @@ -100,7 +100,7 @@ data class VitalDetails( null, systolicBP_1stReading = vitals?.bpSystolic, temperature = vitals?.temperature, - vanID = user?.vanId, + facilityID = user?.facilityID, waistCircumference_cm = vitals?.waistCircumference, null, weight_Kg = vitals?.weight, diff --git a/app/src/main/java/org/piramalswasthya/cho/network/AmritApiService.kt b/app/src/main/java/org/piramalswasthya/cho/network/AmritApiService.kt index d1b4cb30c..746dbd08e 100644 --- a/app/src/main/java/org/piramalswasthya/cho/network/AmritApiService.kt +++ b/app/src/main/java/org/piramalswasthya/cho/network/AmritApiService.kt @@ -6,8 +6,10 @@ import org.piramalswasthya.cho.model.ANCPost import org.piramalswasthya.cho.model.AllocationItemDataRequest import org.piramalswasthya.cho.model.BenNewFlow import org.piramalswasthya.cho.model.CbacRequest +import org.piramalswasthya.cho.model.DeliveryOutcomePost import org.piramalswasthya.cho.model.ECTNetwork import org.piramalswasthya.cho.model.ImmunizationPost +import org.piramalswasthya.cho.model.InfantRegApiPost import org.piramalswasthya.cho.model.LabResultDTO import org.piramalswasthya.cho.model.LocationRequest import org.piramalswasthya.cho.model.MasterLabProceduresRequestModel @@ -23,6 +25,7 @@ import org.piramalswasthya.cho.model.PharmacistPatientDataRequest import org.piramalswasthya.cho.model.PharmacistPatientIssueDataRequest import org.piramalswasthya.cho.model.PrescribedMedicineDataRequest import org.piramalswasthya.cho.model.PrescriptionTemplateDB +import org.piramalswasthya.cho.model.PwrPost import org.piramalswasthya.cho.model.StockItemRequest import org.piramalswasthya.cho.model.UserMasterVillage import org.piramalswasthya.cho.model.fhir.SelectedOutreachProgram @@ -34,6 +37,15 @@ import retrofit2.http.Headers import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query +import org.piramalswasthya.cho.model.EarDiagnosisNetwork +import org.piramalswasthya.cho.model.OphthalmicNetwork +import org.piramalswasthya.cho.model.OralHealthNetwork +import org.piramalswasthya.cho.model.PainAssessmentNetwork +import org.piramalswasthya.cho.model.PsychosocialCaregiverSupportNetwork +import org.piramalswasthya.cho.model.NoseDiagnosisNetwork +import org.piramalswasthya.cho.model.ThroatDiagnosisNetwork +import org.piramalswasthya.cho.model.ElderlyHealthNetwork +import org.piramalswasthya.cho.model.MentalHealthNetwork interface AmritApiService { @@ -58,7 +70,7 @@ interface AmritApiService { @POST("common-api/user/getLoginResponse") suspend fun getLoginResponse() : Response - @POST("hwc-api/user/getUserVanSpDetails?apiKey=undefined") + @POST("hwc-api/user/getUserFacilityDetails") suspend fun getUserVanSpDetails( @Body vanServiceType: TmcUserVanSpDetailsRequest ): Response @@ -101,6 +113,8 @@ interface AmritApiService { @POST("hwc-api/sync/beneficiariesToServer") suspend fun saveBenificiaryDetails(@Body benificiary: PatientNetwork) : Response + @POST("hwc-api/sync/update/beneficiariesToServer") + suspend fun updateBenificiaryDetails(@Body benificiary: PatientNetwork) : Response @POST("hwc-api/sync/beneficiariesToAppCount") suspend fun getBeneficiariesCount(@Body villageList: VillageIdList): Response @@ -129,22 +143,55 @@ interface AmritApiService { @GET("hwc-api/sync/{userID}/prescriptionTemplatesDataToApp") suspend fun getTemplateFromServer(@Path("userID") userID: Int): Response - @POST("/flw-api/maternalCare/ancVisit/saveAll") + @POST("/hwc-api/maternal/ancVisit/saveAll") suspend fun postAncForm(@Body ancPostList: List): Response - @POST("/flw-api/maternalCare/pnc/saveAll") + @POST("/hwc-api/pnc/saveAll") suspend fun postPncForm(@Body ancPostList: List): Response + @POST("/hwc-api/pnc/getAll") + suspend fun getAllPncVisits(@Body request: Any): Response + + @POST("/hwc-api/maternal/pregnantWoman/saveAll") + suspend fun postPregnantWomanForm(@Body pwrPostList: List): Response + + @POST("/hwc-api/maternal/pregnantWoman/getAll") + suspend fun getAllPregnantWomen(@Body request: Any): Response + + @POST("/hwc-api/maternal/ancVisit/getAll") + suspend fun getAllAncVisits(@Body request: Any): Response + + @POST("/hwc-api/deliveryOutcome/saveAll") + suspend fun postDeliveryOutcomeForm(@Body deliveryOutcomeList: List): Response + + @POST("/hwc-api/deliveryOutcome/getAll") + suspend fun getAllDeliveryOutcomes(@Body request: Any): Response + @POST("/flw-api/child-care/vaccination/saveAll") suspend fun postChildImmunizationDetails(@Body immunizationList: List): Response + @POST("/hwc-api/child/saveAll") + suspend fun postChildDetails(@Body request: Any): Response + + @POST("/hwc-api/child/getAll") + suspend fun getAllChildren(@Body request: Any): Response + + @POST("/hwc-api/infant/saveAll") + suspend fun postInfantRegForm(@Body infantRegList: List): Response + + @POST("/hwc-api/infant/getAll") + suspend fun getAllInfants(@Body request: Any): Response + @POST("/hwc-api/NCD/save/nurseData") suspend fun postCbacData(@Body cbacList: CbacRequest): Response - @POST("/flw-api/couple/tracking/saveAll") + @POST("/hwc-api/couple/tracking/saveAll") suspend fun postEctForm(@Body ectPostList: List): Response + @POST("/hwc-api/couple/tracking/getAll") + suspend fun getEligibleCouples(@Body villageList: VillageIdList): Response + @GET("/flw-api/child-care/vaccine/getAll") suspend fun getAllChildVaccines(@Query("category") category: String): Response @@ -242,12 +289,11 @@ interface AmritApiService { @Path("gender") gender: String, @Query("apiKey") apiKey :String): Response - @GET("hwc-api/master/doctor/masterData/{visitCategoryID}/{providerServiceMapID}/{gender}/{facilityID}/{vanID}") + @GET("hwc-api/master/doctor/masterData/{visitCategoryID}/{providerServiceMapID}/{gender}/{facilityID}") suspend fun getDoctorMasterData(@Path("visitCategoryID") visitCategoryID: Int, @Path("providerServiceMapID") providerServiceMapID : Int, @Path("gender") gender: String, @Path("facilityID") facilityID: Int, - @Path("vanID") vanID: Int, @Query("apiKey") apiKey :String): Response @POST("hwc-api/generalOPD/getBenCaseRecordFromDoctorGeneralOPD") @@ -256,6 +302,9 @@ interface AmritApiService { @POST("/hwc-api/registrar/get/benDetailsByRegIDForLeftPanelNew?apiKey=undefined") suspend fun getPharmacistPatientDetails(@Body pharmacistPatientDataRequest: PharmacistPatientDataRequest) : Response + @GET("/hwc-api/procedureFields/fields") + suspend fun getProcedureFields(): Response + @POST("/inventory-api/allocateStockFromItemID/{facilityID}?apiKey=undefined") suspend fun getPharmacistAllocationItemList(@Body allocationItemDataRequest: List, @Path("facilityID") facilityID: Int) : Response @@ -270,5 +319,57 @@ interface AmritApiService { @Body request: StockItemRequest ):Response + @POST("/hwc-api/earDiagnosis/saveAll") + suspend fun postEarForm(@Body earList: List): Response + + @POST("/hwc-api/earDiagnosis/getAll") + suspend fun getEarVisits(@Body villageList: VillageIdList): Response + + @POST("/hwc-api/opthalmicVisit/saveAll") + suspend fun postOphthalmicForm(@Body ophthalmicList: List): Response + + @POST("/hwc-api/opthalmicVisit/getAll") + suspend fun getOphthalmicVisits(@Body villageList: VillageIdList): Response + + @POST("/hwc-api/oralHealth/saveAll") + suspend fun postOralForm(@Body oralList: List): Response + + @POST("/hwc-api/oralHealth/getAll") + suspend fun getOralVisits(@Body villageList: VillageIdList): Response + + @POST("/hwc-api/painSymptom/saveAll") + suspend fun postPainForm(@Body painList: List): Response + + @POST("/hwc-api/painSymptom/getAll") + suspend fun getPainVisits(@Body villageList: VillageIdList): Response + + @POST("/hwc-api/psychosocialCaregiver/saveAll") + suspend fun postPsychosocialCaregiverForm(@Body psychosocialCaregiverList: List): Response + + @POST("/hwc-api/psychosocialCaregiver/getAll") + suspend fun getPsychosocialCaregiverVisits(@Body villageList: VillageIdList): Response + + @POST("/hwc-api/noseDiagnosis/saveAll") + suspend fun postNoseForm(@Body noseList: List): Response + + @POST("/hwc-api/noseDiagnosis/getAll") + suspend fun getNoseVisits(@Body villageList: VillageIdList): Response + + @POST("/hwc-api/throatDiagnosis/saveAll") + suspend fun postThroatForm(@Body throatList: List): Response + + @POST("/hwc-api/throatDiagnosis/getAll") + suspend fun getThroatVisits(@Body villageList: VillageIdList): Response + + @POST("/hwc-api/elderlyHealth/saveAll") + suspend fun postElderlyForm(@Body elderlyList: List): Response + + @POST("/hwc-api/elderlyHealth/getAll") + suspend fun getElderlyVisits(@Body villageList: VillageIdList): Response + @POST("/hwc-api/mentalHealth/saveAll") + suspend fun postMentalForm(@Body mentalList: List): Response + @POST("/hwc-api/mentalHealth/getAll") + suspend fun getMentalVisits(@Body villageList: VillageIdList): Response + -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/network/JsonAdapters.kt b/app/src/main/java/org/piramalswasthya/cho/network/JsonAdapters.kt index ec96c0fa5..7cc21791a 100644 --- a/app/src/main/java/org/piramalswasthya/cho/network/JsonAdapters.kt +++ b/app/src/main/java/org/piramalswasthya/cho/network/JsonAdapters.kt @@ -761,9 +761,45 @@ data class LabProceduresDataRequest( val visitCode: Long, ) + fun getLongFromDate(dateString: String?): Long { - val f = SimpleDateFormat("MMM d, yyyy h:mm:ss a", Locale.ENGLISH) - val date = dateString?.let { f.parse(it) } - return date?.time ?: 0L + if (dateString.isNullOrBlank()) return 0L + + val normalized = dateString.trim() + val patterns = listOf( + "MMM d, yyyy h:mm:ss a", + "dd/MM/yyyy HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd HH:mm:ss", + "dd-MM-yyyy", + "yyyy-MM-dd" + ) + + for (pattern in patterns) { + try { + val format = SimpleDateFormat(pattern, Locale.ENGLISH).apply { + isLenient = false + if (pattern.contains("'Z'")) { + timeZone = java.util.TimeZone.getTimeZone("UTC") + } + } + val date = format.parse(normalized) + if (date != null) return date.time + } catch (_: Exception) { + // Try next known format. + } + } + + return 0L } +fun getDateFromLong(dateLong: Long): String? { + if (dateLong == 0L) return null + val cal = java.util.Calendar.getInstance() + cal.timeInMillis = dateLong + val f = SimpleDateFormat("dd-MM-yyyy", Locale.ENGLISH) + return f.format(cal.time) +} diff --git a/app/src/main/java/org/piramalswasthya/cho/network/TokenRefreshProvider.kt b/app/src/main/java/org/piramalswasthya/cho/network/TokenRefreshProvider.kt new file mode 100644 index 000000000..bc3d0cf99 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/network/TokenRefreshProvider.kt @@ -0,0 +1,23 @@ +package org.piramalswasthya.cho.network + +/** + * Synchronous token refresh provider used by [AuthRefreshInterceptor]. + * Set via [TokenRefreshHolder] after DI is ready to avoid circular dependency + * (interceptor -> OkHttpClient -> AmritApiService -> UserRepo). + */ +interface TokenRefreshProvider { + /** + * Refreshes the JWT/token by calling the login API with stored credentials. + * @return true if refresh succeeded and token was updated, false otherwise. + */ + fun refresh(): Boolean +} + +/** + * Holder for the [TokenRefreshProvider] so that [AuthRefreshInterceptor] can + * obtain it without being injected (which would create a circular dependency). + */ +object TokenRefreshHolder { + @Volatile + var provider: TokenRefreshProvider? = null +} diff --git a/app/src/main/java/org/piramalswasthya/cho/network/TokenRefreshProviderImpl.kt b/app/src/main/java/org/piramalswasthya/cho/network/TokenRefreshProviderImpl.kt new file mode 100644 index 000000000..26d6f9d03 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/network/TokenRefreshProviderImpl.kt @@ -0,0 +1,77 @@ +package org.piramalswasthya.cho.network + +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.piramalswasthya.cho.crypt.CryptoUtil +import org.piramalswasthya.cho.database.room.dao.UserDao +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.network.interceptors.TokenInsertTmcInterceptor +import retrofit2.HttpException +import timber.log.Timber +import java.net.SocketTimeoutException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of [TokenRefreshProvider] that calls the JWT/login API with stored + * credentials and updates the token. Used by [AuthRefreshInterceptor] when any API + * returns 401/403 or statusCode 5002 (token expired). + */ +@Singleton +class TokenRefreshProviderImpl @Inject constructor( + private val userDao: UserDao, + private val amritApiService: AmritApiService, + private val preferenceDao: PreferenceDao +) : TokenRefreshProvider { + + private val cryptoUtil = CryptoUtil() + + override fun refresh(): Boolean = runBlocking { + try { + val user = userDao.getLoggedInUser() ?: run { + Timber.w("TokenRefreshProvider: No logged-in user") + return@runBlocking false + } + refreshTokenTmc(user.userName, user.password) + } catch (e: Exception) { + Timber.e(e, "TokenRefreshProvider: refresh failed") + false + } + } + + private suspend fun refreshTokenTmc(userName: String, password: String): Boolean { + return try { + val encryptedPassword = cryptoUtil.encrypt(password) + val response = amritApiService.getJwtToken( + TmcAuthUserRequest(userName, encryptedPassword) + ) + if (!response.isSuccessful) { + return false + } + val bodyString = response.body()?.string() + ?: return false + val responseBody = JSONObject(bodyString) + val responseStatusCode = responseBody.getInt("statusCode") + if (responseStatusCode == 200) { + val data = responseBody.getJSONObject("data") + TokenInsertTmcInterceptor.setJwt(data.getString("jwtToken")) + preferenceDao.registerJWTAmritToken(data.getString("jwtToken")) + val token = data.getString("key") + TokenInsertTmcInterceptor.setToken(token) + preferenceDao.registerPrimaryApiToken(token) + Timber.d("TokenRefreshProvider: Token refreshed for user $userName") + true + } else { + val errorMessage = responseBody.optString("errorMessage", "Unknown error") + Timber.w("TokenRefreshProvider: API error $responseStatusCode - $errorMessage") + false + } + } catch (e: SocketTimeoutException) { + Timber.w("TokenRefreshProvider: Timeout, retrying once") + refreshTokenTmc(userName, password) + } catch (e: HttpException) { + Timber.w("TokenRefreshProvider: Auth failed - ${e.code()}") + false + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/network/interceptors/AuthRefreshInterceptor.kt b/app/src/main/java/org/piramalswasthya/cho/network/interceptors/AuthRefreshInterceptor.kt new file mode 100644 index 000000000..42a5c1c8c --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/network/interceptors/AuthRefreshInterceptor.kt @@ -0,0 +1,112 @@ +package org.piramalswasthya.cho.network.interceptors + +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.json.JSONObject +import org.piramalswasthya.cho.network.TokenRefreshHolder +import timber.log.Timber + +/** + * OkHttp interceptor that detects expired JWT (HTTP 401/403 or API statusCode 5002), + * refreshes the token via login API, and retries the original request once. + * Prevents sync/API failures when the app is kept open overnight and the token expires. + */ +class AuthRefreshInterceptor : Interceptor { + + companion object { + private const val HEADER_NO_AUTH = "No-Auth" + /** Header added on retry so we only retry once. */ + private const val HEADER_RETRY = "X-Auth-Retry" + private const val API_STATUS_TOKEN_EXPIRED = 5002 + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + // Do not add token or try refresh for login/refresh requests + if (request.header(HEADER_NO_AUTH) != null) { + return chain.proceed(request) + } + + // If this is already a retry after refresh, don't try again + if (request.header(HEADER_RETRY) != null) { + return chain.proceed(request) + } + + val response = chain.proceed(request) + + // HTTP-level auth failure: refresh and retry once + if (response.code == 401 || response.code == 403) { + if (performRefreshAndRetry(chain, request)) { + response.close() + return retryRequest(chain, request) + } + return response + } + + // Success response might still have API-level token expiry (statusCode 5002) + if (response.code == 200 && response.body != null) { + val contentType = response.body!!.contentType() + val bodyString = response.body!!.string() + response.close() + + val statusCode = try { + JSONObject(bodyString).optInt("statusCode", -1) + } catch (_: Exception) { + -1 + } + + if (statusCode == API_STATUS_TOKEN_EXPIRED) { + Timber.w("AuthRefreshInterceptor: API returned statusCode 5002 (token expired)") + if (performRefreshAndRetry(chain, request)) { + return retryRequest(chain, request) + } + // Return the original error response so caller can handle + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(401) + .message("Token expired") + .body(bodyString.toResponseBody(contentType)) + .build() + } + + // Rebuild response with consumed body so caller can read it + return response.newBuilder() + .body(bodyString.toResponseBody(contentType)) + .build() + } + + return response + } + + private fun performRefreshAndRetry(chain: Interceptor.Chain, request: Request): Boolean { + val provider = TokenRefreshHolder.provider + if (provider == null) { + Timber.w("AuthRefreshInterceptor: TokenRefreshProvider not set, cannot refresh") + return false + } + return try { + val refreshed = provider.refresh() + if (refreshed) { + Timber.d("AuthRefreshInterceptor: Token refreshed successfully, retrying request") + } else { + Timber.w("AuthRefreshInterceptor: Token refresh failed (no user or API error)") + } + refreshed + } catch (e: Exception) { + Timber.e(e, "AuthRefreshInterceptor: Error during token refresh") + false + } + } + + private fun retryRequest(chain: Interceptor.Chain, originalRequest: Request): Response { + val retryRequest = originalRequest.newBuilder() + .header(HEADER_RETRY, "1") + .build() + return chain.proceed(retryRequest) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/network/interceptors/TokenInsertTmcInterceptor.kt b/app/src/main/java/org/piramalswasthya/cho/network/interceptors/TokenInsertTmcInterceptor.kt index 7e0d60182..c91ab3d32 100644 --- a/app/src/main/java/org/piramalswasthya/cho/network/interceptors/TokenInsertTmcInterceptor.kt +++ b/app/src/main/java/org/piramalswasthya/cho/network/interceptors/TokenInsertTmcInterceptor.kt @@ -26,6 +26,11 @@ class TokenInsertTmcInterceptor : Interceptor{ return JWT } + fun clearTokens() { + TOKEN = "" + JWT = "" + } + } override fun intercept(chain: Interceptor.Chain): Response { @@ -33,7 +38,7 @@ class TokenInsertTmcInterceptor : Interceptor{ if (request.header("No-Auth") == null) { request = request .newBuilder() - .addHeader("Authorization", TOKEN) + //.addHeader("Authorization", TOKEN) .addHeader("Jwttoken" , JWT) .build() } diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/AmritSyncRepositoryHelper.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/AmritSyncRepositoryHelper.kt new file mode 100644 index 000000000..c67fb667b --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/AmritSyncRepositoryHelper.kt @@ -0,0 +1,212 @@ +package org.piramalswasthya.cho.repositories + +import kotlinx.coroutines.delay +import org.json.JSONArray +import org.json.JSONObject +import org.piramalswasthya.cho.network.VillageIdList +import okhttp3.ResponseBody +import retrofit2.Response +import timber.log.Timber +import java.net.SocketTimeoutException + +object AmritSyncRepositoryHelper { + private const val STATUS_OK = 200 + private const val STATUS_TOKEN_EXPIRED = 5002 + private const val STATUS_NO_RECORDS = 5000 + private const val MAX_PULL_ATTEMPTS = 3 + private const val INITIAL_BACKOFF_MS = 500L + + fun convertStringToIntList(villageIds: String): List { + if (villageIds.trim().isEmpty()) return emptyList() + return villageIds.split(",").mapNotNull { it.trim().toIntOrNull() } + } + + suspend fun pushUnsynced( + unsyncedList: List, + mapToPayload: suspend (T) -> N?, + post: suspend (List) -> Response, + markSynced: suspend (T) -> Unit, + logLabel: String + ): Boolean { + if (unsyncedList.isEmpty()) return true + + val payload = mutableListOf() + val sentItems = mutableListOf() + unsyncedList.forEach { item -> + val model = mapToPayload(item) ?: return@forEach + payload.add(model) + sentItems.add(item) + } + + if (payload.isEmpty()) return false + + return try { + val response = post(payload) + val rawResponseBody = response.body()?.string() + val statusCode = rawResponseBody?.let { body -> + runCatching { JSONObject(body).optInt("statusCode") }.getOrNull() + } + if (!response.isSuccessful) { + Timber.w( + "Failed syncing %s: httpCode=%d, statusCode=%s, body=%s", + logLabel, + response.code(), + statusCode, + rawResponseBody + ) + false + } else if (statusCode == STATUS_OK) { + sentItems.forEach { markSynced(it) } + true + } else { + Timber.w( + "Failed syncing %s: httpCode=%d, statusCode=%s, body=%s", + logLabel, + response.code(), + statusCode, + rawResponseBody + ) + false + } + } catch (e: Exception) { + Timber.e(e, "Error syncing $logLabel") + false + } + } + + suspend fun pullWithRetry( + villageIds: String, + lastSyncDate: String, + fetch: suspend (VillageIdList) -> Response, + refreshToken: suspend () -> Boolean, + onDataArray: suspend (JSONArray) -> Unit, + logLabel: String + ): Boolean { + var backoffMs = INITIAL_BACKOFF_MS + + repeat(MAX_PULL_ATTEMPTS) { attempt -> + try { + val villageList = VillageIdList(convertStringToIntList(villageIds), lastSyncDate) + var response = fetch(villageList) + var attemptedTokenRefreshRetry = false + + while (true) { + if (!response.isSuccessful) { + val httpCode = response.code() + val rawResponseBody = response.errorBody()?.string() + Timber.w( + "HTTP failure while pulling %s: code=%d, body=%s, attempt=%d/%d", + logLabel, + httpCode, + rawResponseBody, + attempt + 1, + MAX_PULL_ATTEMPTS + ) + + if (httpCode in 400..499) return false + + if (attempt < MAX_PULL_ATTEMPTS - 1) { + delay(backoffMs) + backoffMs *= 2 + return@repeat + } + Timber.w("Max retry attempts reached for HTTP failure while pulling $logLabel") + return false + } + + val responseBody = response.body()?.string() + if (responseBody.isNullOrBlank()) { + Timber.e("Empty response body while pulling $logLabel, code=${response.code()}") + return false + } + + val jsonObj = try { + JSONObject(responseBody) + } catch (e: Exception) { + Timber.e(e, "Invalid JSON while pulling $logLabel, body=$responseBody") + return false + } + + if (jsonObj.isNull("statusCode")) { + Timber.e( + "Missing statusCode while pulling %s, code=%d, body=%s", + logLabel, + response.code(), + responseBody + ) + return false + } + + val statusCode = jsonObj.getInt("statusCode") + when (statusCode) { + STATUS_OK -> { + val dataArray = jsonObj.optJSONArray("data") + if (dataArray != null && dataArray.length() > 0) { + onDataArray(dataArray) + } + return true + } + STATUS_TOKEN_EXPIRED -> { + val tokenRefreshed = refreshToken() + if (!tokenRefreshed) { + Timber.w("Token refresh failed while pulling $logLabel") + return false + } + + if (attemptedTokenRefreshRetry) { + Timber.w("Token expired again after refresh retry while pulling $logLabel") + return false + } + + attemptedTokenRefreshRetry = true + response = fetch(villageList) + } + STATUS_NO_RECORDS -> return true + else -> { + Timber.w( + "Unknown statusCode while pulling %s: statusCode=%d, code=%d, body=%s", + logLabel, + statusCode, + response.code(), + responseBody + ) + return false + } + } + } + } catch (e: SocketTimeoutException) { + if (attempt < MAX_PULL_ATTEMPTS - 1) { + delay(backoffMs) + backoffMs *= 2 + return@repeat + } + Timber.e(e, "Socket timeout while pulling $logLabel after retries") + return false + } catch (e: Exception) { + Timber.e(e, "Error pulling $logLabel") + return false + } + return false + } + + return false + } + + suspend fun upsertByBeneficiaryRegId( + dataArray: JSONArray, + parseNetwork: (JSONObject) -> N, + beneficiaryRegId: (N) -> Long?, + resolvePatientId: suspend (Long) -> String?, + isExisting: suspend (String, N) -> Boolean, + insertNew: suspend (String, N) -> Unit + ) { + for (i in 0 until dataArray.length()) { + val networkObj = parseNetwork(dataArray.getJSONObject(i)) + val benRegId = beneficiaryRegId(networkObj) ?: continue + val patientId = resolvePatientId(benRegId) ?: continue + if (!isExisting(patientId, networkObj)) { + insertNew(patientId, networkObj) + } + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/BenFlowRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/BenFlowRepo.kt index 701623fed..06594f187 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/BenFlowRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/BenFlowRepo.kt @@ -3,8 +3,10 @@ package org.piramalswasthya.cho.repositories import android.util.Log import androidx.room.Transaction import com.google.gson.Gson -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.json.JSONArray import org.json.JSONObject import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.database.room.dao.BatchDao @@ -95,6 +97,18 @@ class BenFlowRepo @Inject constructor( private val caseRecordeDao: CaseRecordeDao, ) { + companion object { + // Max beneficiaries pulled concurrently in the per-record nurse/doctor case-data pass. + // Kept small so the server isn't hammered while still cutting wall-clock time dramatically. + private const val CLINICAL_PULL_CONCURRENCY = 3 + } + + private fun buildDrugStrength(strength: String?, unitOfMeasurement: String?): String? { + val cleanStrength = strength?.trim().orEmpty() + val cleanUnit = unitOfMeasurement?.trim().orEmpty() + return (cleanStrength + cleanUnit).trim().ifBlank { null } + } + suspend fun getBenFlowByBenRegIdAndBenVisitNo(beneficiaryRegID: Long, benVisitNo: Int) : BenFlow?{ return benFlowDao.getBenFlowByBenRegIdAndBenVisitNo(beneficiaryRegID, benVisitNo) @@ -171,14 +185,58 @@ class BenFlowRepo @Inject constructor( } - suspend fun downloadAndSyncFlowRecords(): Boolean { + suspend fun downloadAndSyncFlowRecords(pullClinical: Boolean = true, ignoreWatermark: Boolean = false): Boolean { val user = userRepo.getLoggedInUser() + val loggedInFacilityID = user?.facilityID + val parsedVillageIds = convertStringToIntList(user?.assignVillageIds ?: "") + val prefVillageIds = preferenceDao.getUserLocationData() + ?.villageList + ?.mapNotNull { it.districtBranchID.toIntOrNull() } + ?.distinct() + ?: emptyList() + val effectiveVillageIds: List = when { + parsedVillageIds.isNotEmpty() -> parsedVillageIds + prefVillageIds.isNotEmpty() -> prefVillageIds + user?.masterVillageID != null -> listOf(user.masterVillageID!!) + else -> emptyList() + } + // Upsync/form-save re-pull passes ignoreWatermark=true so a JUST-created benflow (whose + // server timestamp the incremental hour-truncated watermark would exclude) is reliably + // returned by the server. Safe because this pass runs pullClinical=false and does NOT + // advance the watermark (only PullClinicalRecordsWorker does), so incremental downsync is + // unaffected. + val lastSyncDate = if (ignoreWatermark) { + preferenceDao.getEpochBenflowSyncTime() + } else { + preferenceDao.getLastBenflowSyncTime() + } + +// if (effectiveVillageIds.isEmpty() || lastSyncDate.isBlank()) { +// Log.w( +// "BenFlowFacilityDebug", +// "downloadAndSyncFlowRecords: skipped due to incomplete payload villageIDs=$effectiveVillageIds lastSyncDate='$lastSyncDate' assignVillageIds='${user?.assignVillageIds}' masterVillageID=${user?.masterVillageID}" +// ) +// return false +// } val villageList = VillageIdList( - convertStringToIntList(user?.assignVillageIds ?: ""), - preferenceDao.getLastBenflowSyncTime() + effectiveVillageIds, + lastSyncDate + ) + Log.i( + "BenFlowFacilityDebug", + "downloadAndSyncFlowRecords: userFacility=${user?.facilityID}, userVan=${user?.vanId}, assignVillageIds='${user?.assignVillageIds}', prefVillageCount=${prefVillageIds.size}, finalVillageCount=${villageList.villageID.size}, lastSyncDate='${villageList.lastSyncDate}'" ) + if (loggedInFacilityID != null) { + val updatedRows = benFlowDao.backfillNullFacilityID(loggedInFacilityID) + Log.i( + "BenFlowFacilityDebug", + "downloadAndSyncFlowRecords: backfillNullFacilityID applied facilityID=$loggedInFacilityID updatedRows=$updatedRows" + ) + } else { + Log.w("BenFlowFacilityDebug", "downloadAndSyncFlowRecords: loggedIn user facilityID is null; backfill skipped") + } when(val response = getBenFlowCountToDownload(villageList)){ is NetworkResult.Success -> { @@ -192,10 +250,13 @@ class BenFlowRepo @Inject constructor( else -> {} } - when(val response = syncFlowIds(villageList)){ + when(val response = syncFlowIds(villageList, pullClinical)){ is NetworkResult.Success -> { - return true -// return (response.data as DownsyncSuccess).isSuccess + // Only report success (which lets PullBenFlowFromAmritWorker advance + // lastBenflowSyncTime) when EVERY benflow row matched a local patient. A partial run + // must not move the hour-truncated watermark, otherwise the server returns 0 rows on + // the next pull and the unmatched rows' module data is lost until data is cleared. + return (response.data as DownsyncSuccess).isSuccess } is NetworkResult.Error -> { Log.d("error code is", response.code.toString()) @@ -208,22 +269,29 @@ class BenFlowRepo @Inject constructor( else -> {} } return true + } @Transaction suspend fun refreshDoctorData(prescriptionCaseRecord: List?, investigationCaseRecord: InvestigationCaseRecord, diagnosisCaseRecords : List,patient: Patient, benFlow: BenFlow, patientVisitInfoSync: PatientVisitInfoSync,docData:DoctorDataDownSync){ + // Do not overwrite local case record while case is pending in pharmacist queue. + // Doctor edits before dispense are local-authoritative and must not be replaced by downsync payload. + val preserveLocalDoctorData = patientVisitInfoSync.pharmacist_flag == 1 + if (!preserveLocalDoctorData) { + prescriptionDao.deletePrescriptionByPatientIdAndBenVisitNo(patient.patientID, patientVisitInfoSync.benVisitNo) + prescriptionCaseRecord?.let { + prescriptionDao.insertAll(it) + } - prescriptionDao.deletePrescriptionByPatientIdAndBenVisitNo(patient.patientID, patientVisitInfoSync.benVisitNo) - prescriptionCaseRecord?.let { - prescriptionDao.insertAll(it) - } - - investigationDao.deleteInvestigationCaseRecordByPatientIdAndBenVisitNo(patient.patientID, patientVisitInfoSync.benVisitNo) - investigationDao.insertInvestigation(investigationCaseRecord) + investigationDao.deleteInvestigationCaseRecordByPatientIdAndBenVisitNo(patient.patientID, patientVisitInfoSync.benVisitNo) + investigationDao.insertInvestigation(investigationCaseRecord) - caseRecordeDao.deleteDiagnosisByPatientIdAndBenVisitNo(patient.patientID, patientVisitInfoSync.benVisitNo) - diagnosisCaseRecords.let { - caseRecordeDao.insertAll(it) + caseRecordeDao.deleteDiagnosisByPatientIdAndBenVisitNo(patient.patientID, patientVisitInfoSync.benVisitNo) + diagnosisCaseRecords.let { + caseRecordeDao.insertAll(it) + } + } else { + Timber.d("Preserving local doctor data for pending pharmacist case %s/%s", patient.patientID, patientVisitInfoSync.benVisitNo) } patientVisitInfoSyncDao.updateAfterDoctorDataDownSync(patientVisitInfoSync.doctorFlag!!, patientVisitInfoSync.patientID, patientVisitInfoSync.benVisitNo) @@ -255,7 +323,7 @@ class BenFlowRepo @Inject constructor( } } - + } @@ -350,20 +418,31 @@ class BenFlowRepo @Inject constructor( } - suspend fun checkAndDownsyncNurseData(benFlow: BenFlow, patient: Patient){ + // Returns true when there is nothing to pull or the sub-pull succeeded; false when an + // attempted nurse-data pull failed. The local sync-state gate protects locally edited rows + // from overwrite, while the local nurseFlag is intentionally not used as a server-side + // eligibility gate because fresh first-sync rows need to pass through here. + suspend fun checkAndDownsyncNurseData(benFlow: BenFlow, patient: Patient): Boolean { val patientVisitInfoSync = patientVisitInfoSyncDao.getPatientVisitInfoSyncByPatientIdAndBenVisitNo(patientID = patient.patientID, benVisitNo = benFlow.benVisitNo!!) - if(patientVisitInfoSync != null && benFlow.nurseFlag!! == 9 && patientVisitInfoSync.nurseFlag!! == 1){ + if (patientVisitInfoSync != null && patientVisitInfoSync.nurseDataSynced != SyncState.UNSYNCED) { patientVisitInfoSync.nurseFlag = 9 patientVisitInfoSync.doctorFlag = 1 - getAndSaveNurseDataToDb(benFlow, patient, patientVisitInfoSync) + return getAndSaveNurseDataToDb(benFlow, patient, patientVisitInfoSync) is NetworkResult.Success } + return true } - suspend fun checkAndDownsyncDoctorData(benFlow: BenFlow, patient: Patient){ + // See checkAndDownsyncNurseData: returns false only when an attempted doctor-data pull failed. + suspend fun checkAndDownsyncDoctorData(benFlow: BenFlow, patient: Patient): Boolean { val patientVisitInfoSync = patientVisitInfoSyncDao.getPatientVisitInfoSyncByPatientIdAndBenVisitNo(patientID = patient.patientID, benVisitNo = benFlow.benVisitNo!!) - if(patientVisitInfoSync != null && benFlow.doctorFlag!! > 1 && patientVisitInfoSync.doctorDataSynced != SyncState.UNSYNCED && patientVisitInfoSync.labDataSynced != SyncState.UNSYNCED){ + if ( + patientVisitInfoSync != null && + patientVisitInfoSync.nurseFlag == 9 && + patientVisitInfoSync.doctorDataSynced != SyncState.UNSYNCED && + patientVisitInfoSync.labDataSynced != SyncState.UNSYNCED + ) { patientVisitInfoSync.doctorFlag = benFlow.doctorFlag - getAndSaveDoctorDataToDb(benFlow, patient, patientVisitInfoSync) + return getAndSaveDoctorDataToDb(benFlow, patient, patientVisitInfoSync) is NetworkResult.Success } // if(patientVisitInfoSync != null && benFlow.doctorFlag!! > 1 && // ((benFlow.doctorFlag > patientVisitInfoSync.doctorFlag!!) || @@ -371,6 +450,7 @@ class BenFlowRepo @Inject constructor( // patientVisitInfoSync.doctorFlag = benFlow.doctorFlag // getAndSaveDoctorDataToDb(benFlow, patient, patientVisitInfoSync) // } + return true } suspend fun checkAndAddNewVisitInfo(benFlow: BenFlow, patient: Patient){ @@ -387,93 +467,237 @@ class BenFlowRepo @Inject constructor( patientID = patient.patientID, benVisitNo = benFlow.benVisitNo!!, pharmacistFlag = benFlow.pharmacist_flag!!, - visitCategory = benFlow.VisitCategory ?: "" + visitCategory = benFlow.VisitCategory?.takeIf { it.isNotBlank() } ?: "General OPD" ) } suspend fun insertBenFlow(benFlow: BenFlow) { + Log.d( + "BenFlowFacilityDebug", + "insertBenFlow(): benFlowID=${benFlow.benFlowID}, benRegID=${benFlow.beneficiaryRegID}, vanID=${benFlow.vanID}, facilityID=${benFlow.facilityID}" + ) benFlowDao.insertBenFlow(benFlow = benFlow) } - suspend fun syncFlowIds(villageList: VillageIdList): NetworkResult { + suspend fun syncFlowIds(villageList: VillageIdList, pullClinical: Boolean = true): NetworkResult { + val loggedInUser = userRepo.getLoggedInUser() return networkResultInterceptor { val response = apiService.getBenFlowRecords(villageList) val responseBody = response.body()?.string() + // Always log here: refreshTokenInterceptor only runs onSuccess when JSON statusCode==200. + try { + val jo = responseBody?.let { JSONObject(it) } + val sc = jo?.optInt("statusCode", -1) ?: -1 + val dataNode = jo?.opt("data") + val dataLen = when (dataNode) { + is JSONArray -> dataNode.length() + else -> -1 + } + Log.i( + "BenFlowFacilityDebug", + "syncFlowIds: httpCode=${response.code()} jsonStatusCode=$sc dataArrayLength=$dataLen bodyLen=${responseBody?.length ?: 0}" + ) + Timber.i( + "BenFlowFacilityDebug syncFlowIds: httpCode=%s jsonStatusCode=%s dataArrayLength=%s", + response.code(), + sc, + dataLen + ) + if (sc != 200) { + Log.w("BenFlowFacilityDebug", "syncFlowIds: skipping insert loop — JSON statusCode!=200 (see refreshTokenInterceptor). body snippet: ${responseBody?.take(400)}") + } + } catch (e: Exception) { + Log.e("BenFlowFacilityDebug", "syncFlowIds: failed to parse response for debug log", e) + } refreshTokenInterceptor( responseBody = responseBody, onSuccess = { val benflowArray = responseBody.let { JSONObject(it).getJSONArray("data") } + Log.i( + "BenFlowFacilityDebug", + "syncFlowIds onSuccess: processing ${benflowArray.length()} benflow row(s)" + ) var isSuccess = true var totalDownloaded = 0 + var lastReportedProgress = -1 + + // Beneficiaries whose per-record nurse/doctor case data still needs pulling. + // Collected during the (fast, DB-only) worklist pass below and pulled AFTER the + // loop with bounded concurrency, so the ~2 sequential API calls per beneficiary + // no longer run one beneficiary at a time. + val clinicalTargets = mutableListOf>() for (i in 0 until benflowArray.length()) { totalDownloaded++ if(WorkerUtils.totalRecordsToDownload > 0 && totalDownloaded <= WorkerUtils.totalRecordsToDownload){ - withContext(Dispatchers.Main) { - WorkerUtils.totalPercentageCompleted.value = ((totalDownloaded.toDouble() / WorkerUtils.totalRecordsToDownload.toDouble())*100).toInt() + val progressPercent = ((totalDownloaded.toDouble() / WorkerUtils.totalRecordsToDownload.toDouble()) * 100).toInt() + if (progressPercent != lastReportedProgress) { + lastReportedProgress = progressPercent + WorkerUtils.totalPercentageCompleted.postValue(progressPercent) } } try { - val data = benflowArray.getString(i) + val data = benflowArray.getJSONObject(i).toString() + val benFlowJson = benflowArray.getJSONObject(i) + Log.i( + "BenFlowFacilityDebug", + "syncFlowIds row: raw benflowID=${benFlowJson.optLong("benFlowID")}, hasFacilityID=${benFlowJson.has("facilityID")}, facilityID=${benFlowJson.opt("facilityID")}, vanID=${benFlowJson.opt("vanID")}" + ) val benFlow = Gson().fromJson(data, BenFlow::class.java) - val patient = patientRepo.getPatientByBenRegId(benFlow.beneficiaryRegID!!) - benFlowDao.insertBenFlow(benFlow) - if(patient != null){ - checkAndAddNewVisitInfo(benFlow, patient) - updateBenFlowId(benFlow, patient) - checkAndDownsyncNurseData(benFlow, patient) - checkAndDownsyncDoctorData(benFlow, patient) - } - val pvis = patientVisitInfoSyncDao.getPatientVisitInfoByPatientIdAndSyncState(patient!!.patientID, SyncState.SHARED_OFFLINE) - if(pvis!=null){ - val chiefComplaints = visitReasonsAndCategoriesDao.getChiefComplaintsByPatientId(patient.patientID, pvis.benVisitNo) - // Iterate through each chief complaint and update the benFlowID - if (chiefComplaints.isNotEmpty()) { - // Iterate through each chief complaint and update the benFlowID - chiefComplaints.forEach { complaint -> - // Update the benFlowID - val updatedComplaint = complaint.copy(benFlowID = pvis.benFlowID) - // Save the updated complaint back to the database - visitReasonsAndCategoriesDao.updateChiefComplaint(updatedComplaint) + val benFlowToInsert = + if (benFlow.facilityID == null || benFlow.facilityID == 0) { + val fallbackFacilityId = loggedInUser?.facilityID + if (fallbackFacilityId != null) { + Log.w( + "BenFlowFacilityDebug", + "syncFlowIds row: facilityID missing in benflow payload; applying fallback from logged-in user facilityID=$fallbackFacilityId for benFlowID=${benFlow.benFlowID}" + ) + benFlow.copy(facilityID = fallbackFacilityId) + } else { + benFlow } } else { - // Handle the case when chiefComplaints is empty or null - println("No chief complaints found for patientID: ${patient.patientID} and benVisitNo: ${pvis.benVisitNo}") + benFlow } - // Fetch visitDb and update if not null - val visitDb = visitReasonsAndCategoriesDao.getVisitDbByPatientId(pvis.patientID) - if (visitDb != null) { - val updatedVisitDb = visitDb.copy(benFlowID = pvis.benFlowID) - visitReasonsAndCategoriesDao.updateVisitDB(updatedVisitDb) - } else { - // Handle the case when visitDb is null - println("No visitDb found for patientID: ${pvis.patientID}") + Log.i( + "BenFlowFacilityDebug", + "syncFlowIds row: parsed benFlowID=${benFlow.benFlowID}, benRegID=${benFlow.beneficiaryRegID}, vanID=${benFlow.vanID}, facilityID=${benFlow.facilityID}, facilityIDToInsert=${benFlowToInsert.facilityID}" + ) + val benRegId = benFlowToInsert.beneficiaryRegID + if (benRegId == null) { + Log.w( + "BenFlowFacilityDebug", + "syncFlowIds row: benflow has null beneficiaryRegID; skipping unmatchable row (benFlowID=${benFlowToInsert.benFlowID})" + ) + insertBenFlow(benFlowToInsert) + continue + } + val patient = patientRepo.getPatientByBenRegId(benRegId) + insertBenFlow(benFlowToInsert) + if(patient != null){ + if (benFlowToInsert.reproductiveStatusId != null && + patient.statusOfWomanID != benFlowToInsert.reproductiveStatusId + ) { + patient.statusOfWomanID = benFlowToInsert.reproductiveStatusId + patientRepo.updateRecord(patient) } - // Fetch vitals and update if not null - val vitals = vitalsDao.getPatientVitalsByPatientID(pvis.patientID) - if (vitals != null) { - val updatedVitals = vitals.copy(benFlowID = pvis.benFlowID) - vitalsDao.updateVitals(updatedVitals) - } else { - // Handle the case when vitals is null - println("No vitals found for patientID: ${pvis.patientID}") + checkAndAddNewVisitInfo(benFlowToInsert, patient) + updateBenFlowId(benFlowToInsert, patient) + // The per-beneficiary nurse/doctor case-record pull (2 API calls each) + // is the slow part of the benflow downsync. It runs ONLY in the clinical + // pass (PullClinicalRecordsWorker) so the role worklists built above appear + // early. Defer the actual pulls until after this loop and run them with + // bounded concurrency instead of one beneficiary at a time. + if (pullClinical) { + clinicalTargets.add(benFlowToInsert to patient) + } + val pvis = patientVisitInfoSyncDao.getPatientVisitInfoByPatientIdAndSyncState( + patient.patientID, + SyncState.SHARED_OFFLINE + ) + if(pvis!=null){ + val chiefComplaints = visitReasonsAndCategoriesDao.getChiefComplaintsByPatientId(patient.patientID, pvis.benVisitNo) + // Iterate through each chief complaint and update the benFlowID + if (chiefComplaints.isNotEmpty()) { + // Iterate through each chief complaint and update the benFlowID + chiefComplaints.forEach { complaint -> + // Update the benFlowID + val updatedComplaint = complaint.copy(benFlowID = pvis.benFlowID) + // Save the updated complaint back to the database + visitReasonsAndCategoriesDao.updateChiefComplaint(updatedComplaint) + } + } else { + // Handle the case when chiefComplaints is empty or null + println("No chief complaints found for patientID: ${patient.patientID} and benVisitNo: ${pvis.benVisitNo}") + } + // Fetch visitDb and update if not null + val visitDb = visitReasonsAndCategoriesDao.getVisitDbByPatientId(pvis.patientID) + if (visitDb != null) { + val updatedVisitDb = visitDb.copy(benFlowID = pvis.benFlowID) + visitReasonsAndCategoriesDao.updateVisitDB(updatedVisitDb) + } else { + // Handle the case when visitDb is null + println("No visitDb found for patientID: ${pvis.patientID}") + } + // Fetch vitals and update if not null + val vitals = vitalsDao.getPatientVitalsByPatientID(pvis.patientID) + if (vitals != null) { + val updatedVitals = vitals.copy(benFlowID = pvis.benFlowID) + vitalsDao.updateVitals(updatedVitals) + } else { + // Handle the case when vitals is null + println("No vitals found for patientID: ${pvis.patientID}") + } + patientVisitInfoSyncDao.updatePatientNurseDataSyncSuccess(patientID = pvis.patientID, benVisitNo = pvis.benVisitNo) } - patientVisitInfoSyncDao.updatePatientNurseDataSyncSuccess(patientID = pvis.patientID, benVisitNo = pvis.benVisitNo) + } else { + // No local patient yet for this benRegID. The role worklists + // (Nurse/Doctor/Lab/Pharmacist) are built from PatientVisitInfoSync rows + // that are ONLY created inside the patient != null branch above, so skipping + // here leaves those modules empty for this beneficiary. This happens when the + // patient down-sync that inserts this benRegID hasn't finished yet (the two + // pulls run on concurrent worker chains). Mark the run incomplete so the + // caller does NOT advance the benflow watermark — the next pull re-downloads + // the full payload and matches the patient once it has landed. + isSuccess = false + Log.w( + "BenFlowFacilityDebug", + "syncFlowIds row: no local patient for benRegID=${benFlowToInsert.beneficiaryRegID} (benFlowID=${benFlowToInsert.benFlowID}); marking run incomplete to retry next sync" + ) } } catch (e : Exception){ isSuccess = false + Log.e("BenFlowFacilityDebug", "syncFlowIds(): failed to parse/insert benflow", e) + } + } + + // Bounded-concurrency per-beneficiary nurse/doctor pull. Nurse and doctor for the + // SAME beneficiary stay sequential (the doctor check reads flags the nurse pull may + // touch), but up to CLINICAL_PULL_CONCURRENCY beneficiaries are pulled in parallel, + // turning ~2*N serial round-trips into ~2*N/CONCURRENCY. A failed sub-pull does NOT + // throw (networkResultInterceptor swallows it into NetworkResult.Error), so mark the + // run incomplete here; otherwise the watermark advances while visit data is missing + // and the next pull never re-fetches it. + if (pullClinical && clinicalTargets.isNotEmpty()) { + Log.i( + "BenFlowFacilityDebug", + "syncFlowIds clinical pull: targetCount=${clinicalTargets.size}, concurrency=$CLINICAL_PULL_CONCURRENCY" + ) + val clinicalPullStartedAt = System.currentTimeMillis() + clinicalTargets.chunked(CLINICAL_PULL_CONCURRENCY).forEach { batch -> + coroutineScope { + batch.map { (bf, p) -> + async { + val nurseOk = checkAndDownsyncNurseData(bf, p) + val doctorOk = checkAndDownsyncDoctorData(bf, p) + Triple(bf, nurseOk, doctorOk) + } + }.awaitAll() + }.forEach { (bf, nurseOk, doctorOk) -> + if (!nurseOk || !doctorOk) { + isSuccess = false + Log.w( + "BenFlowFacilityDebug", + "syncFlowIds row: sub-pull failed (nurseOk=$nurseOk, doctorOk=$doctorOk) for benRegID=${bf.beneficiaryRegID} (benFlowID=${bf.benFlowID}); marking run incomplete to retry next sync" + ) + } + } } + Log.i( + "BenFlowFacilityDebug", + "syncFlowIds clinical pull completed in ${System.currentTimeMillis() - clinicalPullStartedAt}ms" + ) } NetworkResult.Success(DownsyncSuccess(isSuccess)) }, onTokenExpired = { val user = userRepo.getLoggedInUser()!! userRepo.refreshTokenTmc(user.userName, user.password) - syncFlowIds(villageList) + syncFlowIds(villageList, pullClinical) }, ) } @@ -482,7 +706,7 @@ class BenFlowRepo @Inject constructor( private suspend fun getBenFlowCountToDownload(villageList: VillageIdList): NetworkResult{ return networkResultInterceptor { - val response = apiService.getBeneficiariesCount(villageList) + val response = apiService.getBenFlowRecordCount(villageList) val responseBody = response.body()?.string() refreshTokenInterceptor( responseBody = responseBody, @@ -490,6 +714,10 @@ class BenFlowRepo @Inject constructor( val data = responseBody.let { JSONObject(it).getString("data") } val result = Gson().fromJson(data, CountDownSync::class.java) WorkerUtils.totalRecordsToDownload = result.response.toInt() + Log.i( + "BenFlowFacilityDebug", + "getBenFlowCountToDownload: serverCount=${result.response}, villageCount=${villageList.villageID.size}, lastSyncDate='${villageList.lastSyncDate}'" + ) NetworkResult.Success(NetworkResponse()) }, onTokenExpired = { @@ -599,48 +827,50 @@ class BenFlowRepo @Inject constructor( - suspend fun getAllocationItemForPharmacist(prescriptionDTO: PrescriptionDTO): NetworkResult { - return networkResultInterceptor { - val listAllocation: MutableList = mutableListOf() - prescriptionDTO.itemList.forEach { prescriptionItemDTO -> - val allocationItemDataRequest = AllocationItemDataRequest( - itemID = prescriptionItemDTO.drugID, - quantity = prescriptionItemDTO.qtyPrescribed - ) - listAllocation.add(allocationItemDataRequest) - } - Timber.d("******************* prescriptionBatchDTO Item DTO************** ",listAllocation) - val facilityID = userDao.getLoggedInUserFacilityID() - val response = apiService.getPharmacistAllocationItemList(listAllocation, facilityID) - val responseBody = response.body()?.string() - - refreshTokenInterceptor( - responseBody = responseBody, - onSuccess = { - val jsonObj = JSONObject(responseBody) - val data = jsonObj.getJSONObject("data").toString() - val prescriptionBatchApiDTO = Gson().fromJson(data, PrescriptionBatchApiDTO::class.java) - Timber.d("******************* prescriptionBatchDTO Item DTO************** ",prescriptionBatchApiDTO) - - NetworkResult.Success(NetworkResponse()) - }, - onTokenExpired = { - val user = userRepo.getLoggedInUser()!! - userRepo.refreshTokenTmc(user.userName, user.password) - getAllocationItemForPharmacist(prescriptionDTO) - }, + suspend fun getAllocationItemForPharmacist(prescriptionDTO: PrescriptionDTO): NetworkResult { + return networkResultInterceptor { + val listAllocation: MutableList = mutableListOf() + prescriptionDTO.itemList.forEach { prescriptionItemDTO -> + val allocationItemDataRequest = AllocationItemDataRequest( + itemID = prescriptionItemDTO.drugID, + quantity = prescriptionItemDTO.qtyPrescribed ) + listAllocation.add(allocationItemDataRequest) } + Timber.d("******************* prescriptionBatchDTO Item DTO************** ",listAllocation) + val facilityID = userDao.getLoggedInUserFacilityID() + val response = apiService.getPharmacistAllocationItemList(listAllocation, facilityID) + val responseBody = response.body()?.string() + + refreshTokenInterceptor( + responseBody = responseBody, + onSuccess = { + val jsonObj = JSONObject(responseBody) + val data = jsonObj.getJSONObject("data").toString() + val prescriptionBatchApiDTO = Gson().fromJson(data, PrescriptionBatchApiDTO::class.java) + Timber.d("******************* prescriptionBatchDTO Item DTO************** ",prescriptionBatchApiDTO) + + NetworkResult.Success(NetworkResponse()) + }, + onTokenExpired = { + val user = userRepo.getLoggedInUser()!! + userRepo.refreshTokenTmc(user.userName, user.password) + getAllocationItemForPharmacist(prescriptionDTO) + }, + ) + } } - suspend fun getStockDetailsOfSubStore(facilityID: Int): NetworkResult{ + suspend fun getStockDetailsOfSubStore(facilityID: Int): NetworkResult{ return networkResultInterceptor { val request = StockItemRequest( itemName = "%%", facilityID = facilityID.toString() ) + val response = apiService.getPharmacistStockItemList(request) val responseBody = response.body()?.string() + refreshTokenInterceptor( responseBody = responseBody, onSuccess = { @@ -648,6 +878,9 @@ class BenFlowRepo @Inject constructor( val jsonObj = JSONObject(responseBody) val dataArray = jsonObj.getJSONArray("data") val batchList = mutableListOf() + var successCount = 0 + var failedCount = 0 + for (i in 0 until dataArray.length()) { val item = dataArray.getJSONObject(i).toString() val apiItemStockEntry = Gson().fromJson(item, ApiItemStockEntry::class.java) @@ -661,19 +894,22 @@ class BenFlowRepo @Inject constructor( quantityInHand = apiItemStockEntry.quantityInHand ) - batchList.add(batch) + // Try to insert each batch individually to handle foreign key constraints + try { + batchDao.insertBatch(batch) + successCount++ + } catch (e: Exception) { + failedCount++ + Log.w("BatchAPI", "Failed to insert batch for itemID ${apiItemStockEntry.itemID}: ${e.message}. Item may not exist in ItemMasterList.") + } } - batchDao.insertAllBatches(batchList) - Log.d("Medicine list", "$batchList") NetworkResult.Success(NetworkResponse()) } catch (e: Exception) { - Log.e("Medicine list", "Error during parsing or deserialization", e) + NetworkResult.Error(0, e.message ?: "Failed to process batch data") } - NetworkResult.Success(NetworkResponse()) }, onTokenExpired = { - Log.d("Token Expired", "Token Expired") val user = userRepo.getLoggedInUser()!! userRepo.refreshTokenTmc(user.userName, user.password) getStockDetailsOfSubStore(facilityID) @@ -711,22 +947,55 @@ class BenFlowRepo @Inject constructor( patient: Patient, facilityID: Int, patientDoctorBundle: PatientDoctorBundle, - patientVisitInfoSync: PatientVisitInfoSync + patientVisitInfoSync: PatientVisitInfoSync, + replaceLatestPending: Boolean = false ) { try { - Log.d("Pharmacist","Deleting existing prescriptions for patientID: ${patient.patientID}, visitNo: ${patientVisitInfoSync.benVisitNo}") - prescriptionDao.deletePrescriptionByPatientIDAndBenVisitNo( - patient.patientID, - patientVisitInfoSync.benVisitNo - ) + val existingPrescriptions = + prescriptionDao.getPrescriptionsByPatientIdAndBenVisitNo( + patient.patientID, + patientVisitInfoSync.benVisitNo + ).orEmpty() + val existingPrimary = existingPrescriptions.firstOrNull { + (it.prescriptionID > 0L) || (it.visitCode > 0L) + } ?: existingPrescriptions.firstOrNull() + val benFlowForVisit = patientVisitInfoSync.benFlowID?.let { benFlowId -> + try { + benFlowDao.getBenFlowByBenFlowID(benFlowId) + } catch (e: Exception) { + null + } + } + if (replaceLatestPending && existingPrescriptions.isNotEmpty()) { + // Doctor is editing an already-pending pharmacist cycle: + // replace only latest pending draft and preserve older dispensed history. + Log.d( + "Pharmacist", + "Replacing latest pending prescription for patientID=${patient.patientID}, visitNo=${patientVisitInfoSync.benVisitNo}" + ) + prescriptionDao.deleteLatestPrescriptionByPatientIDAndBenVisitNo( + patient.patientID, + patientVisitInfoSync.benVisitNo + ) + } else { + // New cycle (or first local copy): append current medicines to preserve previous dispensed cycles. + Log.d( + "Pharmacist", + "Preserving existing prescription history for patientID=${patient.patientID}, visitNo=${patientVisitInfoSync.benVisitNo}" + ) + } Log.d("Pharmacist", "patient:in benflowrepo ${patient}") val prescription = Prescription( - prescriptionID = 0, // Placeholder - beneficiaryRegID = 0, // Placeholder, adjust as needed - visitCode = 0, // Placeholder, adjust as needed - consultantName = null, // Or a placeholder name + prescriptionID = existingPrimary?.prescriptionID + ?: patientVisitInfoSync.prescriptionID?.toLong() + ?: 0, + beneficiaryRegID = existingPrimary?.beneficiaryRegID ?: 0, + visitCode = existingPrimary?.visitCode + ?: benFlowForVisit?.visitCode + ?: 0, + consultantName = existingPrimary?.consultantName, patientID = patient.patientID, benFlowID = null, benVisitNo = patientVisitInfoSync.benVisitNo @@ -742,7 +1011,7 @@ class BenFlowRepo @Inject constructor( prescriptionID = prescriptionID, dose = it.strength, drugForm = it.itemFormName, - drugStrength = it.strength, + drugStrength = buildDrugStrength(it.strength, it.unitOfMeasurement), duration = it.duration, durationUnit = it.unit, frequency = it.frequency, @@ -776,13 +1045,13 @@ class BenFlowRepo @Inject constructor( } } } - } catch (e: Exception){ - e.printStackTrace() - Log.d("Pharmacist",e.toString()) - } - + } catch (e: Exception){ + e.printStackTrace() + Log.d("Pharmacist",e.toString()) } + } + fun parseDate(dateString: String): Date { val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) sdf.timeZone = TimeZone.getTimeZone("UTC") @@ -885,4 +1154,89 @@ class BenFlowRepo @Inject constructor( false } } -} \ No newline at end of file + + /** + * Refresh only header fields (consultantName, visitCode, prescriptionID) without touching local item list. + * This preserves local doctor edits while ensuring header data is visible. + */ + suspend fun refreshPrescriptionHeaderOnly(benVisitInfo: PatientDisplayWithVisitInfo): NetworkResult { + return networkResultInterceptor { + val benRegId = benVisitInfo.patient.beneficiaryRegID ?: return@networkResultInterceptor NetworkResult.Error(0, "Missing beneficiaryRegID") + val benVisitNo = benVisitInfo.benVisitNo ?: return@networkResultInterceptor NetworkResult.Error(0, "Missing benVisitNo") + val facilityID = userDao.getLoggedInUserFacilityID() + val benFlow = benFlowDao.getBenFlowByBenRegIdAndBenVisitNo(benRegId, benVisitNo) + ?: return@networkResultInterceptor NetworkResult.Error(0, "Missing benFlow") + + val visitCode = benFlow.visitCode + ?: return@networkResultInterceptor NetworkResult.Error(0, "Missing visitCode") + val request = PrescribedMedicineDataRequest( + beneficiaryRegID = benFlow.beneficiaryRegID!!, + facilityID = facilityID, + visitCode = visitCode + ) + + val response = apiService.getPharmacistPrescriptionList(request) + val responseBody = response.body()?.string() + + refreshTokenInterceptor( + responseBody = responseBody, + onSuccess = { + val jsonObj = JSONObject(responseBody) + val data = jsonObj.getJSONObject("data").toString() + val prescriptionDTO = Gson().fromJson(data, PrescriptionDTO::class.java) + prescriptionDao.updatePrescriptionHeader( + patientID = benVisitInfo.patient.patientID, + benVisitNo = benVisitNo, + prescriptionID = prescriptionDTO.prescriptionID, + visitCode = prescriptionDTO.visitCode, + consultantName = prescriptionDTO.consultantName + ) + NetworkResult.Success(NetworkResponse()) + }, + onTokenExpired = { + val user = userRepo.getLoggedInUser()!! + userRepo.refreshTokenTmc(user.userName, user.password) + refreshPrescriptionHeaderOnly(benVisitInfo) + }, + ) + } + } + + /** + * When doctor has submitted prescription locally (saved in Prescription_Cases_Recorde) but API + * has not synced yet, copy that data to the Prescription table so pharmacist module can show medicines. + */ + suspend fun copyPrescriptionFromCaseRecordToPharmacistTable( + benVisitInfo: PatientDisplayWithVisitInfo, + replaceLatestPending: Boolean? = null + ): Boolean { + return try { + val prescriptionCaseRecordVal = caseRecordeDao.getPrescriptionCaseRecordeByPatientIDAndBenVisitNo( + benVisitInfo.patient.patientID, + benVisitInfo.benVisitNo!! + ) + if (prescriptionCaseRecordVal.isNullOrEmpty()) return false + val sync = patientVisitInfoSyncDao.getPatientVisitInfoSyncByPatientIdAndBenVisitNo( + benVisitInfo.patient.patientID, + benVisitInfo.benVisitNo!! + ) ?: return false + val facilityID = userDao.getLoggedInUserFacilityID() + val bundle = PatientDoctorBundle( + patient = benVisitInfo.patient, + patientVisitInfoSync = sync, + prescriptionCaseRecordVal = prescriptionCaseRecordVal + ) + savePrescriptionListForPharmacist( + patient = benVisitInfo.patient, + facilityID = facilityID, + patientDoctorBundle = bundle, + patientVisitInfoSync = sync, + replaceLatestPending = replaceLatestPending ?: ((benVisitInfo.pharmacist_flag ?: 0) == 1) + ) + true + } catch (e: Exception) { + Timber.e(e, "copyPrescriptionFromCaseRecordToPharmacistTable") + false + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/BenVisitRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/BenVisitRepo.kt index 4bb2910ee..73e5bd697 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/BenVisitRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/BenVisitRepo.kt @@ -122,6 +122,7 @@ class BenVisitRepo @Inject constructor( } + private suspend fun registerLabData(labResultDTO: LabResultDTO): NetworkResult { return networkResultInterceptor { @@ -165,13 +166,25 @@ class BenVisitRepo @Inject constructor( } + // Returns true when every unsynced nurse record was resolved (pushed, or genuinely had nothing + // to do). Returns false when at least one record was SKIPPED because a transient precondition + // had not landed yet — the beneficiary is not yet registered on the server (beneficiaryRegID + // null) or the server BenFlow (nurseFlag==1) has not been pulled into the local DB. The caller + // (PushBenVisitInfoToAmrit) maps false to a bounded Result.retry() so the push re-runs once the + // prerequisite registration/benflow pull catches up, instead of leaving the visit UNSYNCED until + // the next periodic down-sync. suspend fun processUnsyncedNurseData() : Boolean{ val patientNurseDataUnSyncList = patientVisitInfoSyncRepo.getPatientNurseDataUnsynced() val user = userRepo.getLoggedInUser() + var anyPending = false patientNurseDataUnSyncList.forEach { + if(it.patient.beneficiaryRegID == null){ + anyPending = true + Timber.w("Nurse data upsync SKIPPED: beneficiaryRegID is null for patientID=${it.patient.patientID}, benVisitNo=${it.patientVisitInfoSync.benVisitNo}. Beneficiary not yet registered on server; clinical data stays UNSYNCED.") + } if(it.patient.beneficiaryRegID != null){ withContext(Dispatchers.IO){ @@ -217,12 +230,15 @@ class BenVisitRepo @Inject constructor( else -> { } } - } else { } + } else { + anyPending = true + Timber.w("Nurse data upsync SKIPPED: no matching BenFlow with nurseFlag==1 for patientID=${it.patient.patientID}, benRegID=${it.patient.beneficiaryRegID}, benVisitNo=${it.patientVisitInfoSync.benVisitNo} (benFlow=$benFlow). Clinical data stays UNSYNCED.") + } } } } - return true + return !anyPending } @@ -234,6 +250,9 @@ class BenVisitRepo @Inject constructor( patientDoctorDataUnSyncList.forEach { + if(it.patient.beneficiaryRegID == null){ + Timber.w("Doctor data (pending test) upsync SKIPPED: beneficiaryRegID is null for patientID=${it.patient.patientID}, benVisitNo=${it.patientVisitInfoSync.benVisitNo}. Beneficiary not yet registered on server; clinical data stays UNSYNCED.") + } if(it.patient.beneficiaryRegID != null){ withContext(Dispatchers.IO){ @@ -288,6 +307,9 @@ class BenVisitRepo @Inject constructor( patientDoctorDataUnSyncList.forEach { + if(it.patient.beneficiaryRegID == null){ + Timber.w("Doctor data (without test) upsync SKIPPED: beneficiaryRegID is null for patientID=${it.patient.patientID}, benVisitNo=${it.patientVisitInfoSync.benVisitNo}. Beneficiary not yet registered on server; clinical data stays UNSYNCED.") + } if(it.patient.beneficiaryRegID != null){ withContext(Dispatchers.IO){ @@ -342,6 +364,9 @@ class BenVisitRepo @Inject constructor( patientDoctorDataUnSyncList.forEach { + if(it.patient.beneficiaryRegID == null){ + Timber.w("Doctor data (after test) upsync SKIPPED: beneficiaryRegID is null for patientID=${it.patient.patientID}, benVisitNo=${it.patientVisitInfoSync.benVisitNo}. Beneficiary not yet registered on server; clinical data stays UNSYNCED.") + } if(it.patient.beneficiaryRegID != null){ withContext(Dispatchers.IO){ @@ -441,7 +466,7 @@ class BenVisitRepo @Inject constructor( visitCode = benFlow.visitCode, providerServiceMapID = benFlow.providerServiceMapId, specialist_flag = null, - vanID = benFlow.vanID, + facilityID = benFlow.facilityID ?: user.facilityID, parkingPlaceID = benFlow.parkingPlaceID ) @@ -566,11 +591,13 @@ class BenVisitRepo @Inject constructor( val pharmacistPatientIssueDataRequest = PharmacistItemStockExitDataRequest( itemID = prescriptionItemDTO.drugID, itemStockEntryID = item.itemStockEntryID, - quantity = prescriptionItemDTO.qtyPrescribed, + quantity = item.qty, createdBy = user?.userName!! ) - itemStockExitList+=pharmacistPatientIssueDataRequest + if (item.qty > 0) { + itemStockExitList+=pharmacistPatientIssueDataRequest + } } } @@ -578,7 +605,6 @@ class BenVisitRepo @Inject constructor( val pharmacistPatientIssueDataRequest = PharmacistPatientIssueDataRequest( issuedBy = "MMU", visitCode = benFlow.visitCode, - facilityID = userDao.getLoggedInUserFacilityID(), age = it.patient.age, beneficiaryID = it.patient.beneficiaryID, benRegID = it.patient.beneficiaryRegID!!, @@ -593,7 +619,7 @@ class BenVisitRepo @Inject constructor( visitID = benFlow.benVisitID, visitDate = benFlow.visitDate, parkingPlaceID = benFlow.parkingPlaceID, - vanID = benFlow.vanID, + facilityID = benFlow.facilityID ?: user?.facilityID, itemStockExit = itemStockExitList ) if (pharmacistPatientIssueDataRequest.itemStockExit.isNotEmpty()) { @@ -648,11 +674,13 @@ class BenVisitRepo @Inject constructor( val pharmacistPatientIssueDataRequest = PharmacistItemStockExitDataRequest( itemID = prescriptionItemDTO.drugID, itemStockEntryID = item.itemStockEntryID, - quantity = prescriptionItemDTO.qtyPrescribed, + quantity = item.qty, createdBy = user?.userName!! ) - itemStockExitList+=pharmacistPatientIssueDataRequest + if (item.qty > 0) { + itemStockExitList+=pharmacistPatientIssueDataRequest + } } } @@ -660,7 +688,6 @@ class BenVisitRepo @Inject constructor( val pharmacistPatientIssueDataRequest = PharmacistPatientIssueDataRequest( issuedBy = "MMU", visitCode = benFlow.visitCode, - facilityID = userDao.getLoggedInUserFacilityID(), age = benVisitInfo.patient.age, beneficiaryID = benVisitInfo.patient.beneficiaryID, benRegID = benVisitInfo.patient.beneficiaryRegID!!, @@ -675,7 +702,7 @@ class BenVisitRepo @Inject constructor( visitID = benFlow.benVisitID, visitDate = benFlow.visitDate, parkingPlaceID = benFlow.parkingPlaceID, - vanID = benFlow.vanID, + facilityID = benFlow.facilityID ?: user?.facilityID, itemStockExit = itemStockExitList ) @@ -700,4 +727,4 @@ class BenVisitRepo @Inject constructor( } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/CaseRecordeRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/CaseRecordeRepo.kt index d0f74b04c..36fcc2643 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/CaseRecordeRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/CaseRecordeRepo.kt @@ -26,11 +26,20 @@ import javax.inject.Inject class CaseRecordeRepo @Inject constructor( private val caseRecordDao: CaseRecordeDao, private val benFlowDao: BenFlowDao, + private val procedureRepo: ProcedureRepo, ) { suspend fun saveInvestigationToCatche(investigationCaseRecord: InvestigationCaseRecord) { try{ withContext(Dispatchers.IO){ caseRecordDao.insertInvestigationCaseRecord(investigationCaseRecord) + // Copy prescribed lab procedures from master so lab technician can render form from DB without API + investigationCaseRecord.benVisitNo?.let { benVisitNo -> + procedureRepo.copyProceduresFromMasterForVisit( + investigationCaseRecord.patientID, + benVisitNo, + investigationCaseRecord.newTestIds + ) + } } } catch (e: Exception){ Timber.d("Error in saving Investigation $e") @@ -73,17 +82,20 @@ class CaseRecordeRepo @Inject constructor( return caseRecordDao.getPrescriptionCasesRecordId(prescriptionId) } suspend fun getPrescriptionByPatientIDAndVisitNumber(benVisitInfo: PatientDisplayWithVisitInfo): List { - return caseRecordDao.getPrescriptionByPatientIDAndBenVisitNo(benVisitInfo.patient.patientID, benVisitInfo.benVisitNo!!) + val visitNo = benVisitInfo.benVisitNo ?: return emptyList() + return caseRecordDao.getPrescriptionByPatientIDAndBenVisitNo(benVisitInfo.patient.patientID, visitNo) } fun getDiagnosis(diagnosisId:String): LiveData { return caseRecordDao.getDiagnosisCasesRecordById(diagnosisId) } suspend fun getDiagnosisByPatientIDAndVisitNumber(benVisitInfo: PatientDisplayWithVisitInfo): List { - return caseRecordDao.getDiagnosisByPatientIDAndBenVisitNo(benVisitInfo.patient.patientID, benVisitInfo.benVisitNo!!) + val visitNo = benVisitInfo.benVisitNo ?: return emptyList() + return caseRecordDao.getDiagnosisByPatientIDAndBenVisitNo(benVisitInfo.patient.patientID, visitNo) } suspend fun getInvestigationCasesRecordByPatientIDAndVisitNumber(benVisitInfo: PatientDisplayWithVisitInfo): InvestigationCaseRecord? { - return caseRecordDao.getPrescriptionCasesRecordByPatientIDAndBenVisitNo(benVisitInfo.patient.patientID, benVisitInfo.benVisitNo!!) + val visitNo = benVisitInfo.benVisitNo ?: return null + return caseRecordDao.getPrescriptionCasesRecordByPatientIDAndBenVisitNo(benVisitInfo.patient.patientID, visitNo) } suspend fun updateBenIdAndBenRegId(beneficiaryID: Long, beneficiaryRegID: Long, patientID: String){ diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/CbacRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/CbacRepo.kt index a38e82dc4..44f4d3afe 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/CbacRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/CbacRepo.kt @@ -113,6 +113,11 @@ class CbacRepo @Inject constructor( return withContext(Dispatchers.IO) { val user = userRepo.getLoggedInUser() ?: throw IllegalStateException("No user logged in!!") + val facilityID = user.facilityID + if (facilityID == null) { + Timber.w("pushUnSyncedCbacRecords: logged-in user has no facilityID; skipping CBAC sync") + return@withContext false + } val cbacCacheList: List = cbacDao.getAllUnprocessedCbac(SyncState.UNSYNCED) @@ -140,14 +145,14 @@ class CbacRepo @Inject constructor( visitDetails = VisitDetailsWrapper( visitDetails = CbacVisitDetails( beneficiaryRegID = patient.beneficiaryRegID!!, - providerServiceMapID = userRepo.getLoggedInUser()!!.serviceMapId, + providerServiceMapID = user.serviceMapId, visitNo = null , visitReason = "New Chief Complaint", visitCategory = "NCD screening", IdrsOrCbac = "CBAC", createdBy = user.userName, - vanID = userRepo.getLoggedInUser()!!.vanId, - parkingPlaceID = userRepo.getLoggedInUser()!!.parkingPlaceId, + facilityID = facilityID, + parkingPlaceID = user.parkingPlaceId, subVisitCategory = null, pregnancyStatus = null, followUpForFpMethod = null, @@ -164,12 +169,12 @@ class CbacRepo @Inject constructor( benFlowID = patient.beneficiaryRegID!!, beneficiaryID = patient.beneficiaryID!!, sessionID = 3, - parkingPlaceID = userRepo.getLoggedInUser()!!.parkingPlaceId, + parkingPlaceID = user.parkingPlaceId, createdBy = user.userName, - vanID = userRepo.getLoggedInUser()!!.vanId, + facilityID = facilityID, beneficiaryRegID = patient.beneficiaryRegID!!, benVisitID = null, - providerServiceMapID = userRepo.getLoggedInUser()!!.serviceMapId + providerServiceMapID = user.serviceMapId ) try { diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/CphcDetailsRepository.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/CphcDetailsRepository.kt new file mode 100644 index 000000000..6c8ad7e56 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/CphcDetailsRepository.kt @@ -0,0 +1,76 @@ +package org.piramalswasthya.cho.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.piramalswasthya.cho.database.room.dao.EarDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.ElderlyHealthAssessmentDao +import org.piramalswasthya.cho.database.room.dao.MentalHealthScreeningDao +import org.piramalswasthya.cho.database.room.dao.NoseDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.OralHealthDao +import org.piramalswasthya.cho.database.room.dao.OphthalmicDao +import org.piramalswasthya.cho.database.room.dao.PainAndSymptomAssessmentDao +import org.piramalswasthya.cho.database.room.dao.PsychosocialCaregiverSupportDao +import org.piramalswasthya.cho.database.room.dao.ThroatDiagnosisAssessmentDao +import org.piramalswasthya.cho.database.room.dao.VisitReasonsAndCategoriesDao +import org.piramalswasthya.cho.model.ChiefComplaintDB +import org.piramalswasthya.cho.model.VisitDB +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CphcDetailsRepository @Inject constructor( + private val visitReasonsAndCategoriesDao: VisitReasonsAndCategoriesDao, + private val earDiagnosisAssessmentDao: EarDiagnosisAssessmentDao, + private val noseDiagnosisAssessmentDao: NoseDiagnosisAssessmentDao, + private val throatDiagnosisAssessmentDao: ThroatDiagnosisAssessmentDao, + private val oralHealthDao: OralHealthDao, + private val ophthalmicDao: OphthalmicDao, + private val elderlyHealthAssessmentDao: ElderlyHealthAssessmentDao, + private val mentalHealthScreeningDao: MentalHealthScreeningDao, + private val painAndSymptomAssessmentDao: PainAndSymptomAssessmentDao, + private val psychosocialCaregiverSupportDao: PsychosocialCaregiverSupportDao, +) { + + suspend fun getLatestVisitDb(patientID: String, benVisitNo: Int): VisitDB? { + return withContext(Dispatchers.IO) { + visitReasonsAndCategoriesDao.getLatestVisitDbByPatientIDAndBenVisitNo(patientID, benVisitNo) + } + } + + suspend fun replaceVisitAndChiefComplaints( + visitDB: VisitDB, + chiefComplaints: List, + patientID: String, + benVisitNo: Int, + ) { + withContext(Dispatchers.IO) { + visitReasonsAndCategoriesDao.deleteVisitDbByPatientIdAndBenVisitNo(patientID, benVisitNo) + visitReasonsAndCategoriesDao.deleteChiefComplaintsByPatientIdAndBenVisitNo(patientID, benVisitNo) + visitReasonsAndCategoriesDao.insertVisitDB(visitDB) + if (chiefComplaints.isNotEmpty()) { + visitReasonsAndCategoriesDao.insertAll(chiefComplaints) + } + } + } + + suspend fun replaceVisitDb(visitDB: VisitDB, patientID: String, benVisitNo: Int) { + withContext(Dispatchers.IO) { + visitReasonsAndCategoriesDao.deleteVisitDbByPatientIdAndBenVisitNo(patientID, benVisitNo) + visitReasonsAndCategoriesDao.insertVisitDB(visitDB) + } + } + + suspend fun clearAssessmentsForVisit(patientID: String, benVisitNo: Int) { + withContext(Dispatchers.IO) { + earDiagnosisAssessmentDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + noseDiagnosisAssessmentDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + throatDiagnosisAssessmentDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + oralHealthDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + ophthalmicDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + elderlyHealthAssessmentDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + mentalHealthScreeningDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + painAndSymptomAssessmentDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + psychosocialCaregiverSupportDao.deleteByPatientIdAndVisitNo(patientID, benVisitNo) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/DeliveryOutcomeRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/DeliveryOutcomeRepo.kt index 6e8ef7f49..2b068ce6e 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/DeliveryOutcomeRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/DeliveryOutcomeRepo.kt @@ -1,36 +1,56 @@ package org.piramalswasthya.cho.repositories -import androidx.lifecycle.LiveData import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.database.room.dao.DeliveryOutcomeDao +import org.piramalswasthya.cho.database.room.dao.PatientDao import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao -import org.piramalswasthya.cho.helpers.Konstants -import org.piramalswasthya.cho.helpers.setToStartOfTheDay import org.piramalswasthya.cho.model.DeliveryOutcomeCache import org.piramalswasthya.cho.model.DeliveryOutcomePost +import org.piramalswasthya.cho.model.Patient import org.piramalswasthya.cho.network.AmritApiService +import org.piramalswasthya.cho.network.VillageIdList //import org.piramalswasthya.cho.network.GetDataPaginatedRequest import timber.log.Timber -import java.io.IOException import java.net.SocketTimeoutException -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Locale -import java.util.concurrent.TimeUnit import javax.inject.Inject class DeliveryOutcomeRepo @Inject constructor( private val preferenceDao: PreferenceDao, private val amritApiService: AmritApiService, private val userRepo: UserRepo, - private val deliveryOutcomeDao: DeliveryOutcomeDao + private val deliveryOutcomeDao: DeliveryOutcomeDao, + private val patientDao: PatientDao ) { + private fun buildVillageList(assignVillageIds: String?): VillageIdList { + return VillageIdList( + RepositorySyncUtils.parseVillageIds(assignVillageIds ?: ""), + preferenceDao.getLastPatientSyncTime() + ) + } + + private fun extractDataArray(jsonObj: JSONObject): JSONArray { + return when (val dataNode = jsonObj.opt("data")) { + is JSONArray -> dataNode + is JSONObject -> dataNode.optJSONArray("data") + else -> null + } ?: JSONArray() + } + + private suspend fun refreshAndRetryIfNeeded( + responseStatusCode: Int, + userName: String, + password: String + ): Boolean { + return responseStatusCode == 5002 && userRepo.refreshTokenTmc(userName, password) + } + suspend fun getDeliveryOutcome(patientID: String): DeliveryOutcomeCache? { return withContext(Dispatchers.IO) { deliveryOutcomeDao.getDeliveryOutcome(patientID) @@ -39,8 +59,289 @@ class DeliveryOutcomeRepo @Inject constructor( suspend fun saveDeliveryOutcome(deliveryOutcomeCache: DeliveryOutcomeCache) { withContext(Dispatchers.IO) { - deliveryOutcomeDao.saveDeliveryOutcome(deliveryOutcomeCache) + // Upsert pattern: check for existing record by patientID and reuse its id if found + val existing = deliveryOutcomeDao.getDeliveryOutcome(deliveryOutcomeCache.patientID) + + if (existing != null) { + // Reuse existing record's id and update + val updatedCache = deliveryOutcomeCache.copy(id = existing.id) + val rowsAffected = deliveryOutcomeDao.updateDeliveryOutcome(updatedCache) + if (rowsAffected == 0) { + // Update failed (record not found by id), insert instead + deliveryOutcomeDao.saveDeliveryOutcome(updatedCache) + } + } else if (deliveryOutcomeCache.id != 0L) { + // Has id but not found in DB, try update first + val rowsAffected = deliveryOutcomeDao.updateDeliveryOutcome(deliveryOutcomeCache) + if (rowsAffected == 0) { + // Update failed (id doesn't exist), insert instead + deliveryOutcomeDao.saveDeliveryOutcome(deliveryOutcomeCache) + } + } else { + // New record, insert + deliveryOutcomeDao.saveDeliveryOutcome(deliveryOutcomeCache) + } + } + } + + /** + * Ensures an active delivery-outcome row exists so postnatal beneficiaries + * registered/edited via beneficiary form can enter the PNC workflow. + */ + suspend fun ensureActiveDeliveryOutcomeForPnc(patient: Patient) { + withContext(Dispatchers.IO) { + val existing = deliveryOutcomeDao.getDeliveryOutcome(patient.patientID) + if (existing != null) { + if (!existing.isActive) { + existing.isActive = true + existing.syncState = SyncState.UNSYNCED + deliveryOutcomeDao.updateDeliveryOutcome(existing) + } + return@withContext + } + + val userName = userRepo.getLoggedInUser()?.userName?.takeIf { it.isNotBlank() } ?: "system" + deliveryOutcomeDao.saveDeliveryOutcome( + DeliveryOutcomeCache( + patientID = patient.patientID, + isActive = true, + dateOfDelivery = null, + createdBy = userName, + updatedBy = userName, + syncState = SyncState.UNSYNCED + ) + ) + } + } + + suspend fun processNewDeliveryOutcomes(): Boolean { + return withContext(Dispatchers.IO) { + val deliveryList = deliveryOutcomeDao.getAllUnprocessedDeliveryOutcomes() + if (deliveryList.isEmpty()) { + Timber.d("No unsynced Delivery Outcome records found for upload") + return@withContext true + } + + var hasFailures = false + val payload = mutableSetOf() + + deliveryList.forEach { record -> + payload.clear() + val patient = patientDao.getPatient(record.patientID) + val benId = patient.beneficiaryID + if (benId == null || benId == 0L) { + Timber.w("Skipping Delivery Outcome upload for ${record.patientID}: beneficiaryID missing") + hasFailures = true + return@forEach + } + + record.syncState = SyncState.SYNCING + deliveryOutcomeDao.updateDeliveryOutcome(record) + payload.add(record.asPostModel(benId)) + + val uploaded = postDeliveryOutcomeToAmritServer(payload) + if (uploaded) { + record.processed = "P" + record.syncState = SyncState.SYNCED + } else { + record.syncState = SyncState.UNSYNCED + hasFailures = true + } + deliveryOutcomeDao.updateDeliveryOutcome(record) + } + + return@withContext !hasFailures + } + } + + private suspend fun postDeliveryOutcomeToAmritServer(payload: MutableSet): Boolean { + if (payload.isEmpty()) return false + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + while (true) { + try { + val response = amritApiService.postDeliveryOutcomeForm(payload.toList()) + val statusCode = response.code() + if (statusCode != 200) { + Timber.w("Bad response from server for DeliveryOutcome saveAll, code=$statusCode") + return false + } + + val responseString = response.body()?.string() + if (responseString.isNullOrBlank()) { + Timber.d("DeliveryOutcome saveAll succeeded with empty response body") + return true + } + + val jsonObj = try { + JSONObject(responseString) + } catch (e: Exception) { + Timber.w(e, "DeliveryOutcome saveAll returned non-JSON body, treating as success: $responseString") + return true + } + + val responseStatusCode = jsonObj.optInt("statusCode", 200) + val errorMessage = jsonObj.optString("errorMessage") + when (responseStatusCode) { + 200 -> return true + else -> { + if (refreshAndRetryIfNeeded(responseStatusCode, user.userName, user.password)) { + continue + } + Timber.w("DeliveryOutcome saveAll failed: $errorMessage") + return false + } + } + } catch (e: SocketTimeoutException) { + Timber.d("Caught timeout for DeliveryOutcome saveAll $e; retrying") + continue + } catch (e: JSONException) { + Timber.d("Caught JSON exception for DeliveryOutcome saveAll $e") + return false + } + } + } + + suspend fun pullDeliveryOutcomesFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + while (true) { + try { + val villageList = buildVillageList(user.assignVillageIds) + val response = amritApiService.getAllDeliveryOutcomes(villageList) + if (response.code() != 200) { + Timber.w("Bad response from server for DeliveryOutcome getAll: $response") + return@withContext false + } + + val responseString = response.body()?.string() + if (responseString.isNullOrBlank()) { + Timber.w("Empty response body for DeliveryOutcome getAll") + return@withContext false + } + + val jsonObj = JSONObject(responseString) + val responseStatusCode = jsonObj.optInt("statusCode", 200) + val errorMessage = jsonObj.optString("errorMessage") + when (responseStatusCode) { + 200 -> { + val dataArray = extractDataArray(jsonObj) + val gson = Gson() + var savedCount = 0 + var skippedNoPatientCount = 0 + for (i in 0 until dataArray.length()) { + val networkModel = gson.fromJson( + dataArray.getJSONObject(i).toString(), + DeliveryOutcomePost::class.java + ) + if (networkModel.benId == 0L) { + Timber.w("Skipping DeliveryOutcome getAll item with invalid benId at index=$i") + continue + } + + val patient = patientDao.getPatientByAnyBeneficiaryId(networkModel.benId) + ?: ensurePatientPlaceholderForDeliveryOutcome(networkModel.benId) + if (patient == null) { + Timber.w("No local patient found for DeliveryOutcome benId=${networkModel.benId}, skipping") + skippedNoPatientCount++ + continue + } + + val incoming = networkModel.toDeliveryCache(patient.patientID).copy( + processed = "P", + syncState = SyncState.SYNCED, + updatedDate = System.currentTimeMillis(), + updatedBy = networkModel.updatedBy.ifBlank { user.userName } + ) + val existing = deliveryOutcomeDao.getDeliveryOutcome(patient.patientID) + val merged = if (existing != null) { + mergeDeliveryOutcome(existing, incoming) + } else incoming + + saveDeliveryOutcome(merged) + savedCount++ + } + Timber.d("DeliveryOutcome getAll downsync completed, saved=$savedCount skippedNoPatient=$skippedNoPatientCount received=${dataArray.length()}") + return@withContext true + } + 5000 -> { + Timber.d("No Delivery Outcome records found on server") + return@withContext true + } + else -> { + if (refreshAndRetryIfNeeded(responseStatusCode, user.userName, user.password)) { + continue + } + Timber.w("DeliveryOutcome getAll failed: $errorMessage") + return@withContext false + } + } + } catch (e: SocketTimeoutException) { + Timber.d("Caught timeout for DeliveryOutcome getAll $e; retrying") + continue + } catch (e: JSONException) { + Timber.d("Caught JSON exception for DeliveryOutcome getAll $e") + return@withContext false + } + } + return@withContext false } } -} \ No newline at end of file + private suspend fun ensurePatientPlaceholderForDeliveryOutcome(benId: Long): Patient? { + if (benId <= 0L) return null + val existing = patientDao.getPatientByAnyBeneficiaryId(benId) + if (existing != null) return existing + Timber.w("Skipping DeliveryOutcome downsync record: patient not found for benId=$benId") + return null + } + + private fun mergeDeliveryOutcome( + existing: DeliveryOutcomeCache, + incoming: DeliveryOutcomeCache + ): DeliveryOutcomeCache { + return incoming.copy( + id = existing.id, + patientID = existing.patientID, + isActive = incoming.isActive, + dateOfDelivery = incoming.dateOfDelivery ?: existing.dateOfDelivery, + timeOfDelivery = incoming.timeOfDelivery ?: existing.timeOfDelivery, + placeOfDelivery = incoming.placeOfDelivery ?: existing.placeOfDelivery, + typeOfDelivery = incoming.typeOfDelivery ?: existing.typeOfDelivery, + hadComplications = incoming.hadComplications ?: existing.hadComplications, + complication = incoming.complication ?: existing.complication, + causeOfDeath = incoming.causeOfDeath ?: existing.causeOfDeath, + otherCauseOfDeath = incoming.otherCauseOfDeath ?: existing.otherCauseOfDeath, + otherComplication = incoming.otherComplication ?: existing.otherComplication, + deliveryOutcome = incoming.deliveryOutcome ?: existing.deliveryOutcome, + liveBirth = incoming.liveBirth ?: existing.liveBirth, + stillBirth = incoming.stillBirth ?: existing.stillBirth, + dateOfDischarge = incoming.dateOfDischarge ?: existing.dateOfDischarge, + timeOfDischarge = incoming.timeOfDischarge ?: existing.timeOfDischarge, + isJSYBenificiary = incoming.isJSYBenificiary ?: existing.isJSYBenificiary, + gestationalAgeAtDelivery = incoming.gestationalAgeAtDelivery ?: existing.gestationalAgeAtDelivery, + deliveryConductedBy = incoming.deliveryConductedBy ?: existing.deliveryConductedBy, + modeOfDelivery = incoming.modeOfDelivery ?: existing.modeOfDelivery, + indicationForLSCS = incoming.indicationForLSCS ?: existing.indicationForLSCS, + indicationForLSCSOther = incoming.indicationForLSCSOther ?: existing.indicationForLSCSOther, + privateHospitalName = incoming.privateHospitalName ?: existing.privateHospitalName, + motherCondition = incoming.motherCondition ?: existing.motherCondition, + maternalComplications = incoming.maternalComplications ?: existing.maternalComplications, + motherCurrentlyAdmitted = incoming.motherCurrentlyAdmitted ?: existing.motherCurrentlyAdmitted, + isDeath = incoming.isDeath ?: existing.isDeath, + isDeathValue = incoming.isDeathValue ?: existing.isDeathValue, + dateOfDeath = incoming.dateOfDeath ?: existing.dateOfDeath, + placeOfDeath = incoming.placeOfDeath ?: existing.placeOfDeath, + placeOfDeathId = incoming.placeOfDeathId ?: existing.placeOfDeathId, + otherPlaceOfDeath = incoming.otherPlaceOfDeath ?: existing.otherPlaceOfDeath, + processed = incoming.processed ?: existing.processed, + createdBy = if (existing.createdBy.isNotBlank()) existing.createdBy else incoming.createdBy, + createdDate = if (existing.createdDate > 0L) existing.createdDate else incoming.createdDate, + updatedBy = incoming.updatedBy, + updatedDate = incoming.updatedDate, + syncState = incoming.syncState + ) + } + +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/DoctorMasterDataMaleRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/DoctorMasterDataMaleRepo.kt index 5a9bfd356..8aae8f3cd 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/DoctorMasterDataMaleRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/DoctorMasterDataMaleRepo.kt @@ -54,7 +54,7 @@ class DoctorMasterDataMaleRepo @Inject constructor( val response = amritApiService.getDoctorMasterData( visitCategoryID, providerServiceMapID, - gender,facilityID,vanID, apiKey + gender,facilityID, apiKey ) if (response.code() == 200) { @@ -159,6 +159,10 @@ class DoctorMasterDataMaleRepo @Inject constructor( return healthCenterDao.getItemMasterListById(id) } + suspend fun getItemMasterQtyInHandById(id: Int): Int { + return healthCenterDao.getItemMasterQtyInHandById(id) ?: 0 + } + suspend fun getHigherHealthTypeByNameMap():Map{ return healthCenterDao.getHigherHealthMap().associate { it.institutionID to it.institutionName diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/EarDiagnosisRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/EarDiagnosisRepo.kt new file mode 100644 index 000000000..a7434305d --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/EarDiagnosisRepo.kt @@ -0,0 +1,108 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.EarDiagnosisAssessmentDao +import org.piramalswasthya.cho.model.EarDiagnosisAssessment +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.model.EarDiagnosisNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao + +class EarDiagnosisRepo @Inject constructor( + private val earDiagnosisAssessmentDao: EarDiagnosisAssessmentDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun saveAssessment(assessment: EarDiagnosisAssessment) { + if (assessment.assessmentId == 0L) { + assessment.benVisitNo?.let { visitNo -> + cphcDetailsRepo.clearAssessmentsForVisit(assessment.patientId, visitNo) + } + earDiagnosisAssessmentDao.insert(assessment) + } else { + earDiagnosisAssessmentDao.update(assessment) + } + } + + suspend fun getAssessmentByPatientId(patientID: String): EarDiagnosisAssessment? { + return earDiagnosisAssessmentDao.getAssessmentByPatientId(patientID) + } + + suspend fun getAssessmentByPatientIdAndVisitNo(patientID: String, benVisitNo: Int): EarDiagnosisAssessment? { + return earDiagnosisAssessmentDao.getAssessmentByPatientIdAndVisitNo(patientID, benVisitNo) + } + suspend fun processEarVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = earDiagnosisAssessmentDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientId) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postEarForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + earDiagnosisAssessmentDao.update(assessment) + }, + logLabel = "Ear Diagnosis records" + ) + } + } + + suspend fun pullEarVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getEarVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), EarDiagnosisNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getAssessmentByPatientIdAndVisitNo( + patientId, + networkObj.benVisitNo ?: 0 + ) != null + }, + insertNew = { patientId, networkObj -> + saveAssessment( + networkObj.toCacheModel(patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Ear records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/EcrRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/EcrRepo.kt index 882d25843..b6c9c0590 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/EcrRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/EcrRepo.kt @@ -1,16 +1,21 @@ package org.piramalswasthya.cho.repositories import android.util.Log +import com.google.gson.Gson import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext -import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import org.piramalswasthya.cho.database.room.InAppDb import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.ECTNetwork +import org.piramalswasthya.cho.model.EligibleCoupleRegCache import org.piramalswasthya.cho.model.EligibleCoupleTrackingCache +import org.piramalswasthya.cho.model.Patient import org.piramalswasthya.cho.network.AmritApiService +import org.piramalswasthya.cho.network.VillageIdList //import org.piramalswasthya.sakhi.database.room.SyncState //import org.piramalswasthya.sakhi.database.shared_preferences.PreferenceDao //import org.piramalswasthya.sakhi.helpers.Konstants @@ -25,8 +30,6 @@ import org.piramalswasthya.cho.network.AmritApiService import timber.log.Timber import java.io.IOException import java.net.SocketTimeoutException -import java.text.SimpleDateFormat -import java.util.Locale import javax.inject.Inject class EcrRepo @Inject constructor( @@ -38,34 +41,103 @@ class EcrRepo @Inject constructor( private val tmcNetworkApiService: AmritApiService ) { + // ===== Eligible Couple Registration Methods ===== + + fun getAllPatientsWithECR() = database.ecrDao.getAllPatientsWithECR() + + suspend fun getPatientWithECR(patientId: String) = + database.ecrDao.getPatientWithECR(patientId) + + suspend fun getSavedECR(patientId: String) = + database.ecrDao.getSavedECR(patientId) + + suspend fun saveECR(ecrCache: org.piramalswasthya.cho.model.EligibleCoupleRegCache) { + database.ecrDao.upsert(ecrCache) + } + + suspend fun updateECR(ecrCache: org.piramalswasthya.cho.model.EligibleCoupleRegCache) { + database.ecrDao.update(ecrCache) + } + + suspend fun getECRCount() = + database.ecrDao.ecrCount() + + // ===== Eligible Couple Tracking Methods ===== suspend fun getAllECT(patientID: String): List { - return withContext(Dispatchers.IO) { - database.ecrDao.getAllECT(patientID) - } + return database.ecrDao.getAllECT(patientID) } suspend fun getEct(patientID: String, createdDate: Long): EligibleCoupleTrackingCache? { - return withContext(Dispatchers.IO) { - database.ecrDao.getEct(patientID, createdDate) - } + return database.ecrDao.getEct(patientID, createdDate) + } + + fun getPatientsForTrackingList(): Flow> { + return database.ecrDao.getPatientsForTrackingList() + } + + fun getEligibleCoupleTrackingCount(): Flow { + return database.ecrDao.getEligibleCoupleTrackingCount() } suspend fun saveEct(eligibleCoupleTrackingCache: EligibleCoupleTrackingCache) { - withContext(Dispatchers.IO) { - database.ecrDao.upsert(eligibleCoupleTrackingCache) + database.ecrDao.upsert(eligibleCoupleTrackingCache) + } + + suspend fun resetEctAfterAbortion(patientID: String, createdBy: String) { + val allEct = database.ecrDao.getAllECT(patientID) + val mostRecent = allEct.maxByOrNull { it.visitDate } + val hasActive = allEct.any { it.isActive } + + allEct.forEach { ect -> + var changed = false + if (ect.isPregnant != null) { + ect.isPregnant = null + changed = true + } + if (ect.pregnancyTestResult != null) { + ect.pregnancyTestResult = null + changed = true + } + // Reactivate the most recent row only when nothing is currently active, + // so the EC-tracking DAO (which filters on isActive = 1) keeps the + // patient visible after the post-abortion statusOfWomanID = 1 reset. + if (!hasActive && ect === mostRecent && !ect.isActive) { + ect.isActive = true + changed = true + } + if (changed) { + if (ect.processed != "N") ect.processed = "U" + ect.syncState = SyncState.UNSYNCED + database.ecrDao.upsert(ect) + } + } + + if (allEct.isEmpty()) { + val freshEct = EligibleCoupleTrackingCache( + patientID = patientID, + visitDate = System.currentTimeMillis(), + createdBy = createdBy, + updatedBy = createdBy, + processed = "N", + isActive = true, + syncState = SyncState.UNSYNCED + ) + database.ecrDao.upsert(freshEct) } } suspend fun pushAndUpdateEctRecord(): Boolean { return withContext(Dispatchers.IO) { - val user = userRepo.getLoggedInUser() - ?: throw IllegalStateException("No user logged in!!") - val ectList = database.ecrDao.getAllUnprocessedECT() + if (ectList.isEmpty()) { + Timber.d("No unsynced EC tracking records found for upload") + return@withContext true + } val ectPostList = mutableSetOf() + var hasFailures = false ectList.forEach { ectPostList.clear() @@ -78,13 +150,14 @@ class EcrRepo @Inject constructor( it.syncState = SyncState.SYNCED } else { it.syncState = SyncState.UNSYNCED + hasFailures = true } database.ecrDao.updateEligibleCoupleTracking(it) // if(!uploadDone) // return@withContext false } - return@withContext true + return@withContext !hasFailures } } @@ -94,15 +167,25 @@ class EcrRepo @Inject constructor( val user = userRepo.getLoggedInUser() ?: throw IllegalStateException("No user logged in!!") try { + val ectPayload = ectPostList.toList().mapNotNull { cache -> + val benId = runCatching { patientRepo.getPatient(cache.patientID).beneficiaryID } + .getOrNull() + if (benId == null || benId <= 0L) { + Timber.w("Skipping ECT upload: unresolved beneficiaryID for patient ${cache.patientID}") + null + } else { + cache.asNetworkModel(benId) + } + } + + if (ectPayload.isEmpty()) { + Timber.w("Skipping ECT upload: no valid beneficiary IDs found in payload") + return false + } + + Timber.d("Uploading EC tracking payload count=${ectPayload.size}") val response = - amritApiService.postEctForm( - ectPostList.toList() - .filter{ ben -> patientRepo.getPatient(ben.patientID).beneficiaryID != null } - .map { - val pat = patientRepo.getPatient(it.patientID) - it.asNetworkModel(pat.beneficiaryID!!) - } - ) + amritApiService.postEctForm(ectPayload) val statusCode = response.code() if (statusCode == 200) { @@ -154,8 +237,140 @@ class EcrRepo @Inject constructor( } suspend fun getLatestEctByBenId(benId: String): EligibleCoupleTrackingCache? { - return withContext(Dispatchers.IO){ - database.ecrDao.getLatestEct(benId) + return database.ecrDao.getLatestEct(benId) + } + + // ===== Pull Eligible Couples from Server ===== + + suspend fun pullEligibleCouplesFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + try { + val villageList = VillageIdList( + RepositorySyncUtils.parseVillageIds(user.assignVillageIds ?: ""), + preferenceDao.getLastPatientSyncTime() + ) + val response = amritApiService.getEligibleCouples(villageList) + val statusCode = response.code() + if (statusCode == 200) { + val responseString = response.body()?.string() + if (responseString != null) { + val jsonObj = JSONObject(responseString) + val errorMessage = jsonObj.optString("errorMessage") + val responseStatusCode = jsonObj.optInt("statusCode", 200) + when (responseStatusCode) { + 200 -> { + try { + val dataArray = when (val dataNode = jsonObj.opt("data")) { + is org.json.JSONArray -> dataNode + is org.json.JSONObject -> dataNode.optJSONArray("data") + else -> null + } ?: org.json.JSONArray() + val gson = Gson() + var savedCount = 0 + for (i in 0 until dataArray.length()) { + val ectNetwork = gson.fromJson( + dataArray.getJSONObject(i).toString(), + ECTNetwork::class.java + ) + val benId = ectNetwork.benId + if (benId == null || benId == 0L) { + Timber.w("Skipping EC record: benId missing/invalid in payload index=$i") + continue + } + // Map server benId to local patientID. + // benId may come as beneficiaryID or beneficiaryRegID depending on backend source. + val patient = patientRepo.getPatientByAnyBeneficiaryId(benId) + if (patient != null) { + val existingEct = database.ecrDao.getEct( + patient.patientID, + org.piramalswasthya.cho.network.getLongFromDate(ectNetwork.visitDate) + ) ?: database.ecrDao.getEctByVisitDay( + patient.patientID, + org.piramalswasthya.cho.network.getLongFromDate(ectNetwork.visitDate) + ) + if (existingEct == null) { + val cache = ectNetwork.toCache(patient.patientID) + database.ecrDao.upsert(cache) + savedCount++ + Timber.d("Saved EC tracking record for patient ${patient.patientID}") + } else { + Timber.d("EC tracking record already exists for patient ${patient.patientID} on ${ectNetwork.visitDate}") + } + val ecrSource = existingEct ?: ectNetwork.toCache(patient.patientID) + ensureEcrRowForTrackedPatient(patient.patientID, ecrSource, user.userName) + } else { + Timber.w("No local patient found for benId $benId, skipping EC record") + } + } + Timber.d("EC downsync completed, received=${dataArray.length()} saved=$savedCount") + } catch (e: Exception) { + Timber.e(e, "Error parsing EC downsync data") + return@withContext false + } + return@withContext true + } + 5002 -> { + if (userRepo.refreshTokenTmc( + user.userName, user.password + ) + ) throw SocketTimeoutException() + } + 5000 -> { + Timber.d("No EC records found on server") + return@withContext true + } + else -> { + Log.d("error ec pull", errorMessage) + throw IOException("EC pull failed with code $responseStatusCode") + } + } + } + } + Timber.w("Bad response from server for EC pull: $response") + return@withContext false + } catch (e: SocketTimeoutException) { + Timber.d("Caught timeout exception $e, retrying EC pull") + return@withContext pullEligibleCouplesFromServer() + } catch (e: JSONException) { + Timber.d("Caught JSON exception $e for EC pull") + return@withContext false + } + } + } + + private suspend fun ensureEcrRowForTrackedPatient( + patientId: String, + trackingCache: EligibleCoupleTrackingCache, + defaultUserName: String + ) { + val existingEcr = database.ecrDao.getSavedECR(patientId) + if (existingEcr != null) { + if ((existingEcr.lmpDate == null || existingEcr.lmpDate == 0L) && trackingCache.lmpDate != null) { + existingEcr.lmpDate = trackingCache.lmpDate + existingEcr.syncState = SyncState.SYNCED + existingEcr.processed = "P" + database.ecrDao.update(existingEcr) + } + return } + + val ecr = EligibleCoupleRegCache( + patientID = patientId, + dateOfReg = trackingCache.createdDate, + lmpDate = trackingCache.lmpDate, + noOfChildren = 0, + noOfLiveChildren = 0, + noOfMaleChildren = 0, + noOfFemaleChildren = 0, + isRegistered = true, + processed = "P", + createdBy = trackingCache.createdBy.ifBlank { defaultUserName }, + createdDate = trackingCache.createdDate, + updatedBy = trackingCache.updatedBy.ifBlank { defaultUserName }, + syncState = SyncState.SYNCED + ) + database.ecrDao.upsert(ecr) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/ElderlyHealthRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/ElderlyHealthRepo.kt new file mode 100644 index 000000000..86dee4768 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/ElderlyHealthRepo.kt @@ -0,0 +1,103 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.ElderlyHealthAssessmentDao +import org.piramalswasthya.cho.model.ElderlyHealthAssessment +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.model.ElderlyHealthNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao + +class ElderlyHealthRepo @Inject constructor( + private val elderlyHealthAssessmentDao: ElderlyHealthAssessmentDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun saveAssessment(assessment: ElderlyHealthAssessment) { + if (assessment.assessmentId == 0L) { + cphcDetailsRepo.clearAssessmentsForVisit(assessment.patientId, assessment.benVisitNo) + elderlyHealthAssessmentDao.insert(assessment) + } else { + elderlyHealthAssessmentDao.update(assessment) + } + } + + suspend fun getAssessmentByPatientId(patientID: String): ElderlyHealthAssessment? { + return elderlyHealthAssessmentDao.getAssessmentByPatientId(patientID) + } + + suspend fun getAssessment(patientID: String, benVisitNo: Int): ElderlyHealthAssessment? { + return elderlyHealthAssessmentDao.getAssessment(patientID, benVisitNo) + } + suspend fun processElderlyVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = elderlyHealthAssessmentDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientId) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postElderlyForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + elderlyHealthAssessmentDao.update(assessment) + }, + logLabel = "Elderly Health records" + ) + } + } + + suspend fun pullElderlyVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getElderlyVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), ElderlyHealthNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getAssessment(patientId, networkObj.benVisitNo ?: 0) != null + }, + insertNew = { patientId, networkObj -> + saveAssessment( + networkObj.toCacheModel(patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Elderly records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/InfantRegRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/InfantRegRepo.kt new file mode 100644 index 000000000..478a2f1d1 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/InfantRegRepo.kt @@ -0,0 +1,995 @@ +package org.piramalswasthya.cho.repositories + +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import org.json.JSONException +import org.json.JSONArray +import org.json.JSONObject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.InfantRegDao +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.ChildApiPost +import org.piramalswasthya.cho.model.ChildRegDomain +import org.piramalswasthya.cho.model.DeliveryOutcomeCache +import org.piramalswasthya.cho.model.Gender +import org.piramalswasthya.cho.model.InfantRegApiPost +import org.piramalswasthya.cho.model.InfantRegCache +import org.piramalswasthya.cho.model.InfantRegDomain +import org.piramalswasthya.cho.model.InfantRegWithPatient +import org.piramalswasthya.cho.model.Patient +import org.piramalswasthya.cho.model.getIsoDateTimeStringFromLong +import org.piramalswasthya.cho.network.AmritApiService +import org.piramalswasthya.cho.network.VillageIdList +import timber.log.Timber +import java.net.SocketTimeoutException +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import retrofit2.Response + +class InfantRegRepo @Inject constructor( + private val preferenceDao: PreferenceDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val patientDao: PatientDao, + private val infantRegDao: InfantRegDao +) { + /** + * Get list of infants eligible for registration + * Source of truth: combined DELIVERY_OUTCOME + INFANT_REG. + * - Generates rows from total delivery outcome count. + * - Marks only live-birth rows as registerable. + * - Maps synced infant rows onto generated indices for View state. + */ + fun getListForInfantReg(): Flow> { + return infantRegDao.getListForInfantRegister() + .map { list -> + list + .flatMap { it.asDomainModel() } + .sortedByDescending { it.deliveryOutcome.dateOfDelivery ?: 0L } + } + } + + /** + * Get count of infants eligible for registration + */ + fun getInfantRegisterCount(): Flow { + return infantRegDao.getInfantRegisterCount() + } + + /** + * Get all registered infants for child registration list + */ + fun getRegisteredInfants(): Flow> { + return infantRegDao.getAllRegisteredInfants() + .map { list -> + list + .map { it.asDomainModel() } + } + } + + /** + * Get count of registered infants + */ + fun getRegisteredInfantsCount(): Flow { + return infantRegDao.getAllRegisteredInfantsCount() + } + + suspend fun getInfantReg(patientID: String, babyIndex: Int): InfantRegCache? = + infantRegDao.getInfantReg(patientID, babyIndex) + + suspend fun getInfantRegFromChildPatientID(childPatientID: String): InfantRegCache? = + infantRegDao.getInfantRegFromChildPatientID(childPatientID) + + suspend fun saveInfantReg(infantRegCache: InfantRegCache) { + upsertInfantReg(infantRegCache) + } + + suspend fun upsertInfantReg(infantRegCache: InfantRegCache) { + // IMPORTANT: + // Downsync payload can contain non-zero server-side IDs that do not exist in local DB. + // So we must not assume `id != 0` means "already present locally". + // Local uniqueness for this module is motherPatientID + babyIndex. + val existing = infantRegDao.getInfantReg( + infantRegCache.motherPatientID, + infantRegCache.babyIndex + ) + + if (existing != null) { + infantRegDao.updateInfantReg(infantRegCache.copy(id = existing.id)) + } else { + infantRegDao.saveInfantReg(infantRegCache.copy(id = 0)) + } + } + + suspend fun getNumBabiesRegistered(patientID: String): Int = + infantRegDao.getNumBabiesRegistered(patientID) + + /** + * Creates/ensures infant placeholder rows in INFANT_REG based on Delivery Outcome count. + * We keep placeholders as processed/synced so they are local list entries until user fills details. + */ + suspend fun ensureInfantPlaceholdersForDeliveryOutcome( + motherPatientID: String, + motherName: String?, + infantCount: Int, + userName: String + ) { + if (infantCount <= 0) return + val safeMotherName = motherName?.trim().takeUnless { it.isNullOrBlank() } ?: "Mother" + val now = System.currentTimeMillis() + + for (index in 0 until infantCount) { + val existing = infantRegDao.getInfantReg(motherPatientID, index) + if (existing != null) continue + + val placeholder = InfantRegCache( + motherPatientID = motherPatientID, + babyIndex = index, + babyName = "baby ${index + 1} of $safeMotherName", + isActive = true, + processed = "Y", + createdBy = userName, + createdDate = now, + updatedBy = userName, + updatedDate = now, + syncState = SyncState.SYNCED + ) + upsertInfantReg(placeholder) + } + } + + suspend fun processNewInfantRegister(): Boolean { + return withContext(Dispatchers.IO) { + if (preferenceDao.getLoggedInUser() == null) { + throw IllegalStateException("No user logged in") + } + + val infantRegList = infantRegDao.getAllUnprocessedInfantReg() + if (infantRegList.isEmpty()) return@withContext true + + val deduplicatedInfantRegs = infantRegList + .groupBy { it.motherPatientID to it.babyIndex } + .map { (_, records) -> + records.maxWithOrNull( + compareBy { it.updatedDate } + .thenBy { it.createdDate } + .thenBy { it.id } + ) ?: records.first() + } + + var hasFailures = false + deduplicatedInfantRegs.forEach { infantReg -> + val motherPatient = getPatientOrNull(infantReg.motherPatientID) + val motherBenId = motherPatient?.beneficiaryID + + if (motherBenId == null) { + Timber.w("Skipping infant registration ${infantReg.id}: mother beneficiary ID missing") + hasFailures = true + return@forEach + } + + val childBenId = infantReg.childPatientID + ?.let { getPatientOrNull(it)?.beneficiaryID } + ?: 0L + + val payload = infantReg.toApiPost( + motherBenId = motherBenId, + childBenId = childBenId + ) + + infantReg.syncState = SyncState.SYNCING + infantRegDao.updateInfantReg(infantReg) + + val uploadDone = postDataToAmritServer(listOf(payload)) + if (uploadDone) { + infantReg.processed = "P" + infantReg.syncState = SyncState.SYNCED + } else { + infantReg.syncState = SyncState.UNSYNCED + hasFailures = true + } + infantRegDao.updateInfantReg(infantReg) + } + + return@withContext !hasFailures + } + } + + suspend fun syncChildRegistration(infantReg: InfantRegCache): Boolean { + return withContext(Dispatchers.IO) { + val motherPatient = getPatientOrNull(infantReg.motherPatientID) + val motherBenId = motherPatient?.beneficiaryID + if (motherBenId == null || motherBenId <= 0L) { + Timber.w("Skipping child registration sync ${infantReg.id}: mother beneficiary ID missing") + return@withContext false + } + + val childBenId = infantReg.childPatientID + ?.let { getPatientOrNull(it)?.beneficiaryID } + ?.takeIf { it > 0L } + ?: motherBenId + + val childGenderID = infantReg.childPatientID + ?.let { getPatientOrNull(it)?.genderID } + ?: infantReg.genderID + + val childPayload = infantReg.toChildApiPost( + childBenId = childBenId, + childGenderID = childGenderID + ) + val syncDone = postChildDataToAmritServer(listOf(childPayload)) + if (syncDone) { + val now = System.currentTimeMillis() + val updated = infantReg.copy( + processed = "C", + syncState = SyncState.SYNCED, + updatedDate = now + ) + upsertInfantReg(updated) + } + syncDone + } + } + + private suspend fun postWithTokenRefreshRetry( + moduleName: String, + call: suspend () -> Response + ): Boolean { + val user = preferenceDao.getLoggedInUser() + ?: throw IllegalStateException("No user logged in") + var refreshed = false + while (true) { + try { + val response = call() + if (response.code() != 200) { + Timber.w("$moduleName sync failed with HTTP ${response.code()}") + return false + } + + val responseString = response.body()?.string() + if (responseString.isNullOrBlank()) { + Timber.d("$moduleName saveAll succeeded with empty response body") + return true + } + val jsonObj = JSONObject(responseString) + val responseStatusCode = jsonObj.optInt("statusCode", -1) + when (responseStatusCode) { + 200 -> return true + 401, 5002 -> { + if (!refreshed && userRepo.refreshTokenTmc(user.userName, user.password)) { + refreshed = true + Timber.d("Retrying $moduleName sync after token refresh") + continue + } + return false + } + else -> { + Timber.w("$moduleName sync failed with response statusCode=$responseStatusCode") + return false + } + } + } catch (e: SocketTimeoutException) { + if (!refreshed && userRepo.refreshTokenTmc(user.userName, user.password)) { + refreshed = true + Timber.d("Retrying $moduleName sync after token refresh") + continue + } + Timber.e(e, "$moduleName sync failed") + return false + } catch (e: JSONException) { + Timber.e(e, "$moduleName sync parse failed") + return false + } catch (e: Exception) { + Timber.e(e, "$moduleName sync failed") + return false + } + } + } + + private suspend fun postChildDataToAmritServer( + childPostList: List + ): Boolean { + if (childPostList.isEmpty()) return true + return postWithTokenRefreshRetry( + moduleName = "Child", + call = { amritApiService.postChildDetails(childPostList) } + ) + } + + private suspend fun postDataToAmritServer( + infantRegPostList: List + ): Boolean { + if (infantRegPostList.isEmpty()) return true + return postWithTokenRefreshRetry( + moduleName = "Infant", + call = { amritApiService.postInfantRegForm(infantRegPostList) } + ) + } + + private suspend fun getPatientOrNull(patientID: String) = + runCatching { patientDao.getPatient(patientID) }.getOrNull() + + private suspend fun pullWithTokenRefreshRetry( + moduleName: String, + fetch: suspend (VillageIdList) -> Response, + onSuccess200: suspend (jsonObj: JSONObject, userName: String) -> Boolean + ): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + var refreshed = false + while (true) { + try { + val villageList = VillageIdList( + RepositorySyncUtils.parseVillageIds(user.assignVillageIds ?: ""), + preferenceDao.getLastPatientSyncTime() + ) + val response = fetch(villageList) + if (response.code() != 200) { + Timber.w("Bad response from server for $moduleName getAll: $response") + return@withContext false + } + + val responseString = response.body()?.string() + if (responseString.isNullOrBlank()) { + Timber.w("Empty response body for $moduleName getAll") + return@withContext false + } + + val jsonObj = JSONObject(responseString) + val responseStatusCode = jsonObj.optInt("statusCode", 200) + val errorMessage = jsonObj.optString("errorMessage") + when (responseStatusCode) { + 200 -> return@withContext onSuccess200(jsonObj, user.userName) + 5000 -> { + Timber.d("No ${moduleName.lowercase(Locale.ENGLISH)} records found on server") + return@withContext true + } + 401, 5002 -> { + if (!refreshed && userRepo.refreshTokenTmc(user.userName, user.password)) { + refreshed = true + Timber.d("Retrying ${moduleName.lowercase(Locale.ENGLISH)} getAll after token refresh") + continue + } + return@withContext false + } + else -> { + Timber.w("$moduleName getAll failed: $errorMessage") + return@withContext false + } + } + } catch (e: SocketTimeoutException) { + if (!refreshed && userRepo.refreshTokenTmc(user.userName, user.password)) { + refreshed = true + Timber.d("Retrying ${moduleName.lowercase(Locale.ENGLISH)} getAll after token refresh") + continue + } + Timber.e(e, "$moduleName getAll failed") + return@withContext false + } catch (e: JSONException) { + Timber.e(e, "$moduleName getAll parse failed") + return@withContext false + } catch (e: Exception) { + Timber.e(e, "$moduleName getAll failed") + return@withContext false + } + } + return@withContext false + } + } + + suspend fun pullInfantsFromServer(): Boolean { + return pullWithTokenRefreshRetry( + moduleName = "Infant", + fetch = { villageList -> amritApiService.getAllInfants(villageList) } + ) { jsonObj, userName -> + val dataArray = extractInfantDataArray(jsonObj) + val gson = Gson() + var savedCount = 0 + var skippedNoMotherCount = 0 + var skippedInvalidPayloadCount = 0 + var failedSaveCount = 0 + for (i in 0 until dataArray.length()) { + try { + val rawObj = extractInfantItemObject(dataArray, i) + if (rawObj == null) { + skippedInvalidPayloadCount++ + continue + } + val normalizedObj = normalizeInfantPayload(rawObj) + val networkModel = gson.fromJson( + normalizedObj.toString(), + InfantRegApiPost::class.java + ) + if (networkModel.benId <= 0L) { + Timber.w("Invalid infant payload at index=$i: benId=${networkModel.benId}, skipping") + skippedInvalidPayloadCount++ + continue + } + val mother = patientDao.getPatientByAnyBeneficiaryId(networkModel.benId) + ?: ensureMotherPlaceholderForInfant(benId = networkModel.benId) + if (mother == null) { + Timber.w("No local mother patient found for infant benId=${networkModel.benId}, skipping") + skippedNoMotherCount++ + continue + } + val childPatientID = if (networkModel.childBenId > 0) { + patientDao.getPatientByAnyBeneficiaryId(networkModel.childBenId)?.patientID + } else null + + val incoming = networkModel.toCacheModel( + motherPatientID = mother.patientID, + childPatientID = childPatientID + ).copy( + processed = "P", + syncState = SyncState.SYNCED, + updatedDate = System.currentTimeMillis(), + updatedBy = networkModel.updatedBy?.ifBlank { userName } ?: userName + ) + val existing = infantRegDao.getInfantReg(mother.patientID, incoming.babyIndex) + val merged = if (existing != null) { + if (existing.syncState == SyncState.SYNCED) { + incoming.copy( + id = existing.id, + childPatientID = incoming.childPatientID ?: existing.childPatientID, + babyName = incoming.babyName ?: existing.babyName, + infantTerm = incoming.infantTerm ?: existing.infantTerm, + corticosteroidGiven = incoming.corticosteroidGiven ?: existing.corticosteroidGiven, + genderID = incoming.genderID ?: existing.genderID, + babyCriedAtBirth = incoming.babyCriedAtBirth ?: existing.babyCriedAtBirth, + resuscitation = incoming.resuscitation ?: existing.resuscitation, + referred = incoming.referred ?: existing.referred, + hadBirthDefect = incoming.hadBirthDefect ?: existing.hadBirthDefect, + birthDefect = incoming.birthDefect ?: existing.birthDefect, + otherDefect = incoming.otherDefect ?: existing.otherDefect, + weight = incoming.weight ?: existing.weight, + breastFeedingStarted = incoming.breastFeedingStarted ?: existing.breastFeedingStarted, + opv0Dose = incoming.opv0Dose ?: existing.opv0Dose, + bcgDose = incoming.bcgDose ?: existing.bcgDose, + hepBDose = incoming.hepBDose ?: existing.hepBDose, + vitkDose = incoming.vitkDose ?: existing.vitkDose, + outcomeAtBirth = incoming.outcomeAtBirth ?: existing.outcomeAtBirth, + typeOfResuscitation = incoming.typeOfResuscitation ?: existing.typeOfResuscitation, + newbornComplications = incoming.newbornComplications ?: existing.newbornComplications, + currentStatusOfBaby = incoming.currentStatusOfBaby ?: existing.currentStatusOfBaby, + causeOfDeath = incoming.causeOfDeath ?: existing.causeOfDeath, + otherCauseOfDeath = incoming.otherCauseOfDeath ?: existing.otherCauseOfDeath, + birthDoseVaccinesGiven = incoming.birthDoseVaccinesGiven ?: existing.birthDoseVaccinesGiven, + reasonForNoVaccines = incoming.reasonForNoVaccines ?: existing.reasonForNoVaccines, + vitaminKInjectionGiven = incoming.vitaminKInjectionGiven ?: existing.vitaminKInjectionGiven, + reasonForNoVitaminK = incoming.reasonForNoVitaminK ?: existing.reasonForNoVitaminK, + birthCertificateIssued = incoming.birthCertificateIssued ?: existing.birthCertificateIssued, + createdDate = if (existing.createdDate > 0L) existing.createdDate else incoming.createdDate, + createdBy = if (existing.createdBy.isNotBlank()) existing.createdBy else incoming.createdBy + ) + } else { + existing.copy( + id = existing.id, + childPatientID = existing.childPatientID ?: incoming.childPatientID + ) + } + } else incoming + upsertInfantReg(merged) + savedCount++ + } catch (e: Exception) { + failedSaveCount++ + Timber.w(e, "Failed saving infant payload index=$i") + } + } + Timber.d( + "Infant getAll downsync completed, saved=$savedCount " + + "skippedNoMother=$skippedNoMotherCount " + + "skippedInvalidPayload=$skippedInvalidPayloadCount failedSave=$failedSaveCount " + + "received=${dataArray.length()}" + ) + failedSaveCount == 0 && + skippedNoMotherCount == 0 && + skippedInvalidPayloadCount == 0 + } + } + + suspend fun pullChildrenFromServer(): Boolean { + return pullWithTokenRefreshRetry( + moduleName = "Child", + fetch = { villageList -> amritApiService.getAllChildren(villageList) } + ) { jsonObj, userName -> + val dataArray = extractInfantDataArray(jsonObj) + val gson = Gson() + var savedCount = 0 + var skippedNoMotherCount = 0 + var skippedInvalidPayloadCount = 0 + var failedSaveCount = 0 + + for (i in 0 until dataArray.length()) { + try { + val rawObj = extractInfantItemObject(dataArray, i) + if (rawObj == null) { + skippedInvalidPayloadCount++ + continue + } + val normalizedObj = normalizeChildPayload(rawObj) + val networkModel = gson.fromJson( + normalizedObj.toString(), + ChildApiPost::class.java + ) + if (networkModel.benId <= 0L) { + skippedInvalidPayloadCount++ + continue + } + + val mother = patientDao.getPatientByAnyBeneficiaryId(networkModel.benId) + ?: ensureMotherPlaceholderForInfant(networkModel.benId) + if (mother == null) { + skippedNoMotherCount++ + continue + } + + val existingRegs = infantRegDao.getAllInfantRegs(setOf(mother.patientID)) + .filter { it.isActive } + .sortedWith( + compareBy { it.babyIndex } + .thenByDescending { it.updatedDate } + ) + val resolvedExisting = resolveChildRecord(existingRegs, networkModel) + val resolvedBabyIndex = resolvedExisting?.babyIndex + ?: existingRegs.firstOrNull { !it.hasRegistrationData() }?.babyIndex + ?: existingRegs.size + + val merged = mergeChildDownsync( + existing = resolvedExisting, + motherPatientID = mother.patientID, + child = networkModel, + babyIndex = resolvedBabyIndex, + userName = userName + ) + + upsertInfantReg(merged) + savedCount++ + } catch (e: Exception) { + failedSaveCount++ + Timber.w(e, "Failed saving child payload index=$i") + } + } + + Timber.d( + "Child getAll downsync completed, saved=$savedCount " + + "skippedNoMother=$skippedNoMotherCount " + + "skippedInvalidPayload=$skippedInvalidPayloadCount failedSave=$failedSaveCount " + + "received=${dataArray.length()}" + ) + true + } + } + + private fun InfantRegCache.toApiPost(motherBenId: Long, childBenId: Long): InfantRegApiPost { + return InfantRegApiPost( + id = id, + benId = motherBenId, + childBenId = childBenId, + isActive = isActive, + babyName = babyName, + babyIndex = babyIndex, + infantTerm = infantTerm, + corticosteroidGiven = corticosteroidGiven, + gender = mapGender(genderID), + babyCriedAtBirth = babyCriedAtBirth, + resuscitation = resuscitation, + referred = referred, + hadBirthDefect = hadBirthDefect, + birthDefect = birthDefect, + otherDefect = otherDefect, + weight = weight ?: 0.0, + breastFeedingStarted = breastFeedingStarted, + opv0Dose = getIsoDateTimeStringFromLong(opv0Dose), + bcgDose = getIsoDateTimeStringFromLong(bcgDose), + hepBDose = getIsoDateTimeStringFromLong(hepBDose), + vitkDose = getIsoDateTimeStringFromLong(vitkDose), + outcomeAtBirth = outcomeAtBirth, + typeOfResuscitation = typeOfResuscitation, + newbornComplications = newbornComplications, + currentStatusOfBaby = currentStatusOfBaby, + causeOfDeath = causeOfDeath, + otherCauseOfDeath = otherCauseOfDeath, + birthDoseVaccinesGiven = birthDoseVaccinesGiven, + reasonForNoVaccines = reasonForNoVaccines, + vitaminKInjectionGiven = vitaminKInjectionGiven, + reasonForNoVitaminK = reasonForNoVitaminK, + birthCertificateIssued = birthCertificateIssued, + createdDate = getIsoDateTimeStringFromLong(createdDate), + createdBy = createdBy, + updatedDate = getIsoDateTimeStringFromLong(updatedDate), + updatedBy = updatedBy + ) + } + + private fun InfantRegCache.toChildApiPost(childBenId: Long, childGenderID: Int?): ChildApiPost { + return ChildApiPost( + id = id, + benId = childBenId, + babyName = babyName, + infantTerm = infantTerm, + corticosteroidGiven = corticosteroidGiven, + gender = mapGender(childGenderID), + babyCriedAtBirth = babyCriedAtBirth, + resuscitation = resuscitation, + referred = referred, + hadBirthDefect = hadBirthDefect, + birthDefect = birthDefect, + otherDefect = otherDefect, + weight = weight ?: 0.0, + breastFeedingStarted = breastFeedingStarted, + opv0Dose = getIsoDateTimeStringFromLong(opv0Dose), + bcgDose = getIsoDateTimeStringFromLong(bcgDose), + hepBDose = getIsoDateTimeStringFromLong(hepBDose), + vitkDose = getIsoDateTimeStringFromLong(vitkDose), + createdDate = getIsoDateTimeStringFromLong(createdDate), + createdBy = createdBy, + updatedDate = getIsoDateTimeStringFromLong(updatedDate), + updatedBy = updatedBy + ) + } + + private fun mapGender(genderID: Int?): String? { + return when (genderID) { + 1 -> Gender.MALE.name + 2 -> Gender.FEMALE.name + 3 -> Gender.TRANSGENDER.name + else -> null + } + } + + private fun InfantRegWithPatient.toInfantRegDomain(): InfantRegDomain { + val fallbackDelivery = DeliveryOutcomeCache( + patientID = motherPatient.patientID, + isActive = true, + dateOfDelivery = infant.createdDate, + createdBy = infant.createdBy, + updatedBy = infant.updatedBy, + syncState = infant.syncState + ) + return InfantRegDomain( + motherPatient = motherPatient, + babyIndex = infant.babyIndex, + deliveryOutcome = fallbackDelivery, + savedIr = infant + ) + } + + private fun InfantRegApiPost.toCacheModel( + motherPatientID: String, + childPatientID: String? + ): InfantRegCache { + val genderID = when (gender?.trim()?.lowercase(Locale.ENGLISH)) { + "male" -> 1 + "female" -> 2 + "transgender" -> 3 + else -> null + } + return InfantRegCache( + id = id, + motherPatientID = motherPatientID, + childPatientID = childPatientID, + isActive = isActive, + babyName = babyName, + babyIndex = babyIndex, + infantTerm = infantTerm, + corticosteroidGiven = corticosteroidGiven, + genderID = genderID, + babyCriedAtBirth = babyCriedAtBirth, + resuscitation = resuscitation, + referred = referred, + hadBirthDefect = hadBirthDefect, + birthDefect = birthDefect, + otherDefect = otherDefect, + weight = weight, + breastFeedingStarted = breastFeedingStarted, + opv0Dose = org.piramalswasthya.cho.network.getLongFromDate(opv0Dose), + bcgDose = org.piramalswasthya.cho.network.getLongFromDate(bcgDose), + hepBDose = org.piramalswasthya.cho.network.getLongFromDate(hepBDose), + vitkDose = org.piramalswasthya.cho.network.getLongFromDate(vitkDose), + outcomeAtBirth = outcomeAtBirth, + typeOfResuscitation = typeOfResuscitation, + newbornComplications = newbornComplications, + currentStatusOfBaby = currentStatusOfBaby, + causeOfDeath = causeOfDeath, + otherCauseOfDeath = otherCauseOfDeath, + birthDoseVaccinesGiven = birthDoseVaccinesGiven, + reasonForNoVaccines = reasonForNoVaccines, + vitaminKInjectionGiven = vitaminKInjectionGiven, + reasonForNoVitaminK = reasonForNoVitaminK, + birthCertificateIssued = birthCertificateIssued, + processed = "P", + createdBy = createdBy ?: "system", + createdDate = org.piramalswasthya.cho.network.getLongFromDate(createdDate), + updatedBy = updatedBy ?: (createdBy ?: "system"), + updatedDate = org.piramalswasthya.cho.network.getLongFromDate(updatedDate), + syncState = SyncState.SYNCED + ) + } + + private fun extractInfantDataArray(root: JSONObject): JSONArray { + // Try strict path first for current contracts. + val dataNode = root.opt("data") + if (dataNode is JSONArray) return dataNode + if (dataNode is String) { + parseJsonArrayFromString(dataNode)?.let { return it } + parseJsonObjectFromString(dataNode)?.let { obj -> + val nested = extractInfantDataArray(JSONObject().put("data", obj)) + if (nested.length() > 0) return nested + } + } + if (dataNode is JSONObject) { + val keys = listOf("data", "response", "records", "content", "list", "items") + for (key in keys) { + val arr = dataNode.optJSONArray(key) + if (arr != null) return arr + val text = dataNode.optString(key, "") + parseJsonArrayFromString(text)?.let { return it } + } + } + // Fallback: recursively scan full response for first JSON array payload. + findFirstJsonArray(root)?.let { return it } + return JSONArray() + } + + private fun extractInfantItemObject(dataArray: JSONArray, index: Int): JSONObject? { + val direct = dataArray.optJSONObject(index) + if (direct != null) return direct + + val raw = dataArray.opt(index) + if (raw is String) { + parseJsonObjectFromString(raw)?.let { return it } + } + return null + } + + private fun normalizeInfantPayload(source: JSONObject): JSONObject { + val target = JSONObject(source.toString()) + + if (!target.has("benId") || target.optLong("benId", 0L) <= 0L) { + firstPositiveLong( + source, + "beneficiaryID", "beneficiaryId", "beneficiaryid", "benID", "benid", + "motherBenId", "motherBeneficiaryID", "motherBeneficiaryId" + )?.let { target.put("benId", it) } + } + + if (!target.has("childBenId") || target.optLong("childBenId", 0L) < 0L) { + firstPositiveLong( + source, + "childBeneficiaryID", "childBeneficiaryId", "childbeneficiaryid", + "childBenID", "childBenId", "childID", "childId" + )?.let { target.put("childBenId", it) } + } + + if (!target.has("babyIndex")) { + firstNonNegativeInt(source, "babyNo", "babyNumber", "childIndex", "index") + ?.let { target.put("babyIndex", it) } + } + + return target + } + + private fun normalizeChildPayload(source: JSONObject): JSONObject { + val target = JSONObject(source.toString()) + if (!target.has("benId") || target.optLong("benId", 0L) <= 0L) { + firstPositiveLong( + source, + "beneficiaryID", "beneficiaryId", "beneficiaryid", "benID", "benid", + "motherBenId", "motherBeneficiaryID", "motherBeneficiaryId" + )?.let { target.put("benId", it) } + } + return target + } + + private fun resolveChildRecord( + existingRegs: List, + child: ChildApiPost + ): InfantRegCache? { + existingRegs.firstOrNull { child.id > 0L && it.id == child.id }?.let { return it } + existingRegs.firstOrNull { + val localName = it.babyName?.trim().orEmpty() + val serverName = child.babyName?.trim().orEmpty() + localName.isNotBlank() && serverName.isNotBlank() && localName.equals(serverName, ignoreCase = true) + }?.let { return it } + return existingRegs.firstOrNull { !it.hasRegistrationData() } + } + + private fun mergeChildDownsync( + existing: InfantRegCache?, + motherPatientID: String, + child: ChildApiPost, + babyIndex: Int, + userName: String + ): InfantRegCache { + val now = System.currentTimeMillis() + val incoming = InfantRegCache( + id = child.id, + childPatientID = existing?.childPatientID, + motherPatientID = motherPatientID, + isActive = true, + babyName = child.babyName ?: existing?.babyName, + babyIndex = babyIndex, + infantTerm = child.infantTerm ?: existing?.infantTerm, + corticosteroidGiven = child.corticosteroidGiven ?: existing?.corticosteroidGiven, + genderID = when (child.gender?.trim()?.lowercase(Locale.ENGLISH)) { + "male" -> 1 + "female" -> 2 + "transgender" -> 3 + else -> existing?.genderID + }, + babyCriedAtBirth = child.babyCriedAtBirth ?: existing?.babyCriedAtBirth, + resuscitation = child.resuscitation ?: existing?.resuscitation, + referred = child.referred ?: existing?.referred, + hadBirthDefect = child.hadBirthDefect ?: existing?.hadBirthDefect, + birthDefect = child.birthDefect ?: existing?.birthDefect, + otherDefect = child.otherDefect ?: existing?.otherDefect, + weight = child.weight.takeIf { it != 0.0 } ?: existing?.weight, + breastFeedingStarted = child.breastFeedingStarted ?: existing?.breastFeedingStarted, + opv0Dose = org.piramalswasthya.cho.network.getLongFromDate(child.opv0Dose) ?: existing?.opv0Dose, + bcgDose = org.piramalswasthya.cho.network.getLongFromDate(child.bcgDose) ?: existing?.bcgDose, + hepBDose = org.piramalswasthya.cho.network.getLongFromDate(child.hepBDose) ?: existing?.hepBDose, + vitkDose = org.piramalswasthya.cho.network.getLongFromDate(child.vitkDose) ?: existing?.vitkDose, + outcomeAtBirth = existing?.outcomeAtBirth, + typeOfResuscitation = existing?.typeOfResuscitation, + newbornComplications = existing?.newbornComplications, + currentStatusOfBaby = existing?.currentStatusOfBaby, + causeOfDeath = existing?.causeOfDeath, + otherCauseOfDeath = existing?.otherCauseOfDeath, + birthDoseVaccinesGiven = existing?.birthDoseVaccinesGiven, + reasonForNoVaccines = existing?.reasonForNoVaccines, + vitaminKInjectionGiven = existing?.vitaminKInjectionGiven, + reasonForNoVitaminK = existing?.reasonForNoVitaminK, + birthCertificateIssued = existing?.birthCertificateIssued, + processed = "C", + createdBy = child.createdBy ?: existing?.createdBy ?: userName, + createdDate = org.piramalswasthya.cho.network.getLongFromDate(child.createdDate) + ?: existing?.createdDate + ?: now, + updatedBy = child.updatedBy ?: existing?.updatedBy ?: userName, + updatedDate = org.piramalswasthya.cho.network.getLongFromDate(child.updatedDate) + ?: now, + syncState = SyncState.SYNCED + ) + + return if (existing != null) { + incoming.copy(id = existing.id) + } else { + incoming.copy(id = 0) + } + } + + private fun firstPositiveLong(source: JSONObject, vararg keys: String): Long? { + // Direct key lookup. + for (key in keys) { + val value = source.optLong(key, 0L) + if (value > 0L) return value + source.optString(key, "").trim().toLongOrNull()?.takeIf { it > 0L }?.let { return it } + } + // Case-insensitive fallback. + val lowerKeyMap = mutableMapOf() + val iterator = source.keys() + while (iterator.hasNext()) { + val realKey = iterator.next() + lowerKeyMap[realKey.lowercase(Locale.ENGLISH)] = realKey + } + for (key in keys) { + val realKey = lowerKeyMap[key.lowercase(Locale.ENGLISH)] ?: continue + val value = source.optLong(realKey, 0L) + if (value > 0L) return value + source.optString(realKey, "").trim().toLongOrNull()?.takeIf { it > 0L }?.let { return it } + } + return null + } + + private fun firstNonNegativeInt(source: JSONObject, vararg keys: String): Int? { + for (key in keys) { + if (!source.has(key)) continue + val value = source.optInt(key, Int.MIN_VALUE) + if (value != Int.MIN_VALUE && value >= 0) return value + source.optString(key, "").trim().toIntOrNull()?.takeIf { it >= 0 }?.let { return it } + } + return null + } + + private fun parseJsonArrayFromString(value: String?): JSONArray? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty() || !trimmed.startsWith("[")) return null + return runCatching { JSONArray(trimmed) }.getOrNull() + } + + private fun parseJsonObjectFromString(value: String?): JSONObject? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty() || !trimmed.startsWith("{")) return null + return runCatching { JSONObject(trimmed) }.getOrNull() + } + + private fun findFirstJsonArray(node: Any?): JSONArray? { + when (node) { + is JSONArray -> { + if (node.length() > 0) return node + } + is JSONObject -> { + val iterator = node.keys() + while (iterator.hasNext()) { + val key = iterator.next() + val child = node.opt(key) + if (child is JSONArray && child.length() > 0) return child + if (child is JSONObject) { + findFirstJsonArray(child)?.let { return it } + } else if (child is String) { + parseJsonArrayFromString(child)?.let { if (it.length() > 0) return it } + parseJsonObjectFromString(child)?.let { obj -> + findFirstJsonArray(obj)?.let { return it } + } + } + } + } + is String -> { + parseJsonArrayFromString(node)?.let { if (it.length() > 0) return it } + parseJsonObjectFromString(node)?.let { obj -> + findFirstJsonArray(obj)?.let { return it } + } + } + } + return null + } + + /** + * Ensures INFANT_REG downsync can be saved even when mother is not yet present in PATIENT. + * Creates a lightweight local placeholder mother record keyed by beneficiaryID. + */ + private suspend fun ensureMotherPlaceholderForInfant( + benId: Long + ): Patient? { + if (benId <= 0L) return null + val existing = patientDao.getPatientByAnyBeneficiaryId(benId) + if (existing != null) return existing + + val placeholderPatientId = "infant-mother-$benId" + val now = Date() + val placeholder = Patient( + patientID = placeholderPatientId, + firstName = "Mother", + lastName = benId.toString(), + dob = null, + age = null, + ageUnitID = null, + maritalStatusID = null, + spouseName = null, + ageAtMarriage = null, + phoneNo = null, + genderID = null, + registrationDate = now, + stateID = null, + districtID = null, + blockID = null, + districtBranchID = null, + communityID = null, + religionID = null, + parentName = null, + syncState = SyncState.SYNCED, + beneficiaryID = benId, + beneficiaryRegID = null, + benImage = null, + statusOfWomanID = null, + isNewAbha = false, + healthIdDetails = null, + labTechnicianFlag = 0, + faceEmbedding = null + ) + + return runCatching { + patientDao.insertPatient(placeholder) + patientDao.getPatient(placeholderPatientId) + }.onFailure { err -> + Timber.w(err, "Unable to create mother placeholder for infant benId=$benId") + }.getOrNull() + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/LanguageRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/LanguageRepo.kt index cd1505829..c5dd4a4c0 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/LanguageRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/LanguageRepo.kt @@ -30,13 +30,12 @@ class LanguageRepo @Inject constructor( } } suspend fun saveResponseToCacheLang() { - languageService().forEach { language: Language -> + val languages = languageService() + if (languages.isNotEmpty()) { withContext(Dispatchers.IO) { - languageDao.insertAllLanguages(language) + languageDao.insertLanguages(languages) } - Timber.tag("itemLang").d(language.toString()) } - } suspend fun getCachedResponseLang(): List { diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/MaleMasterDataRepository.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/MaleMasterDataRepository.kt index 74aee3356..70ca22d9b 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/MaleMasterDataRepository.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/MaleMasterDataRepository.kt @@ -14,6 +14,7 @@ import org.json.JSONObject import org.piramalswasthya.cho.database.converters.MasterDataListConverter import org.piramalswasthya.cho.database.room.dao.ChiefComplaintMasterDao import org.piramalswasthya.cho.database.room.dao.HistoryDao +import org.piramalswasthya.cho.database.room.dao.ProcedureMasterDao import org.piramalswasthya.cho.database.room.dao.SubCatVisitDao import org.piramalswasthya.cho.database.room.dao.UserDao import org.piramalswasthya.cho.model.AlcoholDropdown @@ -44,6 +45,7 @@ class MaleMasterDataRepository @Inject constructor( private val chiefComplaintMasterDao: ChiefComplaintMasterDao, private val subCatVisitDao: SubCatVisitDao, private val historyDao: HistoryDao, + private val procedureMasterDao: ProcedureMasterDao, private val userRepo: UserRepo, private val userDao: UserDao, ) { @@ -309,9 +311,18 @@ class MaleMasterDataRepository @Inject constructor( return chiefComplaintMasterDao.getChiefCompMasterMap().associate { it.chiefComplaintID to it.chiefComplaint} } - suspend fun getProcedureTypeByNameMap():Map{ - return historyDao.getProceduresMap().associate { - it.procedureID to it.procedureName + /** + * Returns procedure ID -> name for doctor's lab test dropdown. + * Uses procedure_master first (so IDs match lab record form); falls back to Procedures_Master_Data if empty. + */ + suspend fun getProcedureTypeByNameMap(): Map { + val fromProcedureMaster = procedureMasterDao.getAllProcedures() + return if (fromProcedureMaster.isNotEmpty()) { + fromProcedureMaster.associate { it.procedureID.toInt() to it.procedureName } + } else { + historyDao.getProceduresMap().associate { + it.procedureID to it.procedureName + } } } } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/MaternalHealthRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/MaternalHealthRepo.kt index dc24fc110..2a082f26f 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/MaternalHealthRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/MaternalHealthRepo.kt @@ -1,26 +1,47 @@ package org.piramalswasthya.cho.repositories +import android.content.Context +import android.icu.util.Calendar import android.util.Log -import androidx.lifecycle.LiveData import com.google.gson.Gson +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import org.piramalswasthya.cho.database.room.InAppDb import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.AshaDueListCache +//import org.piramalswasthya.cho.database.room.InAppDb +//import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.AbortionDomain +import org.piramalswasthya.cho.model.PatientWithPwrCache +import org.piramalswasthya.cho.model.PmsmaDomain +import org.piramalswasthya.cho.model.asPmsmaDomainModel import org.piramalswasthya.cho.model.PregnantWomanAncCache import org.piramalswasthya.cho.model.PregnantWomanRegistrationCache import org.piramalswasthya.cho.network.AmritApiService +import org.piramalswasthya.cho.network.VillageIdList +import org.piramalswasthya.cho.network.getLongFromDate import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.AshaDueListDao import org.piramalswasthya.cho.database.room.dao.MaternalHealthDao import org.piramalswasthya.cho.database.room.dao.PatientDao //import org.piramalswasthya.sakhi.database.room.dao.BenDao //import org.piramalswasthya.sakhi.database.room.dao.MaternalHealthDao import org.piramalswasthya.cho.helpers.Konstants +import org.piramalswasthya.cho.helpers.getTodayMillis +import org.piramalswasthya.cho.helpers.getWeeksOfPregnancy import org.piramalswasthya.cho.model.ANCPost +import org.piramalswasthya.cho.model.AncCompletedListItem +import org.piramalswasthya.cho.model.AncDueListItem +import org.piramalswasthya.cho.model.PwrPost //import org.piramalswasthya.sakhi.helpers.getTodayMillis //import org.piramalswasthya.sakhi.model.* //import org.piramalswasthya.sakhi.network.GetDataPaginatedRequest @@ -28,14 +49,25 @@ import org.piramalswasthya.cho.model.ANCPost import timber.log.Timber import java.io.IOException import java.net.SocketTimeoutException -import java.text.SimpleDateFormat -import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject +import retrofit2.Response + +/** Outcome of an upload attempt for one record. */ +private sealed class AncUploadResult { + object Success : AncUploadResult() + /** Recoverable failure (network blip, 5xx, token refresh): worth retrying next cycle. */ + object Transient : AncUploadResult() + /** Server rejected the payload itself (e.g. statusCode 5000 "Saving anc data to db failed"). + * Retrying the same body forever just wastes cycles — caller should park the row. */ + data class Terminal(val message: String) : AncUploadResult() +} class MaternalHealthRepo @Inject constructor( + @ApplicationContext private val context: Context, private val amritApiService: AmritApiService, private val maternalHealthDao: MaternalHealthDao, + private val ashaDueListDao: AshaDueListDao, private val database: InAppDb, private val userRepo: UserRepo, private val patientDao: PatientDao, @@ -46,6 +78,10 @@ class MaternalHealthRepo @Inject constructor( return maternalHealthDao.getSavedRecord(benId) } + suspend fun getLatestRegistrationRecord(benId: String): PregnantWomanRegistrationCache? { + return maternalHealthDao.getLatestRegistrationRecord(benId) + } + suspend fun getActiveRegistrationRecord(benId: String): PregnantWomanRegistrationCache? { @@ -53,7 +89,6 @@ class MaternalHealthRepo @Inject constructor( maternalHealthDao.getSavedActiveRecord(benId) } } -// suspend fun getLastVisitNumber(benId: String): Int? { return maternalHealthDao.getLastVisitNumber(benId) @@ -65,29 +100,82 @@ class MaternalHealthRepo @Inject constructor( } } - -// -// suspend fun getLatestAncRecord(benId: Long): PregnantWomanAncCache? { -// return withContext(Dispatchers.IO) { -// maternalHealthDao.getLatestAnc(benId) -// } -// } -// suspend fun getAllActiveAncRecords(benId: String): List { return maternalHealthDao.getAllActiveAncRecords(benId) } + suspend fun getCompletedActiveAncRecords(benId: String): List { + return maternalHealthDao.getCompletedActiveAncRecords(benId) + } + suspend fun getLastAnc(benId: String): PregnantWomanAncCache? { return maternalHealthDao.getLastAnc(benId) } + suspend fun getLastCompletedAnc(benId: String): PregnantWomanAncCache? { + return maternalHealthDao.getLastCompletedAnc(benId) + } + suspend fun persistRegisterRecord(pregnancyRegistrationForm: PregnantWomanRegistrationCache) { withContext(Dispatchers.IO) { maternalHealthDao.saveRecord(pregnancyRegistrationForm) + generateAndPersistAncSchedule(pregnancyRegistrationForm) } } + suspend fun registerPregnancyWithAncAndAshaDueList( + pwr: PregnantWomanRegistrationCache, + benId: String, + ashaId: Int + ): Long = withContext(Dispatchers.IO) { + val registrationId = maternalHealthDao.saveRecord(pwr) + generateAndPersistAncSchedule(pwr) + addBeneficiaryToAshaDueList(benId, ashaId, pwr.createdBy) + registrationId + } + + private suspend fun addBeneficiaryToAshaDueList(patientID: String, ashaId: Int, createdBy: String) { + val patient = patientDao.getPatient(patientID) + val beneficiaryID = patient?.beneficiaryID + val record = AshaDueListCache( + patientID = patientID, + beneficiaryID = beneficiaryID, + listType = "ANC", + addedDate = System.currentTimeMillis(), + ashaId = ashaId, + createdBy = createdBy, + syncState = SyncState.UNSYNCED + ) + ashaDueListDao.insert(record) + } + + + private suspend fun generateAndPersistAncSchedule(pwr: PregnantWomanRegistrationCache) { + if (maternalHealthDao.getLastActiveVisitNumber(pwr.patientID) != null) return + val cal = Calendar.getInstance().apply { + timeInMillis = pwr.lmpDate + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val lmpStartOfDayMillis = cal.timeInMillis + val ancScheduleDaysFromLmp = listOf(84,98,196,252) + ancScheduleDaysFromLmp.forEachIndexed { index, days -> + val scheduledDateMillis = lmpStartOfDayMillis + TimeUnit.DAYS.toMillis(days.toLong()) + val ancCache = PregnantWomanAncCache( + patientID = pwr.patientID, + visitNumber = index + 1, + ancDate = scheduledDateMillis, + isActive = true, + createdBy = pwr.createdBy, + updatedBy = pwr.updatedBy, + syncState = SyncState.UNSYNCED + ) + maternalHealthDao.saveRecord(ancCache) + } + } suspend fun persistAncRecord(ancCache: PregnantWomanAncCache) { withContext(Dispatchers.IO) { @@ -101,95 +189,694 @@ class MaternalHealthRepo @Inject constructor( } } + suspend fun updatePwr(pwr: PregnantWomanRegistrationCache) { + withContext(Dispatchers.IO) { + maternalHealthDao.updatePwr(pwr) + } + } + + /** + * Retire active pregnancy records when a beneficiary is marked postnatal + * during registration/edit so she moves off PWR/ANC lists onto PNC Mother list. + */ + suspend fun retirePregnancyLifecycleForPostNatal(patientID: String) { + withContext(Dispatchers.IO) { + getSavedRegistrationRecord(patientID)?.let { pwr -> + if (pwr.active) { + pwr.active = false + pwr.syncState = SyncState.UNSYNCED + maternalHealthDao.updatePwr(pwr) + } + } + + val activeAnc = maternalHealthDao.getAllActiveAncRecords(patientID) + if (activeAnc.isNotEmpty()) { + activeAnc.forEach { + it.pregnantWomanDelivered = true + it.isActive = false + it.processed = "U" + it.syncState = SyncState.UNSYNCED + } + maternalHealthDao.updateANC(*activeAnc.toTypedArray()) + } + + database.ecrDao.getAllECT(patientID).forEach { ect -> + if (ect.isPregnant != null || ect.pregnancyTestResult != null) { + ect.isPregnant = null + ect.pregnancyTestResult = null + if (ect.processed != "N") ect.processed = "U" + ect.syncState = SyncState.UNSYNCED + database.ecrDao.updateEligibleCoupleTracking(ect) + } + } + } + } + + suspend fun getAncDueList(ancStage: Int): List { + return withContext(Dispatchers.IO) { + val allPwr = maternalHealthDao.getAllActivePregnancyRegistrations() + val todayMillis = getTodayMillis() + allPwr.mapNotNull { pwr -> + val ancRecords = maternalHealthDao.getAllActiveAncRecords(pwr.patientID) + + // Skip if already delivered + if (ancRecords.any { it.pregnantWomanDelivered == true }) { + return@mapNotNull null + } + + val gaWeeks = getWeeksOfPregnancy(todayMillis, pwr.lmpDate) + val isDue = isAncStageDue(ancStage, ancRecords, gaWeeks) + + if (isDue) { + Timber.d("ANC Due List: Patient ${pwr.patientID} added to ANC $ancStage due list (GA: $gaWeeks weeks)") + AncDueListItem(pwr.patientID, gaWeeks, ancStage) + } else null + } + } + } + + private fun isAncStageDue(ancStage: Int, ancRecords: List, gaWeeks: Int): Boolean { + // ANC 1-4: previous stage completed (weight IS NOT NULL) + GA in range + // Per Jira MHWC-196: Check if previous visit is COMPLETED, not just exists + return when (ancStage) { + 1 -> { + // ANC 1: Pregnancy registered AND GA ≤12 weeks (≤84 days from LMP) + val anc1Completed = ancRecords.any { it.visitNumber == 1 && it.weight != null } + !anc1Completed && gaWeeks <= Konstants.maxAnc1Week + } + 2 -> { + // ANC 2: ANC 1 completed AND GA ≥14 weeks AND <28 weeks + val anc1Completed = ancRecords.any { it.visitNumber == 1 && it.weight != null } + val anc2Completed = ancRecords.any { it.visitNumber == 2 && it.weight != null } + anc1Completed && !anc2Completed && gaWeeks >= Konstants.minAnc2Week && gaWeeks < 28 + } + 3 -> { + // ANC 3: ANC 2 completed AND GA ≥28 weeks AND <36 weeks + val anc2Completed = ancRecords.any { it.visitNumber == 2 && it.weight != null } + val anc3Completed = ancRecords.any { it.visitNumber == 3 && it.weight != null } + anc2Completed && !anc3Completed && gaWeeks >= Konstants.minAnc3Week && gaWeeks < 36 + } + 4 -> { + // ANC 4: ANC 3 completed AND GA ≥36 weeks AND ≤40 weeks + val anc3Completed = ancRecords.any { it.visitNumber == 3 && it.weight != null } + val anc4Completed = ancRecords.any { it.visitNumber == 4 && it.weight != null } + anc3Completed && !anc4Completed && gaWeeks >= Konstants.minAnc4Week && gaWeeks <= Konstants.maxAnc4Week + } + else -> false + } + } + + suspend fun getAncCompletedList(ancStage: Int): List { + return withContext(Dispatchers.IO) { + val allPwr = maternalHealthDao.getAllActivePregnancyRegistrations() + allPwr.mapNotNull { pwr -> + val ancRecords = maternalHealthDao.getAllActiveAncRecords(pwr.patientID) + if (ancRecords.any { it.pregnantWomanDelivered == true }) return@mapNotNull null + // Per Jira MHWC-196: Only include visits that are COMPLETED (weight IS NOT NULL) + ancRecords.find { it.visitNumber == ancStage && it.weight != null }?.let { anc -> + Timber.d("ANC Completed List: Patient ${pwr.patientID} in ANC $ancStage completed list (Visit: ${anc.visitNumber})") + AncCompletedListItem(pwr.patientID, ancStage, anc.visitNumber) + } + } + } + } suspend fun processNewAncVisit(): Boolean { return withContext(Dispatchers.IO) { val ancList = maternalHealthDao.getAllUnprocessedAncVisits() + Timber.d("ANC upload queue size: ${ancList.size}") + if (ancList.isEmpty()) { + Timber.d("No unsynced ANC records found for upload") + return@withContext true + } val ancPostList = mutableSetOf() + val user = userRepo.getLoggedInUser() + var hasFailures = false ancList.forEach { ancPostList.clear() val ben = patientDao.getPatient(it.patientID) if(ben.beneficiaryID != null){ - ancPostList.add(it.asPostModel(ben.beneficiaryID!!)) + ancPostList.add( + it.asPostModel( + benId = ben.beneficiaryID!!, + benRegId = ben.beneficiaryRegID, + providerServiceMapID = user?.serviceMapId, + context = context, + ) + ) it.syncState = SyncState.SYNCING maternalHealthDao.updateANC(it) - val uploadDone = postDataToAmritServer(ancPostList) - if (uploadDone) { - it.processed = "P" - it.syncState = SyncState.SYNCED - } else { - it.syncState = SyncState.UNSYNCED + Timber.d("processNewAncVisit: posting row id=${it.id} patientID=${it.patientID}") + when (val result = postDataToAmritServer(ancPostList)) { + AncUploadResult.Success -> { + // Targeted column update — avoids writing the whole + // row (which would persist any stale field still on + // `it`) and is robust against parallel writers in + // the same sync chain. + maternalHealthDao.markAncSynced(it.id) + Timber.d("processNewAncVisit: row id=${it.id} marked SYNCED") + } + is AncUploadResult.Terminal -> { + // Park the row: getAllUnprocessedAncVisits filters on + // processed IN ('N','U'), so "F" stops the retry loop. + // A subsequent edit resets processed to 'U' and the row + // re-enters the queue. + Timber.w("Parking ANC row ${it.id}: ${result.message}") + it.processed = "F" + it.syncState = SyncState.UNSYNCED + maternalHealthDao.updateANC(it) + hasFailures = true + } + AncUploadResult.Transient -> { + it.syncState = SyncState.UNSYNCED + maternalHealthDao.updateANC(it) + hasFailures = true + } } - maternalHealthDao.updateANC(it) -// if (!uploadDone) -// return@withContext false + } else { + Timber.w("Skipping ANC upload for patient ${it.patientID}: beneficiaryID is null") + hasFailures = true + } + } + + return@withContext !hasFailures + } + } + + suspend fun processNewPWRRecords(): Boolean { + return withContext(Dispatchers.IO) { + val pwrList = maternalHealthDao.getAllUnprocessedPWRs() + if (pwrList.isEmpty()) { + Timber.d("No unsynced PWR records found for upload") + return@withContext true + } + + var hasFailures = false + val pwrPostList = mutableSetOf() + + pwrList.forEach { pwr -> + pwrPostList.clear() + val patient = patientDao.getPatient(pwr.patientID) + val benId = patient.beneficiaryID + if (benId == null) { + Timber.w("Skipping PWR upload for patient ${pwr.patientID}: beneficiaryID is null") + hasFailures = true + return@forEach + } + + pwr.syncState = SyncState.SYNCING + maternalHealthDao.updatePwr(pwr) + + pwrPostList.add(pwr.asPwrPost().copy(benId = benId)) + val uploadDone = postPwrDataToAmritServer(pwrPostList) + if (uploadDone) { + pwr.processed = "P" + pwr.syncState = SyncState.SYNCED + } else { + pwr.syncState = SyncState.UNSYNCED + hasFailures = true } + maternalHealthDao.updatePwr(pwr) + } + + return@withContext !hasFailures + } + } + + /** + * Get all patients with their pregnancy registration data + */ + fun getAllPatientsWithPWR(): Flow> { + return maternalHealthDao.getAllPatientsWithPWR() + .combine(maternalHealthDao.getAllPatientsWithPWRFromEligibleCoupleTracking()) { pwrList, ectPositiveList -> + (pwrList + ectPositiveList) + .distinctBy { it.patient.patientID } + .sortedByDescending { + it.getActiveOrLatestPwr()?.createdDate ?: it.patient.registrationDate?.time ?: 0L + } + } + } + + fun getAllPatientsWithANC(): Flow> { + return maternalHealthDao.getAllPatientsWithANC() + .combine(maternalHealthDao.getAllPatientsWithPWRFromEligibleCoupleTracking()) { pwrList, ectPositiveList -> + (pwrList + ectPositiveList) + .distinctBy { it.patient.patientID } + .sortedByDescending { + it.getActiveOrLatestPwr()?.createdDate ?: it.patient.registrationDate?.time ?: 0L + } } + } + + /** + * Get specific patient with pregnancy registration + */ + suspend fun getPatientWithPWR(patientID: String): PatientWithPwrCache? { + return withContext(Dispatchers.IO) { + maternalHealthDao.getPatientWithPWR(patientID) + } + } - return@withContext true + /** + * Get count of women on the Pregnant Women Registration list. + * Derives from the same combined Flow as PregnantWomenRegistrationFragment + * (PWR-side + ECT-positive catch-all) and applies the same Kotlin predicate, + * so the grid count and the list row count cannot drift. + */ + fun getPWRCount(): Flow { + return getAllPatientsWithPWR().map { list -> + list.map { it.asDomainModel() } + .count { domain -> + val isFemale = domain.patient.genderID == 2 + val age = domain.patient.age ?: 0 + val isReproductiveAge = age in 15..49 + val isPostnatal = domain.patient.statusOfWomanID == 3 + isFemale && isReproductiveAge && !isPostnatal + } + } + } + + /** + * Get count of women eligible for ANC. + * Derives from the same combined Flow as ANCVisitsFragment (PWR-side + ECT-positive catch-all) + * and applies the same Kotlin predicate, so the grid count and the list row count cannot drift. + */ + fun getANCCount(): Flow { + val ancEligibilityWindowMillis = 35L * 24 * 60 * 60 * 1000 + return getAllPatientsWithANC().map { list -> + val now = System.currentTimeMillis() + val cutoff = now - ancEligibilityWindowMillis + list.map { it.asDomainModel() } + .count { domain -> + val hasActivePWR = domain.pwr != null && domain.isActive() + val isFemale = domain.patient.genderID == 2 + val age = domain.patient.age ?: 0 + val isReproductiveAge = age in 15..49 + val isEligibleForANC = domain.pwr?.lmpDate?.let { lmp -> + lmp <= cutoff + } ?: false + hasActivePWR && isFemale && isReproductiveAge && isEligibleForANC + } } } - private suspend fun postDataToAmritServer(ancPostList: MutableSet): Boolean { - if (ancPostList.isEmpty()) return false + /** + * Get all patients who have delivered + * Uses batch query to avoid N+1 queries + */ + fun getAllDeliveredWomen(): Flow> { + return maternalHealthDao.getDeliveredWomenPatientIDs() + .transformLatest { patientIDs -> + if (patientIDs.isNotEmpty()) { + val patients = maternalHealthDao.getDeliveredWomenByIDs(patientIDs) + emit(patients) + } else { + emit(emptyList()) + } + } + } + + /** + * Get count of delivered women + */ + fun getDeliveredWomenCount(): Flow { + return maternalHealthDao.getDeliveredWomenCount() + } + + /** + * Get all women with abortion records + */ + fun getAbortionPregnantWomanList(): Flow> { + return maternalHealthDao.getAllAbortionWomenList() + .map { list -> + list.map { it.asAbortionDomainModel() } + .filter { it.abortionDate != null } // Ensure abortion date exists + } + } + + /** + * Get count of abortion women + */ + fun getAbortionWomenCount(): Flow { + return maternalHealthDao.getAllAbortionWomenCount() + } + + /** + * Get all women registered for pregnancy (eligible for PMSMA) + */ + fun getRegisteredPmsmaWomenList(): Flow> { + return maternalHealthDao.getAllRegisteredPmsmaWomenList() + .map { list -> list.map { it.asPmsmaDomainModel() } } + } + + /** + * Get count of PMSMA eligible women + */ + fun getRegisteredPmsmaWomenCount(): Flow { + return maternalHealthDao.getAllRegisteredPmsmaWomenCount() + } + + /** + * Get e-PMSMA women — the subset of ANC-eligible women who have at least one + * active ANC visit flagged anyHighRisk = true. + * + * Derived from the same Flow as ANCVisitsFragment / getANCCount and intersected + * with the high-risk patientID set, so the e-PMSMA list cannot drift from the + * ANC tile. + */ + fun getEPmsmaWomenList(): Flow> { + val ancEligibilityWindowMillis = 35L * 24 * 60 * 60 * 1000 + return getAllPatientsWithANC() + .combine(maternalHealthDao.getHighRiskAncPatientIDs()) { patientList, hrIds -> + val cutoff = System.currentTimeMillis() - ancEligibilityWindowMillis + val hrSet = hrIds.toHashSet() + patientList + .filter { hrSet.contains(it.patient.patientID) } + .filter { p -> + val domain = p.asDomainModel() + val hasActivePWR = domain.pwr != null && domain.isActive() + val isFemale = domain.patient.genderID == 2 + val age = domain.patient.age ?: 0 + val isReproductiveAge = age in 15..49 + val isEligibleForANC = + domain.pwr?.lmpDate?.let { lmp -> lmp <= cutoff } ?: false + hasActivePWR && isFemale && isReproductiveAge && isEligibleForANC + } + .map { it.asPmsmaDomainModel() } + .sortedByDescending { it.pwr?.dateOfRegistration ?: 0L } + } + } + + /** + * Count of e-PMSMA women. Derived from getEPmsmaWomenList so the tile count + * and list row count cannot drift. + */ + fun getEPmsmaWomenCount(): Flow = getEPmsmaWomenList().map { it.size } + + /** + * Get all women eligible for neonatal outcome (those with a saved delivery outcome) + */ + fun getNeonatalOutcomeEligibleWomen(): Flow> { + return maternalHealthDao.getNeonatalOutcomeEligibleWomenPatientIDs() + .transformLatest { patientIDs -> + if (patientIDs.isNotEmpty()) { + val patients = maternalHealthDao.getDeliveredWomenByIDs(patientIDs) + emit(patients) + } else { + emit(emptyList()) + } + } + } + + /** + * Get patientIDs of women who have a saved delivery outcome + */ + fun getNeonatalOutcomeEligibleWomenPatientIDs(): Flow> { + return maternalHealthDao.getNeonatalOutcomeEligibleWomenPatientIDs() + } + + /** + * Get count of women eligible for neonatal outcome + */ + fun getNeonatalOutcomeEligibleWomenCount(): Flow { + return maternalHealthDao.getNeonatalOutcomeEligibleWomenCount() + } + + private suspend fun postDataToAmritServer(ancPostList: MutableSet): AncUploadResult { + if (ancPostList.isEmpty()) return AncUploadResult.Transient val user = userRepo.getLoggedInUser() ?: throw IllegalStateException("No user logged in!!") - try { + // Bounded retry: recursion + non-local control flow via thrown + // SocketTimeoutException could spin forever (and eventually overflow + // the stack) when the server is persistently issuing 5002 or the + // network is permanently flaky. After MAX_ANC_UPLOAD_ATTEMPTS we + // yield to WorkManager — its outer backoff is the right place to + // delay further attempts. + repeat(MAX_ANC_UPLOAD_ATTEMPTS) { attempt -> + try { + val response = amritApiService.postAncForm(ancPostList.toList()) + val httpCode = response.code() + if (httpCode != 200) { + Timber.w("ANC saveAll bad HTTP code=$httpCode (attempt ${attempt + 1})") + return AncUploadResult.Transient + } - val response = amritApiService.postAncForm(ancPostList.toList()) - val statusCode = response.code() + val responseString = response.body()?.string() + Timber.d("ANC saveAll response body: ${responseString?.take(300)}") + if (responseString.isNullOrBlank()) { + Timber.d("ANC saveAll succeeded with empty response body") + return AncUploadResult.Success + } - if (statusCode == 200) { - try { - val responseString = response.body()?.string() - if (responseString != null) { - val jsonObj = JSONObject(responseString) - - val errormessage = jsonObj.getString("errorMessage") - if (jsonObj.isNull("statusCode")) throw IllegalStateException("Amrit server not responding properly, Contact Service Administrator!!") - val responsestatuscode = jsonObj.getInt("statusCode") - - when (responsestatuscode) { - 200 -> { - Timber.d("Saved Successfully to server") - return true - } + val jsonObj = try { + JSONObject(responseString) + } catch (e: Exception) { + Timber.w(e, "ANC saveAll non-JSON body, treating as success: $responseString") + return AncUploadResult.Success + } - 5002 -> { - if (userRepo.refreshTokenTmc( - user.userName, - user.password - ) - ) throw SocketTimeoutException() - } + val statusCode = jsonObj.optInt("statusCode", 200) + val errorMessage = jsonObj.optString("errorMessage") + + when (statusCode) { + 200 -> { + Timber.d("ANC saved successfully to server") + return AncUploadResult.Success + } + 5002 -> { + Timber.d("ANC saveAll got 5002; refreshing token (attempt ${attempt + 1})") + if (!userRepo.refreshTokenTmc(user.userName, user.password)) { + Timber.w("Token refresh failed") + return AncUploadResult.Transient + } + // Token refreshed — fall through to next loop iteration. + return@repeat + } + // 5000 = server-side persistence failure — usually a payload + // the server refuses to accept (e.g. oversized base64 image). + // Retrying the same body just loops forever, so we mark it + // terminal and park the row until the user edits / re-saves. + 5000 -> { + Log.d("anc error message", errorMessage) + Timber.w("ANC saveAll permanently rejected (5000): $errorMessage") + return AncUploadResult.Terminal(errorMessage) + } + else -> { + Log.d("anc error message", errorMessage) + Timber.w("ANC saveAll server error $statusCode: $errorMessage") + return AncUploadResult.Transient + } + } + } catch (e: SocketTimeoutException) { + Timber.d("ANC saveAll timed out (attempt ${attempt + 1}); retrying") + // Fall through to next iteration. + } catch (e: JSONException) { + Timber.d("ANC saveAll JSON parse error: $e") + return AncUploadResult.Transient + } + } + + Timber.w("ANC saveAll exhausted $MAX_ANC_UPLOAD_ATTEMPTS attempts; deferring to next sync cycle") + return AncUploadResult.Transient + } + + private companion object { + private const val MAX_ANC_UPLOAD_ATTEMPTS = 3 + } - else -> { - Log.d("anc error message", errormessage) - throw IOException("Throwing away IO eXcEpTiOn") + private suspend fun postPwrDataToAmritServer(pwrPostList: MutableSet): Boolean { + if (pwrPostList.isEmpty()) return false + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + try { + val response = amritApiService.postPregnantWomanForm(pwrPostList.toList()) + val statusCode = response.code() + if (statusCode == 200) { + val responseString = response.body()?.string() + if (responseString != null) { + val jsonObj = JSONObject(responseString) + val responseStatusCode = jsonObj.optInt("statusCode", 200) + val errorMessage = jsonObj.optString("errorMessage") + when (responseStatusCode) { + 200 -> { + Timber.d("PWR saved successfully to server") + return true + } + 5002 -> { + if (userRepo.refreshTokenTmc(user.userName, user.password)) { + throw SocketTimeoutException() } } + else -> { + Timber.w("PWR saveAll failed: $errorMessage") + throw IOException("PWR saveAll failed with statusCode=$responseStatusCode") + } } - } catch (e: IOException) { - e.printStackTrace() - } catch (e: Exception) { - e.printStackTrace() } - } else { - //server_resp5(); } - Timber.w("Bad Response from server, need to check $ancPostList $response ") + Timber.w("Bad response from server for PWR saveAll: $response") return false } catch (e: SocketTimeoutException) { - Timber.d("Caught exception $e here") - return postDataToAmritServer(ancPostList) + Timber.d("Caught timeout for PWR sync $e; retrying") + return postPwrDataToAmritServer(pwrPostList) } catch (e: JSONException) { - Timber.d("Caught exception $e here") + Timber.d("Caught JSON exception for PWR sync $e") return false } } + private fun extractGetAllDataArray(jsonObj: JSONObject): JSONArray { + return when (val dataNode = jsonObj.opt("data")) { + is JSONArray -> dataNode + is JSONObject -> dataNode.optJSONArray("data") + else -> null + } ?: JSONArray() + } + + private suspend fun pullMaternalRecordsFromServer( + moduleName: String, + fetch: suspend (VillageIdList) -> Response, + retryOnTimeout: suspend () -> Boolean, + parseItem: (Gson, JSONObject) -> T, + benIdOf: (T) -> Long, + saveItem: suspend (item: T, patientID: String, userName: String) -> Unit + ): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + try { + val villageList = VillageIdList( + RepositorySyncUtils.parseVillageIds(user.assignVillageIds ?: ""), + preferenceDao.getLastPatientSyncTime() + ) + val response = fetch(villageList) + if (response.code() != 200) { + Timber.w("Bad response from server for $moduleName getAll: $response") + return@withContext false + } + + val responseString = response.body()?.string() + if (responseString.isNullOrBlank()) { + Timber.w("Empty response body for $moduleName getAll") + return@withContext false + } + + val jsonObj = JSONObject(responseString) + val responseStatusCode = jsonObj.optInt("statusCode", 200) + val errorMessage = jsonObj.optString("errorMessage") + when (responseStatusCode) { + 200 -> { + val dataArray = extractGetAllDataArray(jsonObj) + val gson = Gson() + var savedCount = 0 + for (i in 0 until dataArray.length()) { + val item = parseItem(gson, dataArray.getJSONObject(i)) + val benId = benIdOf(item) + if (benId == 0L) { + Timber.w("Skipping $moduleName getAll item with invalid benId at index=$i") + continue + } + + val patient = patientDao.getPatientByAnyBeneficiaryId(benId) + if (patient == null) { + Timber.w("No local patient found for $moduleName benId=$benId, skipping") + continue + } + + saveItem(item, patient.patientID, user.userName) + savedCount++ + } + Timber.d("$moduleName getAll downsync completed, saved=$savedCount received=${dataArray.length()}") + return@withContext true + } + + 5000 -> { + Timber.d("No $moduleName records found on server") + return@withContext true + } + + 5002 -> { + if (userRepo.refreshTokenTmc(user.userName, user.password)) { + throw SocketTimeoutException() + } + } + + else -> { + Timber.w("$moduleName getAll failed: $errorMessage") + throw IOException("$moduleName getAll failed with statusCode=$responseStatusCode") + } + } + return@withContext false + } catch (e: SocketTimeoutException) { + Timber.d("Caught timeout for $moduleName getAll $e; retrying") + return@withContext retryOnTimeout() + } catch (e: JSONException) { + Timber.d("Caught JSON exception for $moduleName getAll $e") + return@withContext false + } + } + } + + suspend fun pullPregnantWomenFromServer(): Boolean { + return pullMaternalRecordsFromServer( + moduleName = "PWR", + fetch = { villageList -> amritApiService.getAllPregnantWomen(villageList) }, + retryOnTimeout = { pullPregnantWomenFromServer() }, + parseItem = { gson, json -> gson.fromJson(json.toString(), PwrPost::class.java) }, + benIdOf = { pwrNetwork -> pwrNetwork.benId }, + saveItem = { pwrNetwork, patientID, userName -> + val incoming = pwrNetwork.toPwrCache().copy( + patientID = patientID, + processed = "P", + syncState = SyncState.SYNCED, + updatedDate = System.currentTimeMillis(), + updatedBy = pwrNetwork.updatedBy.ifBlank { userName } + ) + val existing = maternalHealthDao.getSavedRecord(patientID) + val merged = if (existing != null) { + incoming.copy( + id = existing.id, + createdDate = if (existing.createdDate > 0L) existing.createdDate else incoming.createdDate, + createdBy = if (existing.createdBy.isNotBlank()) existing.createdBy else incoming.createdBy + ) + } else { + incoming + } + maternalHealthDao.saveRecord(merged) + } + ) + } -} \ No newline at end of file + suspend fun pullAncVisitsFromServer(): Boolean { + return pullMaternalRecordsFromServer( + moduleName = "ANC", + fetch = { villageList -> amritApiService.getAllAncVisits(villageList) }, + retryOnTimeout = { pullAncVisitsFromServer() }, + parseItem = { gson, json -> gson.fromJson(json.toString(), ANCPost::class.java) }, + benIdOf = { ancNetwork -> ancNetwork.benId }, + saveItem = { ancNetwork, patientID, userName -> + val incoming = ancNetwork.toAncCache().copy( + patientID = patientID, + processed = "P", + syncState = SyncState.SYNCED, + updatedDate = if (ancNetwork.updatedDate.isNullOrBlank()) System.currentTimeMillis() else getLongFromDate(ancNetwork.updatedDate), + updatedBy = ancNetwork.updatedBy.ifBlank { userName } + ) + val existing = maternalHealthDao.getSavedRecord(patientID, incoming.visitNumber) + val merged = if (existing != null) { + incoming.copy( + id = existing.id, + createdDate = if (existing.createdDate > 0L) existing.createdDate else incoming.createdDate, + createdBy = if (existing.createdBy.isNotBlank()) existing.createdBy else incoming.createdBy + ) + } else { + incoming + } + maternalHealthDao.saveRecord(merged) + } + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/MentalHealthScreeningRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/MentalHealthScreeningRepo.kt new file mode 100644 index 000000000..654a1d1ad --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/MentalHealthScreeningRepo.kt @@ -0,0 +1,121 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.MentalHealthScreeningDao +import org.piramalswasthya.cho.model.MentalHealthScreeningCache +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.model.MentalHealthNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import timber.log.Timber + +class MentalHealthScreeningRepo @Inject constructor( + private val mentalHealthScreeningDao: MentalHealthScreeningDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun saveScreening(screening: MentalHealthScreeningCache): MentalHealthScreeningCache { + return if (screening.screeningId == 0L) { + screening.benVisitNo?.let { visitNo -> + cphcDetailsRepo.clearAssessmentsForVisit(screening.patientId, visitNo) + } + val screeningId = mentalHealthScreeningDao.insert(screening) + screening.copy(screeningId = screeningId) + } else { + mentalHealthScreeningDao.update(screening) + screening + } + } + + suspend fun getScreeningByPatientId( + patientID: String + ): MentalHealthScreeningCache? { + return mentalHealthScreeningDao.getScreeningByPatientId(patientID) + } + + suspend fun getScreeningByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): MentalHealthScreeningCache? { + return mentalHealthScreeningDao.getScreeningByPatientIdAndVisitNo( + patientID, benVisitNo + ) + } + suspend fun processMentalVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = mentalHealthScreeningDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientId) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postMentalForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + mentalHealthScreeningDao.update(assessment) + }, + logLabel = "Mental Health records" + ) + } + } + + suspend fun pullMentalVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + if (user == null) { + Timber.w("No user logged in. Skipping pull for Mental Health records") + return@withContext false + } + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getMentalVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), MentalHealthNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getScreeningByPatientIdAndVisitNo( + patientId, + networkObj.benVisitNo ?: 0 + ) != null + }, + insertNew = { patientId, networkObj -> + saveScreening( + networkObj.toCacheModel(patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Mental Health records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/NeonatalOutcomeRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/NeonatalOutcomeRepo.kt new file mode 100644 index 000000000..a14647ceb --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/NeonatalOutcomeRepo.kt @@ -0,0 +1,109 @@ +package org.piramalswasthya.cho.repositories + +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.NeonatalOutcomeDao +import org.piramalswasthya.cho.model.NeonatalOutcomeCache +import javax.inject.Inject + +/** + * Repository for Neonatal Outcome operations + * Handles data access and business logic for neonatal outcome records + */ +class NeonatalOutcomeRepo @Inject constructor( + private val neonatalOutcomeDao: NeonatalOutcomeDao +) { + + /** + * Insert a single neonatal outcome record + */ + suspend fun insertNeonatalOutcome(neonatalOutcome: NeonatalOutcomeCache): Long { + return neonatalOutcomeDao.insertNeonatalOutcome(neonatalOutcome) + } + + /** + * Insert multiple neonatal outcomes (for twins/triplets/etc.) + */ + suspend fun insertNeonatalOutcomes(neonatalOutcomes: List): List { + return neonatalOutcomeDao.insertNeonatalOutcomes(neonatalOutcomes) + } + + /** + * Update an existing neonatal outcome record + */ + suspend fun updateNeonatalOutcome(neonatalOutcome: NeonatalOutcomeCache): Int { + return neonatalOutcomeDao.updateNeonatalOutcome(neonatalOutcome) + } + + /** + * Get all neonatal outcomes for a specific delivery + */ + suspend fun getNeonatalOutcomesByDeliveryId(deliveryOutcomeId: Long): List { + return neonatalOutcomeDao.getNeonatalOutcomesByDeliveryId(deliveryOutcomeId) + } + + /** + * Get all neonatal outcomes for a delivery as Flow (for reactive UI) + */ + fun getNeonatalOutcomesByDeliveryIdFlow(deliveryOutcomeId: Long): Flow> { + return neonatalOutcomeDao.getNeonatalOutcomesByDeliveryIdFlow(deliveryOutcomeId) + } + + /** + * Get all neonatal outcomes for a delivery as LiveData + */ + fun getNeonatalOutcomesByDeliveryIdLiveData(deliveryOutcomeId: Long): LiveData> { + return neonatalOutcomeDao.getNeonatalOutcomesByDeliveryIdFlow(deliveryOutcomeId).asLiveData() + } + + /** + * Get a specific neonatal outcome by ID + */ + suspend fun getNeonatalOutcomeById(neonatalOutcomeId: Long): NeonatalOutcomeCache? { + return neonatalOutcomeDao.getNeonatalOutcomeById(neonatalOutcomeId) + } + + /** + * Get a specific neonate by delivery ID and index + */ + suspend fun getNeonatalOutcomeByIndexAndDelivery(deliveryOutcomeId: Long, neonateIndex: Int): NeonatalOutcomeCache? { + return neonatalOutcomeDao.getNeonatalOutcomeByIndexAndDelivery(deliveryOutcomeId, neonateIndex) + } + + /** + * Get all records pending sync with server + */ + suspend fun getAllPendingSync(): List { + return neonatalOutcomeDao.getAllPendingSync(SyncState.UNSYNCED) + } + + /** + * Get count of neonates for a delivery + */ + suspend fun getCountByDeliveryId(deliveryOutcomeId: Long): Int { + return neonatalOutcomeDao.getCountByDeliveryId(deliveryOutcomeId) + } + + /** + * Update sync state for a neonatal outcome + */ + suspend fun updateSyncState(id: Long, syncState: SyncState): Int { + return neonatalOutcomeDao.updateSyncState(id, syncState) + } + + /** + * Check if neonatal outcomes exist for a delivery + */ + suspend fun hasNeonatalOutcomes(deliveryOutcomeId: Long): Boolean { + return neonatalOutcomeDao.hasNeonatalOutcomes(deliveryOutcomeId) + } + + /** + * Delete all neonatal outcomes for a delivery + */ + suspend fun deleteByDeliveryId(deliveryOutcomeId: Long): Int { + return neonatalOutcomeDao.deleteByDeliveryId(deliveryOutcomeId) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/NoseDiagnosisRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/NoseDiagnosisRepo.kt new file mode 100644 index 000000000..8f5513961 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/NoseDiagnosisRepo.kt @@ -0,0 +1,115 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.NoseDiagnosisAssessmentDao +import org.piramalswasthya.cho.model.NoseDiagnosisAssessment +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.model.NoseDiagnosisNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import timber.log.Timber + +class NoseDiagnosisRepo @Inject constructor( + private val noseDiagnosisAssessmentDao: NoseDiagnosisAssessmentDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun saveAssessment(assessment: NoseDiagnosisAssessment) { + if (assessment.assessmentId == 0L) { + assessment.benVisitNo?.let { visitNo -> + cphcDetailsRepo.clearAssessmentsForVisit(assessment.patientId, visitNo) + } + noseDiagnosisAssessmentDao.insert(assessment) + } else { + noseDiagnosisAssessmentDao.update(assessment) + } + } + + suspend fun getAssessmentByPatientId(patientID: String): NoseDiagnosisAssessment? { + return noseDiagnosisAssessmentDao.getAssessmentByPatientId(patientID) + } + + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): NoseDiagnosisAssessment? { + return noseDiagnosisAssessmentDao.getAssessmentByPatientIdAndVisitNo(patientID, benVisitNo) + } + suspend fun processNoseVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = noseDiagnosisAssessmentDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientId) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postNoseForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + noseDiagnosisAssessmentDao.update(assessment) + }, + logLabel = "Nose Diagnosis records" + ) + } + } + + suspend fun pullNoseVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + if (user == null) { + Timber.w("No user logged in. Skipping pull for Nose Diagnosis records") + return@withContext false + } + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getNoseVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), NoseDiagnosisNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getAssessmentByPatientIdAndVisitNo( + patientId, + networkObj.benVisitNo ?: 0 + ) != null + }, + insertNew = { patientId, networkObj -> + saveAssessment( + networkObj.toCacheModel(patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Nose Diagnosis records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/OphthalmicRepository.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/OphthalmicRepository.kt new file mode 100644 index 000000000..32941a560 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/OphthalmicRepository.kt @@ -0,0 +1,117 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.OphthalmicDao +import org.piramalswasthya.cho.model.OphthalmicVisit +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.model.OphthalmicNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.utils.generateUuid +import timber.log.Timber +import org.json.JSONArray + +class OphthalmicRepository @Inject constructor( + private val ophthalmicDao: OphthalmicDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun getOphthalmicVisit(patientID: String, benVisitNo: Int): OphthalmicVisit? { + return ophthalmicDao.getOphthalmicVisit(patientID, benVisitNo) + } + + suspend fun saveOphthalmicVisit(visit: OphthalmicVisit) { + if (visit.visitId.isNotEmpty()) { + val existing = ophthalmicDao.getOphthalmicVisitById(visit.visitId) + if (existing == null) { + cphcDetailsRepo.clearAssessmentsForVisit(visit.patientID, visit.benVisitNo) + ophthalmicDao.insertOphthalmicVisit(visit) + } else { + ophthalmicDao.updateOphthalmicVisit(visit) + } + } else { + cphcDetailsRepo.clearAssessmentsForVisit(visit.patientID, visit.benVisitNo) + ophthalmicDao.insertOphthalmicVisit(visit) + } + } + + suspend fun updateOphthalmicVisit(visit: OphthalmicVisit) { + ophthalmicDao.updateOphthalmicVisit(visit) + } + suspend fun processOphthalmicVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = ophthalmicDao.getUnsyncedOphthalmicVisits() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientID) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postOphthalmicForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + ophthalmicDao.updateOphthalmicVisit(assessment) + }, + logLabel = "Ophthalmic records" + ) + } + } + + suspend fun pullOphthalmicVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getOphthalmicVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), OphthalmicNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + val visitNo = networkObj.benVisitNo ?: 0 + getOphthalmicVisit(patientId, visitNo) != null + }, + insertNew = { patientId, networkObj -> + val visitId: String = networkObj.visitId?.takeIf { it.isNotBlank() } + ?: generateUuid() + saveOphthalmicVisit( + networkObj.toCacheModel(patientId).copy( + visitId = visitId, + patientID = patientId, + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Ophthalmic records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/OralHealthRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/OralHealthRepo.kt new file mode 100644 index 000000000..ee54fb052 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/OralHealthRepo.kt @@ -0,0 +1,110 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.OralHealthDao +import org.piramalswasthya.cho.model.OralHealth +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.model.OralHealthNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import timber.log.Timber +import org.json.JSONArray + +class OralHealthRepo @Inject constructor( + private val oralHealthDao: OralHealthDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun save(oralHealth: OralHealth) { + if (oralHealth.oralHealthId == 0L) { + oralHealth.benVisitNo?.let { visitNo -> + cphcDetailsRepo.clearAssessmentsForVisit(oralHealth.patientID, visitNo) + } + oralHealthDao.insert(oralHealth) + } else { + oralHealthDao.update(oralHealth) + } + } + + suspend fun getByPatientId(patientID: String): OralHealth? { + return oralHealthDao.getByPatientId(patientID) + } + + suspend fun getByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): OralHealth? { + return oralHealthDao.getByPatientIdAndVisitNo(patientID, benVisitNo) + } + suspend fun processOralVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = oralHealthDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientID) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postOralForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + oralHealthDao.update(assessment) + }, + logLabel = "Oral Health records" + ) + } + } + + suspend fun pullOralVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getOralVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), OralHealthNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getByPatientIdAndVisitNo(patientId, networkObj.benVisitNo ?: 0) != null + }, + insertNew = { patientId, networkObj -> + save( + networkObj.toCacheModel(patientID = patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Oral Health records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/PainAndSymptomAssessmentRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/PainAndSymptomAssessmentRepo.kt new file mode 100644 index 000000000..49fcbae3e --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/PainAndSymptomAssessmentRepo.kt @@ -0,0 +1,119 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.PainAndSymptomAssessmentDao +import org.piramalswasthya.cho.model.PainAndSymptomAssessment +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.PainAssessmentNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import timber.log.Timber +import org.json.JSONArray + +class PainAndSymptomAssessmentRepo @Inject constructor( + private val painAndSymptomAssessmentDao: PainAndSymptomAssessmentDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun saveAssessment(assessment: PainAndSymptomAssessment) { + if (assessment.assessmentId == 0L) { + assessment.benVisitNo?.let { visitNo -> + cphcDetailsRepo.clearAssessmentsForVisit(assessment.patientID, visitNo) + } + painAndSymptomAssessmentDao.insert(assessment) + } else { + painAndSymptomAssessmentDao.update(assessment) + } + } + + suspend fun getAssessmentByPatientId( + patientID: String + ): PainAndSymptomAssessment? { + return painAndSymptomAssessmentDao.getAssessmentByPatientId(patientID) + } + + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): PainAndSymptomAssessment? { + return painAndSymptomAssessmentDao + .getAssessmentByPatientIdAndVisitNo(patientID, benVisitNo) + } + suspend fun processPainvisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = painAndSymptomAssessmentDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientID) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postPainForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + painAndSymptomAssessmentDao.update(assessment) + }, + logLabel = "Pain Assessment records" + ) + } + } + + suspend fun pullPainVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + if (user == null) { + Timber.w("No user logged in. Skipping pull for Pain Assessment records") + return@withContext false + } + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getPainVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), PainAssessmentNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getAssessmentByPatientIdAndVisitNo( + patientId, + networkObj.benVisitNo ?: 0 + ) != null + }, + insertNew = { patientId, networkObj -> + saveAssessment( + networkObj.toCacheModel(patientID = patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Pain Assessment records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/PatientRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/PatientRepo.kt index 2d2172cc4..f4fba921a 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/PatientRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/PatientRepo.kt @@ -19,6 +19,7 @@ import org.piramalswasthya.cho.database.room.dao.DistrictMasterDao import org.piramalswasthya.cho.database.room.dao.PatientDao import org.piramalswasthya.cho.database.room.dao.PrescriptionDao import org.piramalswasthya.cho.database.room.dao.ProcedureDao +import org.piramalswasthya.cho.database.room.dao.ProcedureMasterDao import org.piramalswasthya.cho.database.room.dao.RegistrarMasterDataDao import org.piramalswasthya.cho.database.room.dao.StateMasterDao import org.piramalswasthya.cho.database.room.dao.VillageMasterDao @@ -85,11 +86,36 @@ class PatientRepo @Inject constructor( private val blockMasterDao: BlockMasterDao, private val villageMasterDao: VillageMasterDao, private val procedureDao: ProcedureDao, + private val procedureMasterDao: ProcedureMasterDao, private val prescriptionDao: PrescriptionDao, private val registrarMasterDataDao: RegistrarMasterDataDao, private val batchDao: BatchDao, + private val maternalHealthRepo: MaternalHealthRepo, + private val deliveryOutcomeRepo: DeliveryOutcomeRepo, ) { + companion object { + private const val STATUS_POST_NATAL_MOTHER = 3 + } + + private fun mapReproductiveStatusId(status: String?): Int? { + return when (status?.trim()?.lowercase()) { + "eligible couple" -> 1 + "pregnant woman", "antenatal mother" -> 2 + "postnatal", + "post natal", + "postnatal mother-lactating mother", + "post natal mother-lactating mother", + "postnatal mother", + "post natal mother" -> 3 + "elderly" -> 4 + "adolescent", "teenager" -> 5 + "permanent sterilization", "permanently sterilised", "permanently sterilized" -> 6 + "not applicable" -> 7 + else -> null + } + } + private var abdmFacilityId: String = "" private var abdmFacilityName: String = "" @@ -97,6 +123,7 @@ class PatientRepo @Inject constructor( // Ensure master data exists before inserting patient ensureMasterDataExists(patient) patientDao.insertPatient(patient) + routePostNatalPatientIfNeeded(patient) } /** @@ -175,9 +202,16 @@ class PatientRepo @Inject constructor( suspend fun updateRecord(it: Patient) { withContext(Dispatchers.IO) { patientDao.updatePatient(it) + routePostNatalPatientIfNeeded(it) } } + private suspend fun routePostNatalPatientIfNeeded(patient: Patient) { + if (patient.statusOfWomanID != STATUS_POST_NATAL_MOTHER || patient.genderID != 2) return + maternalHealthRepo.retirePregnancyLifecycleForPostNatal(patient.patientID) + deliveryOutcomeRepo.ensureActiveDeliveryOutcomeForPnc(patient) + } + suspend fun updatePatientSyncing(patient: Patient) { patientDao.updatePatientSyncing(SyncState.SYNCING, patient.patientID) } @@ -213,6 +247,56 @@ class PatientRepo @Inject constructor( return patientDao.getPatientListFlowForLab() } + /** + * Get all infants (0–365 days inclusive). + * Matches DAO day-based range; WHO RMNCHA+ infant definition. + */ + fun getInfantList(): Flow> { + return patientDao.getAllInfantList() + } + + /** + * Get count of infants (0–365 days inclusive). + * Matches DAO day-based range; WHO RMNCHA+ infant definition. + */ + fun getInfantListCount(): Flow { + return patientDao.getInfantListCount() + } + + /** + * Get all children (365–3285 days inclusive, i.e. 1–9 years). + * Matches DAO day-based range; WHO RMNCHA+ child definition. + */ + fun getChildList(): Flow> { + return patientDao.getAllChildList() + } + + /** + * Get count of children (365–3285 days inclusive, i.e. 1–9 years). + * Matches DAO day-based range; WHO RMNCHA+ child definition. + */ + fun getChildListCount(): Flow { + return patientDao.getChildListCount() + } + + /** + * Get all adolescents (3650–6935 days inclusive, i.e. 10–19 years). + * Matches DAO day-based range; WHO RMNCHA+ adolescent definition. + */ + fun getAdolescentList(): Flow> { + return patientDao.getAllAdolescentList() + } + + /** + * Get count of adolescents (3650–6935 days inclusive, i.e. 10–19 years). + * Matches DAO day-based range; WHO RMNCHA+ adolescent definition. + */ + fun getAdolescentListCount(): Flow { + return patientDao.getAdolescentListCount() + } + + + // suspend fun updateFlagsByBenRegId(benFlow: BenFlow) { // val patient = patientDao.getPatientByBenRegId(benFlow.beneficiaryRegID!!) // if(patient != null && benFlow.nurseFlag!! >= patient.nurseFlag!! && benFlow.doctorFlag!! >= patient.doctorFlag!!){ @@ -246,6 +330,10 @@ class PatientRepo @Inject constructor( return patientDao.getPatientByBenRegId(beneficiaryRegID) } + suspend fun getPatientByAnyBeneficiaryId(id: Long): Patient? { + return patientDao.getPatientByAnyBeneficiaryId(id) + } + suspend fun registerNewPatient(patient : PatientDisplay, user: UserDomain?): NetworkResult { val p = patient.patient @@ -257,9 +345,21 @@ class PatientRepo @Inject constructor( } return networkResultInterceptor { - val patNet = PatientNetwork(patient, user) -// Timber.d("patient register is ", patNet.toString()) - val response = apiService.saveBenificiaryDetails(patNet) + val basePayload = PatientNetwork(patient, user) + val isUpdateRequest = p.beneficiaryID != null && p.beneficiaryRegID != null + val payload = if (isUpdateRequest) { + basePayload + } else { + basePayload.copy( + beneficiaryID = null, + beneficiaryRegID = null + ) + } + val response = if (isUpdateRequest) { + apiService.updateBenificiaryDetails(payload) + } else { + apiService.saveBenificiaryDetails(payload) + } // Check if response is successful if (!response.isSuccessful) { @@ -346,10 +446,25 @@ class PatientRepo @Inject constructor( suspend fun downloadAndSyncPatientRecords(): Boolean { val user = userRepo.getLoggedInUser() + val parsedVillageIds = convertStringToIntList(user?.assignVillageIds ?: "") + val effectiveVillageIds = if (parsedVillageIds.isNotEmpty()) { + parsedVillageIds + } else { + user?.masterVillageID?.let { listOf(it) } ?: emptyList() + } + val lastSyncDate = preferenceDao.getLastPatientSyncTime() +// if (effectiveVillageIds.isEmpty() || lastSyncDate.isBlank()) { +// Timber.w( +// "downloadAndSyncPatientRecords skipped: incomplete payload villageIDs=%s lastSyncDate=%s", +// effectiveVillageIds, +// lastSyncDate +// ) +// return false +// } val villageList = VillageIdList( - convertStringToIntList(user?.assignVillageIds ?: ""), - preferenceDao.getLastPatientSyncTime() + effectiveVillageIds, + lastSyncDate ) when(val response = getPatientsCountToDownload(villageList)){ @@ -385,8 +500,8 @@ class PatientRepo @Inject constructor( if(villageIds.trim().nullIfEmpty() == null){ return emptyList(); } - return villageIds.split(",").map { - it.trim().toInt() + return villageIds.split(",").mapNotNull { + it.trim().toIntOrNull() } } @@ -483,13 +598,17 @@ class PatientRepo @Inject constructor( var isSuccess = true var totalDownloaded = 0 + val patientsToInsert = mutableListOf() + var lastReportedProgress = -1 for(beneficiary in beneficiariesDTO){ totalDownloaded++ if(WorkerUtils.totalRecordsToDownload > 0 && totalDownloaded <= WorkerUtils.totalRecordsToDownload){ - withContext(Dispatchers.Main) { - WorkerUtils.totalPercentageCompleted.value = ((totalDownloaded.toDouble() / WorkerUtils.totalRecordsToDownload.toDouble())*100).toInt() + val progressPercent = ((totalDownloaded.toDouble() / WorkerUtils.totalRecordsToDownload.toDouble()) * 100).toInt() + if (progressPercent != lastReportedProgress) { + lastReportedProgress = progressPercent + WorkerUtils.totalPercentageCompleted.postValue(progressPercent) } } @@ -524,8 +643,11 @@ class PatientRepo @Inject constructor( syncState = SyncState.SYNCED, beneficiaryID = beneficiary.benId?.toLong(), beneficiaryRegID = beneficiary.benRegId?.toLong(), + statusOfWomanID = beneficiary.reproductiveStatusId + ?: mapReproductiveStatusId(beneficiary.reproductiveStatus), healthIdDetails = benHealthIdDetails , - faceEmbedding = beneficiary.faceEmbedding + faceEmbedding = beneficiary.faceEmbedding, + benImage = beneficiary.benImage ) setPatientAge(patient) @@ -544,6 +666,7 @@ class PatientRepo @Inject constructor( if (patientDao.getCountByBenId(beneficiary.benId!!.toLong()) > 0) { patientDao.updatePatient(patient) } else { + patientsToInsert.add(patient) patientDao.insertPatient(patient) } } @@ -552,6 +675,13 @@ class PatientRepo @Inject constructor( } } + // Batch insert all new patients in a single transaction + if (patientsToInsert.isNotEmpty()) { + withContext(Dispatchers.IO) { + patientDao.insertAllPatients(patientsToInsert) + } + } + NetworkResult.Success(DownsyncSuccess(isSuccess)) }, onTokenExpired = { @@ -636,6 +766,30 @@ class PatientRepo @Inject constructor( } + suspend fun processPatientById(patientID: String): Boolean { + return withContext(Dispatchers.IO) { + val patient = runCatching { patientDao.getPatientById(patientID) }.getOrNull() + ?: return@withContext false + val user = userRepo.getLoggedInUser() + updatePatientSyncing(patient.patient) + when (val response = registerNewPatient(patient, user)) { + is NetworkResult.Success -> { + val benificiarySaveResponse = response.data as BenificiarySaveResponse + updatePatientSyncSuccess(patient.patient, benificiarySaveResponse) + true + } + is NetworkResult.Error -> { + updatePatientSyncingFailed(patient.patient) + false + } + else -> { + updatePatientSyncingFailed(patient.patient) + false + } + } + } + } + suspend fun getWorkLocationMappedAbdmFacility(visitCode: Long? = 0L, benId: Long? = 0L, benRegId: Long? = 0L): NetworkResult { return networkResultInterceptor { @@ -785,6 +939,10 @@ class PatientRepo @Inject constructor( val procedures = procedureDao.getProceduresByPatientIdAndBenVisitNo(benVisitInfo.patient.patientID, benVisitInfo.benVisitNo!!) procedures?.forEach { procedure -> val compListDetails: MutableList = mutableListOf() + val procedureMaster = procedureMasterDao.getMasterProcedureById(procedure.procedureID) + val masterComponents = procedureMaster + ?.let { procedureMasterDao.getComponentDetails(it.id) } + .orEmpty() val procedureDTO = ProcedureDTO( benRegId = benVisitInfo.patient.beneficiaryRegID!!, procedureDesc = procedure.procedureDesc, @@ -796,33 +954,60 @@ class PatientRepo @Inject constructor( isMandatory = procedure.isMandatory ) - val components = procedureDao.getComponentDetails(procedure.id) - components?.forEach { componentDetails -> + val visitComponents = procedureDao.getComponentDetails(procedure.id).orEmpty() + val visitComponentByTestId = visitComponents.associateBy { it.testComponentID } + + // Render fields from component_details_master matched by procedure.procedure_id. + masterComponents.forEach { masterComponent -> + val visitComponent = visitComponentByTestId[masterComponent.testComponentID] + val componentId = visitComponent?.id ?: procedureDao.insert( + ComponentDetails( + testComponentID = masterComponent.testComponentID, + procedureID = procedure.id, + rangeNormalMin = masterComponent.rangeNormalMin, + rangeNormalMax = masterComponent.rangeNormalMax, + rangeMin = masterComponent.rangeMin, + rangeMax = masterComponent.rangeMax, + isDecimal = masterComponent.isDecimal, + inputType = masterComponent.inputType, + measurementUnit = masterComponent.measurementUnit, + testComponentName = masterComponent.testComponentName, + testComponentDesc = masterComponent.testComponentDesc, + testResultValue = null, + remarks = null + ) + ) val componentOptionDTOs: MutableList = mutableListOf() val componentDetailDTO = ComponentDetailDTO( - id = componentDetails.id, - range_normal_min = componentDetails.rangeNormalMin, - range_normal_max = componentDetails.rangeNormalMax, - range_min = componentDetails.rangeMin, - range_max = componentDetails.rangeMax, - isDecimal = componentDetails.isDecimal, - inputType = componentDetails.inputType, - testComponentID = componentDetails.testComponentID, - measurementUnit = componentDetails.measurementUnit, - testComponentName = componentDetails.testComponentName, - testComponentDesc = componentDetails.testComponentDesc, - testResultValue = componentDetails.testResultValue, - remarks = componentDetails.remarks, + id = componentId, + range_normal_min = masterComponent.rangeNormalMin, + range_normal_max = masterComponent.rangeNormalMax, + range_min = masterComponent.rangeMin, + range_max = masterComponent.rangeMax, + isDecimal = masterComponent.isDecimal, + inputType = masterComponent.inputType, + testComponentID = masterComponent.testComponentID, + measurementUnit = masterComponent.measurementUnit, + testComponentName = masterComponent.testComponentName, + testComponentDesc = masterComponent.testComponentDesc, + testResultValue = visitComponent?.testResultValue, + remarks = visitComponent?.remarks, compOpt = componentOptionDTOs ) - val componentOptions = procedureDao.getComponentOptions(componentDetails.id) + val componentOptions = procedureDao.getComponentOptions(componentId) componentOptions?.forEach { option -> val componentOptionDTO = ComponentOptionDTO( name = option.name ) componentOptionDTOs += componentOptionDTO } + + if (componentOptionDTOs.isEmpty() && (componentDetailDTO.inputType == "RadioButton" || componentDetailDTO.inputType == "DropDown")) { + procedureMasterDao.getComponentOptions(masterComponent.id)?.forEach { opt -> + componentOptionDTOs += ComponentOptionDTO(name = opt.name) + } + } componentDetailDTO.compOpt = componentOptionDTOs compListDetails += componentDetailDTO } @@ -855,9 +1040,10 @@ class PatientRepo @Inject constructor( return withContext(Dispatchers.IO) { val componentDetailsId = componentDetails.id + val savedOptions = procedureDao.getComponentOptions(componentDetailsId).orEmpty() val updatedId = procedureDao.insert(componentDetails) - procedureDao.getComponentOptions(componentDetailsId)?.forEach { componentOption -> + savedOptions.forEach { componentOption -> componentOption.componentDetailsId = updatedId procedureDao.insert(componentOption) } @@ -946,18 +1132,19 @@ class PatientRepo @Inject constructor( prescriptionDao.updatePrescribedDrugsBatch(updatedBatch) } - - updatedPrescribedDrugsBatches?.forEach { prescribedDrugsBatch -> - val prescriptionBatchDTO = PrescriptionBatchDTO( - expiresIn = prescribedDrugsBatch.expiresIn, - batchNo = prescribedDrugsBatch.batchNo, - expiryDate = prescribedDrugsBatch.expiryDate, - itemStockEntryID = prescribedDrugsBatch.itemStockEntryID, - qty = prescribedDrugsBatch.qty, + // Use live stock from batchDao so Total Available Quantity reflects actual inventory + batches.filter { batch -> + DateTimeUtil.calculateExpiryInDays(batch.expiryDate) > 0 && batch.quantityInHand > 0 + }.forEach { batch -> + batchList += PrescriptionBatchDTO( + expiresIn = DateTimeUtil.calculateExpiryInDays(batch.expiryDate), + batchNo = batch.batchNo, + expiryDate = DateTimeUtil.convertDateFormat(batch.expiryDate), + itemStockEntryID = batch.stockEntityId.toInt(), + qty = batch.quantityInHand, isSelected = false, dispenseQuantity = 0 ) - batchList += prescriptionBatchDTO } prescriptionItemDTO.batchList = batchList prescriptionItemList += prescriptionItemDTO @@ -979,4 +1166,10 @@ class PatientRepo @Inject constructor( prescriptionDao.getPrescription(patientID, benVisitNo, prescriptionID) } } -} \ No newline at end of file + + suspend fun getLatestPrescription(patientID: String, benVisitNo: Int): Prescription? { + return withContext(Dispatchers.IO) { + prescriptionDao.getLatestPrescriptionByPatientIdAndBenVisitNo(patientID, benVisitNo) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/PatientVisitInfoSyncRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/PatientVisitInfoSyncRepo.kt index 849cc947e..8144145eb 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/PatientVisitInfoSyncRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/PatientVisitInfoSyncRepo.kt @@ -39,6 +39,14 @@ class PatientVisitInfoSyncRepo @Inject constructor( patientVisitInfoSyncDao.updatePharmacistFlag(patientID, benVisitNo) } + suspend fun markPharmacistDispensedLocally(patientID: String, benVisitNo: Int) { + patientVisitInfoSyncDao.markPharmacistDispensedLocally(patientID, benVisitNo) + } + + suspend fun updatePharmacistFlagToPending(patientID: String, benVisitNo: Int) { + patientVisitInfoSyncDao.updatePharmacistFlagToPending(patientID, benVisitNo) + } + suspend fun updateOnlyDoctorDataSubmitted(nurseFlag : Int, doctorFlag : Int, labtechFlag : Int, patientID: String, benVisitNo: Int){ patientVisitInfoSyncDao.updateOnlyDoctorDataSubmitted(nurseFlag, doctorFlag, labtechFlag, patientID, benVisitNo) } @@ -155,4 +163,4 @@ class PatientVisitInfoSyncRepo @Inject constructor( suspend fun updatePatientReferData(referDate: String?, referTo: String?, referralReason: String?, patientID: String, benVisitNo: Int) { patientVisitInfoSyncDao.updatePatientReferData(referDate, referTo, referralReason, patientID, benVisitNo); } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/PncRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/PncRepo.kt index b27787b11..6387aa3d6 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/PncRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/PncRepo.kt @@ -1,19 +1,24 @@ package org.piramalswasthya.cho.repositories import android.util.Log -import androidx.lifecycle.LiveData import com.google.gson.Gson import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao import org.piramalswasthya.cho.database.room.dao.PatientDao import org.piramalswasthya.cho.database.room.dao.PncDao -import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +//import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao import org.piramalswasthya.cho.model.PNCNetwork import org.piramalswasthya.cho.model.PNCVisitCache +import org.piramalswasthya.cho.model.PatientWithDeliveryOutcomeAndPncCache import org.piramalswasthya.cho.network.AmritApiService +import org.piramalswasthya.cho.network.VillageIdList +import org.piramalswasthya.cho.network.getLongFromDate import timber.log.Timber import java.io.IOException import java.net.SocketTimeoutException @@ -39,7 +44,17 @@ class PncRepo @Inject constructor( } suspend fun getLastVisitNumber(patientID: String): Int? { - return pncDao.getLastVisitNumber(patientID) + return withContext(Dispatchers.IO) { + pncDao.getAllPNCsByPatId(patientID) + .maxByOrNull { it.pncPeriod } + ?.pncPeriod + } + } + + suspend fun savePnc(pncCache: PNCVisitCache): Long { + return withContext(Dispatchers.IO) { + pncDao.insert(pncCache) + } } suspend fun persistPncRecord(pncCache: PNCVisitCache) { @@ -53,6 +68,21 @@ class PncRepo @Inject constructor( return pncDao.getAllPNCsByPatId(patientID) } + /** + * Get all patients who are eligible for PNC (have delivered and within 42 days or not completed all visits) + * Uses a single optimized query instead of N+1 pattern + */ + fun getAllPNCMothers(): Flow> { + return pncDao.getAllPNCMothersWithData() + } + + /** + * Get count of PNC mothers + */ + fun getPNCMothersCount(): Flow { + return pncDao.getPNCMothersCount() + } + suspend fun processPncVisits(): Boolean { return withContext(Dispatchers.IO) { @@ -143,6 +173,142 @@ class PncRepo @Inject constructor( return false } } + + suspend fun pullPncVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + try { + val villageList = VillageIdList( + RepositorySyncUtils.parseVillageIds(user.assignVillageIds ?: ""), + preferenceDao.getLastPatientSyncTime() + ) + + val response = amritApiService.getAllPncVisits(villageList) + if (response.code() != 200) { + Timber.w("Bad response from server for PNC getAll: $response") + return@withContext false + } + + val responseString = response.body()?.string() + if (responseString.isNullOrBlank()) { + Timber.w("Empty response body for PNC getAll") + return@withContext false + } + + val jsonObj = JSONObject(responseString) + val responseStatusCode = jsonObj.optInt("statusCode", 200) + val errorMessage = jsonObj.optString("errorMessage") + + when (responseStatusCode) { + 200 -> { + val dataArray = RepositorySyncUtils.extractDataArray(jsonObj) + + val gson = Gson() + var savedCount = 0 + for (i in 0 until dataArray.length()) { + val networkModel = gson.fromJson( + dataArray.getJSONObject(i).toString(), + PNCNetwork::class.java + ) + + if (networkModel.benId == 0L) { + Timber.w("Skipping PNC getAll item with invalid benId at index=$i") + continue + } + + val patient = patientDao.getPatientByAnyBeneficiaryId(networkModel.benId) + if (patient == null) { + Timber.w("No local patient found for PNC benId=${networkModel.benId}, skipping") + continue + } + + if (networkModel.pncPeriod <= 0) { + Timber.w("Skipping PNC getAll item with invalid pncPeriod at index=$i") + continue + } + + val incoming = networkModel.toCacheModel(patient.patientID) + val existing = pncDao.getSavedRecord(patient.patientID, networkModel.pncPeriod) + val merged = if (existing != null) { + incoming.copy( + id = existing.id, + createdDate = if (existing.createdDate > 0L) existing.createdDate else incoming.createdDate, + createdBy = if (existing.createdBy.isNotBlank()) existing.createdBy else incoming.createdBy + ) + } else incoming + + pncDao.insert(merged) + savedCount++ + } + + Timber.d("PNC getAll downsync completed, saved=$savedCount received=${dataArray.length()}") + return@withContext true + } + + 5000 -> { + Timber.d("No PNC records found on server") + return@withContext true + } + + 5002 -> { + if (userRepo.refreshTokenTmc(user.userName, user.password)) { + throw SocketTimeoutException() + } + } + + else -> { + Timber.w("PNC getAll failed: $errorMessage") + throw IOException("PNC getAll failed with statusCode=$responseStatusCode") + } + } + return@withContext false + } catch (e: SocketTimeoutException) { + Timber.d("Caught timeout for PNC getAll $e; retrying") + return@withContext pullPncVisitsFromServer() + } catch (e: JSONException) { + Timber.d("Caught JSON exception for PNC getAll $e") + return@withContext false + } + } + } + + private fun PNCNetwork.toCacheModel(patientID: String): PNCVisitCache { + return PNCVisitCache( + id = id, + patientID = patientID, + pncPeriod = pncPeriod, + isActive = isActive, + pncDate = getLongFromDate(pncDate), + ifaTabsGiven = ifaTabsGiven, + calciumSupplementation = calciumSupplementation, + anyContraceptionMethod = anyContraceptionMethod, + contraceptionMethod = contraceptionMethod, + sterilisationDate = sterilisationDate?.let { getLongFromDate(it) }, + otherPpcMethod = otherPpcMethod, + anyDangerSign = anyDangerSign, + motherDangerSign = motherDangerSign, + otherDangerSign = otherDangerSign, + maternalSymptoms = maternalSymptoms, + otherMaternalSymptoms = otherMaternalSymptoms, + pallor = pallor, + vaginalBleeding = vaginalBleeding, + referralFacility = referralFacility, + motherDeath = motherDeath, + deathDate = deathDate?.let { getLongFromDate(it) }, + causeOfDeath = causeOfDeath, + otherDeathCause = otherDeathCause, + placeOfDeath = placeOfDeath, + otherPlaceOfDeath = otherPlaceOfDeath, + remarks = remarks, + processed = "P", + createdBy = createdBy, + createdDate = getLongFromDate(createdDate), + updatedBy = updatedBy, + updatedDate = getLongFromDate(updatedDate), + syncState = SyncState.SYNCED + ) + } // // // //PULL @@ -241,4 +407,4 @@ class PncRepo @Inject constructor( // } // } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/ProcedureRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/ProcedureRepo.kt index f0e6c9c52..676be9ab5 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/ProcedureRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/ProcedureRepo.kt @@ -1,9 +1,13 @@ package org.piramalswasthya.cho.repositories +import androidx.room.withTransaction import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject +import org.piramalswasthya.cho.database.room.InAppDb +import org.piramalswasthya.cho.database.room.LabProcedureMasterSeed +import org.piramalswasthya.cho.database.room.dao.HistoryDao import org.piramalswasthya.cho.database.room.dao.ProcedureDao import org.piramalswasthya.cho.database.room.dao.ProcedureMasterDao import org.piramalswasthya.cho.model.ComponentDetails @@ -13,22 +17,31 @@ import org.piramalswasthya.cho.model.ComponentOptionsMaster import org.piramalswasthya.cho.model.MasterLabProceduresRequestModel import org.piramalswasthya.cho.model.PatientDisplayWithVisitInfo import org.piramalswasthya.cho.model.Procedure +import org.piramalswasthya.cho.model.ProcedureDataDownsync import org.piramalswasthya.cho.model.ProcedureDataWithComponent import org.piramalswasthya.cho.model.ProcedureMaster +import org.piramalswasthya.cho.model.ComponentDataDownsync +import org.piramalswasthya.cho.model.ProcedureFieldApiItem import org.piramalswasthya.cho.model.ProcedureMasterDTO import org.piramalswasthya.cho.network.AmritApiService import org.piramalswasthya.cho.network.NetworkResponse import org.piramalswasthya.cho.network.NetworkResult import org.piramalswasthya.cho.network.networkResultInterceptor import org.piramalswasthya.cho.network.refreshTokenInterceptor +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt import javax.inject.Inject class ProcedureRepo @Inject constructor( private val procedureDao: ProcedureDao, private val procedureMasterDao: ProcedureMasterDao, + private val historyDao: HistoryDao, private val apiService: AmritApiService, - private val userRepo: UserRepo + private val userRepo: UserRepo, + private val database: InAppDb ) { suspend fun getProceduresWithComponent( patientID: String, @@ -39,6 +52,42 @@ class ProcedureRepo @Inject constructor( } } + suspend fun syncLabResultsToDownsyncTable(patientID: String, benVisitNo: Int) { + withContext(Dispatchers.IO) { + val procedures = procedureDao.getProceduresByPatientIdAndBenVisitNo(patientID, benVisitNo) ?: return@withContext + val createdDate = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + database.withTransaction { + procedureDao.deleteProcedureDownsyncByPatientIdAndVisitNo(patientID, benVisitNo) + procedures.forEach { procedure -> + val downsync = ProcedureDataDownsync( + id = 0, + prescriptionID = procedure.prescriptionID.toInt(), + procedureID = procedure.procedureID.toInt(), + createdDate = createdDate, + procedureName = procedure.procedureName, + patientID = patientID, + benVisitNo = benVisitNo + ) + val downsyncId = procedureDao.insert(downsync) + val components = procedureDao.getComponentDetails(procedure.id) ?: emptyList() + components.forEach { component -> + procedureDao.insert( + ComponentDataDownsync( + id = 0, + procedureDataID = downsyncId, + testResultValue = component.testResultValue, + testResultUnit = component.measurementUnit, + testComponentID = component.testComponentID.toInt(), + componentName = component.testComponentName, + remarks = component.remarks + ) + ) + } + } + } + } + } + suspend fun pullLabProcedureMasterData(): Boolean { return try { getProcedureMasterData(); @@ -48,13 +97,134 @@ class ProcedureRepo @Inject constructor( } } + suspend fun ensureLabProcedureMasterSeed() { + withContext(Dispatchers.IO) { + val labProcedures = historyDao.getProceduresMap() + .filter { it.procedureType.equals("Laboratory", ignoreCase = true) } + .sortedBy { it.procedureID } + val fieldRows = fetchProcedureFieldsData() ?: return@withContext + if (fieldRows.isEmpty()) return@withContext + val fieldsByProcedureId = fieldRows.groupBy { it.procedureID } + val labProceduresById = labProcedures.associateBy { it.procedureID.toLong() } + val allProcedureIds = (labProceduresById.keys + fieldsByProcedureId.keys).sorted() + + for (procIdLong in allProcedureIds) { + val proc = labProceduresById[procIdLong] + val fallbackField = fieldsByProcedureId[procIdLong]?.firstOrNull() + var procedureMaster = procedureMasterDao.getMasterProcedureById(procIdLong) + if (procedureMaster != null && procedureMaster.id != procIdLong) { + // Legacy rows may have auto-generated id (1,2,3...). Replace with stable API id. + procedureMasterDao.deleteMasterProcedureByRowId(procedureMaster.id) + procedureMaster = null + } + val procedureRowId = if (procedureMaster == null) { + procedureMasterDao.insert( + ProcedureMaster( + id = procIdLong, + procedureID = procIdLong, + procedureDesc = proc?.procedureDesc + ?: fallbackField?.testComponentDesc + ?: "Laboratory Procedure $procIdLong", + procedureType = proc?.procedureType ?: "Laboratory", + prescriptionID = LabProcedureMasterSeed.PRESCRIPTION_ID, + procedureName = proc?.procedureName + ?: fallbackField?.testComponentName + ?: "Laboratory Procedure $procIdLong", + isMandatory = false + ) + ) + } else { + procedureMaster.id + } + + val fieldsForProc = fieldsByProcedureId[procIdLong].orEmpty() + val existingComponents = procedureMasterDao.getComponentDetails(procedureRowId) + + for (field in fieldsForProc) { + val testComponentId = field.id + val existingComp = existingComponents.find { it.testComponentID == testComponentId } + val componentDetailsId = if (existingComp == null) { + procedureMasterDao.insert( + componentDetailsMasterFromField(field, procedureRowId) + ) + } else { + existingComp.id + } + + val optionNames = if (field.inputType.equals("RadioButton", ignoreCase = true)) { + LabProcedureMasterSeed.radioButtonDefaultOptions + } else { + emptyList() + } + val existingOptions = + procedureMasterDao.getComponentOptions(componentDetailsId)?.mapNotNull { it.name } + .orEmpty() + for (optName in optionNames) { + if (optName !in existingOptions) { + procedureMasterDao.insert( + ComponentOptionsMaster(componentDetailsId = componentDetailsId, name = optName) + ) + } + } + } + } + } + } + + private fun componentDetailsMasterFromField( + field: ProcedureFieldApiItem, + procedureMasterRowId: Long + ): ComponentDetailsMaster { + val nMin = field.rangeMin?.roundToInt() + val nMax = field.rangeMax?.roundToInt() + return ComponentDetailsMaster( + testComponentID = field.id, + procedureID = procedureMasterRowId, + rangeNormalMin = nMin, + rangeNormalMax = nMax, + rangeMin = null, + rangeMax = null, + isDecimal = field.inputType.equals("TextBox", ignoreCase = true), + inputType = field.inputType, + measurementUnit = field.measurementNit, + testComponentName = field.testComponentName, + testComponentDesc = field.testComponentDesc + ) + } + + suspend fun fetchProcedureFieldsData(attempt: Int = 0): List? { + if (attempt > 2) return null + return try { + val response = apiService.getProcedureFields() + val responseBody = response.body()?.string() ?: return null + val json = JSONObject(responseBody) + when (json.optInt("statusCode", -1)) { + 200 -> { + val dataArray = json.getJSONArray("data") + Gson().fromJson( + dataArray.toString(), + Array::class.java + ).toList() + } + 5002 -> { + val user = userRepo.getLoggedInUser() ?: return null + userRepo.refreshTokenTmc(user.userName, user.password) + fetchProcedureFieldsData(attempt + 1) + } + else -> null + } + } catch (_: Exception) { + null + } + } + private suspend fun getProcedureMasterData(): NetworkResult { return networkResultInterceptor { - val procedureMasterDataRequest = MasterLabProceduresRequestModel( - providerServiceMapID = userRepo.getLoggedInUser()?.serviceMapId + val procedureMasterDataRequest = MasterLabProceduresRequestModel( + providerServiceMapID = userRepo.getLoggedInUser()?.serviceMapId - ) + ) val response = apiService.getMasterLabProceduresDate(procedureMasterDataRequest) val responseBody = response.body()?.string() @@ -69,6 +239,7 @@ class ProcedureRepo @Inject constructor( procedureDTO.forEach { dto -> val procedure = ProcedureMaster( + id = dto.procedureID, procedureID = dto.procedureID, procedureDesc = dto.procedureDesc, procedureType = dto.procedureType, @@ -76,11 +247,11 @@ class ProcedureRepo @Inject constructor( prescriptionID = dto.prescriptionID, isMandatory = dto.isMandatory ) - val procedureId = procedureMasterDao.insert(procedure) + procedureMasterDao.insert(procedure) dto.compListDetails.forEach { componentDetailDTO -> val componentDetails = ComponentDetailsMaster( testComponentID = componentDetailDTO.testComponentID, - procedureID = procedureId, + procedureID = dto.procedureID, rangeNormalMin = componentDetailDTO.range_normal_min, rangeNormalMax = componentDetailDTO.range_normal_max, rangeMax = componentDetailDTO.range_max, @@ -116,21 +287,38 @@ class ProcedureRepo @Inject constructor( } suspend fun addProcedure(procedureID: Long, benVisitInfo: PatientDisplayWithVisitInfo) { - var procedureMaster = procedureMasterDao.getMasterProcedureById(procedureID) - procedureMaster?.let { - val procedure = Procedure( - patientID = benVisitInfo.patient.patientID, - benVisitNo = benVisitInfo.benVisitNo!!, - procedureID = procedureMaster.procedureID, - procedureDesc = procedureMaster.procedureDesc, - procedureType = procedureMaster.procedureType, - procedureName = procedureMaster.procedureName, - prescriptionID = procedureMaster.prescriptionID, - isMandatory = procedureMaster.isMandatory - ) - val procedureId = procedureDao.insert(procedure) - val componentDetailsMasterList = procedureMasterDao.getComponentDetails(procedureId) - componentDetailsMasterList.forEach { componentDetailsMaster -> + addProcedureFromMaster(procedureID, benVisitInfo.patient.patientID, benVisitInfo.benVisitNo!!) + } + + suspend fun copyProceduresFromMasterForVisit(patientID: String, benVisitNo: Int, newTestIds: String?) { + if (newTestIds.isNullOrBlank()) return + withContext(Dispatchers.IO) { + ensureLabProcedureMasterSeed() + val existingProcedures = procedureDao.getProceduresByPatientIdAndBenVisitNo(patientID, benVisitNo) + val existingProcedureIds = existingProcedures?.map { it.procedureID }?.toSet() ?: emptySet() + newTestIds.split(",").mapNotNull { it.trim().toLongOrNull() }.forEach { procedureID -> + if (procedureID !in existingProcedureIds) { + addProcedureFromMaster(procedureID, patientID, benVisitNo) + } + } + } + } + + private suspend fun addProcedureFromMaster(procedureID: Long, patientID: String, benVisitNo: Int) { + val procedureMaster = procedureMasterDao.getMasterProcedureById(procedureID) ?: return + val procedure = Procedure( + patientID = patientID, + benVisitNo = benVisitNo, + procedureID = procedureMaster.procedureID, + procedureDesc = procedureMaster.procedureDesc, + procedureType = procedureMaster.procedureType, + procedureName = procedureMaster.procedureName, + prescriptionID = procedureMaster.prescriptionID, + isMandatory = procedureMaster.isMandatory + ) + val procedureId = procedureDao.insert(procedure) + val componentDetailsMasterList = procedureMasterDao.getComponentDetails(procedureMaster.id) + componentDetailsMasterList.forEach { componentDetailsMaster -> val component = ComponentDetails( procedureID = procedureId, testComponentID = componentDetailsMaster.testComponentID, @@ -146,18 +334,16 @@ class ProcedureRepo @Inject constructor( testResultValue = null, remarks = null ) - val componentId = procedureDao.insert(component) - val componentOptionsMaster = procedureMasterDao.getComponentOptions(componentId) - componentOptionsMaster?.forEach { componentOptionsMaster -> - val componentOption = ComponentOption( - componentDetailsId = componentOptionsMaster.componentDetailsId, - name = componentOptionsMaster.name - ) - procedureDao.insert(componentOption) - } + val componentId = procedureDao.insert(component) + val componentOptionsMaster = procedureMasterDao.getComponentOptions(componentDetailsMaster.id) + componentOptionsMaster?.forEach { option -> + val componentOption = ComponentOption( + componentDetailsId = componentId, + name = option.name + ) + procedureDao.insert(componentOption) } } - } } \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/PsychosocialCaregiverSupportRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/PsychosocialCaregiverSupportRepo.kt new file mode 100644 index 000000000..63c7af5e2 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/PsychosocialCaregiverSupportRepo.kt @@ -0,0 +1,123 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.PsychosocialCaregiverSupportDao +import org.piramalswasthya.cho.model.PsychosocialCaregiverSupport +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.PsychosocialCaregiverSupportNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import timber.log.Timber +import org.json.JSONArray + +class PsychosocialCaregiverSupportRepo @Inject constructor( + private val psychosocialCaregiverSupportDao: PsychosocialCaregiverSupportDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun saveAssessment(assessment: PsychosocialCaregiverSupport) { + if (assessment.assessmentId == 0L) { + assessment.benVisitNo?.let { visitNo -> + cphcDetailsRepo.clearAssessmentsForVisit(assessment.patientId, visitNo) + } + psychosocialCaregiverSupportDao.insert(assessment) + } else { + psychosocialCaregiverSupportDao.update(assessment) + } + } + + suspend fun getAssessmentByPatientId( + patientID: String + ): PsychosocialCaregiverSupport? { + return psychosocialCaregiverSupportDao + .getAssessmentByPatientId(patientID) + } + + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): PsychosocialCaregiverSupport? { + return psychosocialCaregiverSupportDao + .getAssessmentByPatientIdAndVisitNo(patientID, benVisitNo) + } + suspend fun processPsychosocialCaregiverVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = psychosocialCaregiverSupportDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientId) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postPsychosocialCaregiverForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + psychosocialCaregiverSupportDao.update(assessment) + }, + logLabel = "Psychosocial Caregiver Support Assessment records" + ) + } + } + + suspend fun pullPsychosocialCaregiverSupportVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + if (user == null) { + Timber.w("No user logged in. Skipping pull for Psychosocial Caregiver Support records") + return@withContext false + } + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getPsychosocialCaregiverVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson( + jsonObj.toString(), + PsychosocialCaregiverSupportNetwork::class.java + ) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getAssessmentByPatientIdAndVisitNo( + patientId, + networkObj.benVisitNo ?: 0 + ) != null + }, + insertNew = { patientId, networkObj -> + saveAssessment( + networkObj.toCacheModel(patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Psychosocial Caregiver Support records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/RegistrarMasterDataRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/RegistrarMasterDataRepo.kt index 0437710f8..52637bf35 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/RegistrarMasterDataRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/RegistrarMasterDataRepo.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import org.piramalswasthya.cho.database.converters.MasterDataListConverter import org.piramalswasthya.cho.database.room.dao.RegistrarMasterDataDao -import org.piramalswasthya.cho.moddel.OccupationMaster +import org.piramalswasthya.cho.model.OccupationMaster import org.piramalswasthya.cho.model.AgeUnit import org.piramalswasthya.cho.model.CommunityMaster import org.piramalswasthya.cho.model.GenderMaster @@ -27,6 +27,54 @@ class RegistrarMasterDataRepo @Inject constructor( private val apiService: AmritApiService ) { + /** + * Downloads all registrar master data in a single HTTP request and saves + * every type to the local database using batch inserts. + * Replaces the 13 individual save*ToCache() calls that each made their own + * network request to the same endpoint. + */ + suspend fun saveAllMasterDataToCache() { + val data = registrarMasterService() ?: return + withContext(Dispatchers.IO) { + registrarMasterDataDao.insertAllGenders( + MasterDataListConverter.toGenderList(data.optJSONArray("genderMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllAgeUnits( + MasterDataListConverter.toAgeUnitList(data.optJSONArray("ageUnit")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllMaritalStatuses( + MasterDataListConverter.toMaritalStatusList(data.optJSONArray("maritalStatusMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllCommunities( + MasterDataListConverter.toCommunityMasterList(data.optJSONArray("communityMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllReligionMasters( + MasterDataListConverter.toReligionMasterList(data.optJSONArray("religionMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllIncomeStatuses( + MasterDataListConverter.toIncomeStatusList(data.optJSONArray("incomeMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllLiteracyStatuses( + MasterDataListConverter.toLiteracyStatusList(data.optJSONArray("literacyStatus")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllGovIdMasters( + MasterDataListConverter.toGovIdEntityList(data.optJSONArray("govIdEntityMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllOtherGovIdEntityMasters( + MasterDataListConverter.toOtherGovIdEntityList(data.optJSONArray("otherGovIdEntityMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllOccupationMasters( + MasterDataListConverter.toOccupationMasterList(data.optJSONArray("occupationMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllQualificationMasters( + MasterDataListConverter.toQualificationMasterList(data.optJSONArray("qualificationMaster")?.toString() ?: "[]") + ) + registrarMasterDataDao.insertAllRelationshipMasters( + MasterDataListConverter.toRelationshipMasterList(data.optJSONArray("relationshipMaster")?.toString() ?: "[]") + ) + } + } + private suspend fun registrarMasterService(): JSONObject? { val response = apiService.getRegistrarMasterData(TmcLocationDetailsRequest(1,1)) val statusCode = response.code() diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/RepositorySyncUtils.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/RepositorySyncUtils.kt new file mode 100644 index 000000000..8d94f55a0 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/RepositorySyncUtils.kt @@ -0,0 +1,20 @@ +package org.piramalswasthya.cho.repositories + +import org.json.JSONArray +import org.json.JSONObject + +object RepositorySyncUtils { + fun parseVillageIds(villageIds: String): List { + if (villageIds.trim().isEmpty()) return emptyList() + return villageIds.split(",") + .mapNotNull { it.trim().toIntOrNull() } + } + + fun extractDataArray(root: JSONObject): JSONArray { + return when (val dataNode = root.opt("data")) { + is JSONArray -> dataNode + is JSONObject -> dataNode.optJSONArray("data") + else -> null + } ?: JSONArray() + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/ThroatDiagnosisRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/ThroatDiagnosisRepo.kt new file mode 100644 index 000000000..0543b9dca --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/ThroatDiagnosisRepo.kt @@ -0,0 +1,113 @@ +package org.piramalswasthya.cho.repositories + +import org.piramalswasthya.cho.database.room.dao.ThroatDiagnosisAssessmentDao +import org.piramalswasthya.cho.model.ThroatDiagnosisAssessment +import javax.inject.Inject +import org.piramalswasthya.cho.database.room.SyncState +import org.piramalswasthya.cho.database.room.dao.PatientDao +import org.piramalswasthya.cho.model.ThroatDiagnosisNetwork +import org.piramalswasthya.cho.model.toCacheModel +import org.piramalswasthya.cho.model.toNetworkModel +import org.piramalswasthya.cho.network.AmritApiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.google.gson.Gson +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import timber.log.Timber +import org.json.JSONArray + +class ThroatDiagnosisRepo @Inject constructor( + private val throatDiagnosisAssessmentDao: ThroatDiagnosisAssessmentDao, + private val patientDao: PatientDao, + private val amritApiService: AmritApiService, + private val userRepo: UserRepo, + private val prefDao: PreferenceDao, + private val cphcDetailsRepo: CphcDetailsRepository, +) { + suspend fun saveAssessment(assessment: ThroatDiagnosisAssessment) { + if (assessment.assessmentId == 0L) { + assessment.benVisitNo?.let { visitNo -> + cphcDetailsRepo.clearAssessmentsForVisit(assessment.patientId, visitNo) + } + throatDiagnosisAssessmentDao.insert(assessment) + } else { + throatDiagnosisAssessmentDao.update(assessment) + } + } + + suspend fun getAssessmentByPatientId(patientID: String): ThroatDiagnosisAssessment? { + return throatDiagnosisAssessmentDao.getAssessmentByPatientId(patientID) + } + + suspend fun getAssessmentByPatientIdAndVisitNo( + patientID: String, + benVisitNo: Int + ): ThroatDiagnosisAssessment? { + return throatDiagnosisAssessmentDao.getAssessmentByPatientIdAndVisitNo(patientID, benVisitNo) + } + suspend fun processThroatVisits(): Boolean { + return withContext(Dispatchers.IO) { + val unsyncedList = throatDiagnosisAssessmentDao.getUnsyncedAssessments() + AmritSyncRepositoryHelper.pushUnsynced( + unsyncedList = unsyncedList, + mapToPayload = { assessment -> + val patient = patientDao.getPatient(assessment.patientId) + val benId = patient?.beneficiaryID + val benRegId = patient?.beneficiaryRegID + if (benId == null || benRegId == null) null else { + assessment.toNetworkModel( + beneficiaryID = benId.toString(), + beneficiaryRegID = benRegId.toString() + ) + } + }, + post = { payload -> amritApiService.postThroatForm(payload) }, + markSynced = { assessment -> + assessment.syncState = SyncState.SYNCED.ordinal + throatDiagnosisAssessmentDao.update(assessment) + }, + logLabel = "Throat Diagnosis records" + ) + } + } + + suspend fun pullThroatVisitsFromServer(): Boolean { + return withContext(Dispatchers.IO) { + val user = userRepo.getLoggedInUser() + ?: throw IllegalStateException("No user logged in!!") + val gson = Gson() + AmritSyncRepositoryHelper.pullWithRetry( + villageIds = user.assignVillageIds ?: "", + lastSyncDate = prefDao.getLastPatientSyncTime(), + fetch = { villageList -> amritApiService.getThroatVisits(villageList) }, + refreshToken = { userRepo.refreshTokenTmc(user.userName, user.password) }, + onDataArray = { dataArray -> + AmritSyncRepositoryHelper.upsertByBeneficiaryRegId( + dataArray = dataArray, + parseNetwork = { jsonObj -> + gson.fromJson(jsonObj.toString(), ThroatDiagnosisNetwork::class.java) + }, + beneficiaryRegId = { it.beneficiaryRegID.toLongOrNull() }, + resolvePatientId = { benRegId -> + patientDao.getPatientByBenRegId(benRegId)?.patientID + }, + isExisting = { patientId, networkObj -> + getAssessmentByPatientIdAndVisitNo( + patientId, + networkObj.benVisitNo ?: 0 + ) != null + }, + insertNew = { patientId, networkObj -> + saveAssessment( + networkObj.toCacheModel(patientId).copy( + syncState = SyncState.SYNCED.ordinal + ) + ) + } + ) + }, + logLabel = "Throat records" + ) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/UserRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/UserRepo.kt index aec82a02e..c08617b42 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/UserRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/UserRepo.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import org.piramalswasthya.cho.crypt.CryptoUtil import org.piramalswasthya.cho.database.room.dao.BlockMasterDao @@ -71,7 +73,7 @@ class UserRepo @Inject constructor( userDao.getLoggedInUser()?.asDomainModel() } } - fun getLoggedInUserAsFlow(): Flow { + fun getLoggedInUserAsFlow(): Flow { return userDao.getLoggedInUserAsFlow().map { it?.asDomainModel()?.userId } } suspend fun isUserLoggedIn(): Int { @@ -80,19 +82,19 @@ class UserRepo @Inject constructor( } } - suspend fun setOutreachProgram(loginType: String?, - selectedOption: String?, - loginTimeStamp: String?, - logoutTimeStamp: String?, - lat: Double?, - long: Double?, - logoutType: String?, - userImage: String?, - isOutOfReach:Boolean? - ) { - var user = userDao.getLoggedInUser() - var userName = user?.userName - var userId = user?.userId + suspend fun setOutreachProgram(loginType: String?, + selectedOption: String?, + loginTimeStamp: String?, + logoutTimeStamp: String?, + lat: Double?, + long: Double?, + logoutType: String?, + userImage: String?, + isOutOfReach:Boolean? + ) { + var user = userDao.getLoggedInUser() + var userName = user?.userName + var userId = user?.userId val selectedOutreachProgram = SelectedOutreachProgram( userId = userId, userName = userName, @@ -220,19 +222,22 @@ class UserRepo @Inject constructor( for (i in 0 until vanSpDetailsArray.length()) { val vanSp = vanSpDetailsArray.getJSONObject(i) - val vanId = vanSp.getInt("vanID") - user?.vanId = vanId - val servicePointId = vanSp.getInt("servicePointID") - user?.servicePointId = servicePointId - val servicePointName = vanSp.getString("servicePointName") - user?.servicePointName = servicePointName + val serviceProviderMapId = vanSp.getInt("providerServiceMapID") + user?.serviceMapId = serviceProviderMapId +// val vanId = vanSp.getInt("vanID") +// user?.vanId = vanId +// val servicePointId = vanSp.getInt("servicePointID") +// user?.servicePointId = servicePointId +// val servicePointName = vanSp.getString("servicePointName") +// user?.servicePointName = servicePointName if (!vanSp.has("facilityID")) { Toast.makeText(context, "Facility ID not found", Toast.LENGTH_LONG).show() delay(3000) + continue } val facilityId = vanSp.getInt("facilityID") user?.facilityID = facilityId - user?.parkingPlaceId = vanSp.getInt("parkingPlaceID") +// user?.parkingPlaceId = vanSp.getInt("parkingPlaceID") } true @@ -282,7 +287,7 @@ class UserRepo @Inject constructor( } } - } + } private suspend fun getTokenTmc(userName: String, password: String, context: Context) { @@ -317,7 +322,21 @@ class UserRepo @Inject constructor( val privilegesArray = data.getJSONArray("previlegeObj") val privilegesObject = privilegesArray.getJSONObject(0) val rolesObjectArray = privilegesObject.getJSONArray("roles") - preferenceDao.setWorkingLocationID(rolesObjectArray.getJSONObject(0).getInt("workingLocationID")) + val workingLocationId = if (rolesObjectArray.length() > 0) { + val firstRole = rolesObjectArray.getJSONObject(0) + when { + firstRole.has("workingLocationID") -> firstRole.optInt("workingLocationID", -1) + firstRole.has("workingLocationId") -> firstRole.optInt("workingLocationId", -1) + else -> -1 + } + } else { + -1 + } + if (workingLocationId != -1) { + preferenceDao.setWorkingLocationID(workingLocationId) + } else { + Timber.w("getTokenTmc: workingLocationID missing in role payload") + } val rolesArray = extractRoles(privilegesObject); val name = data.getString("fullName") user = UserNetwork(userId, userName, password, name, rolesArray) @@ -326,12 +345,13 @@ class UserRepo @Inject constructor( val serviceMapId = privilegesObject.getInt("providerServiceMapID") user?.serviceMapId = serviceMapId + applyFacilityDataFromLoginResponse(data) TokenInsertTmcInterceptor.setToken(token) preferenceDao.registerPrimaryApiToken(token) - preferenceDao.registerUser(user!!) getUserVanSpDetails(context) getLocDetailsBasedOnSpIDAndPsmID() getUserMasterVillage() + preferenceDao.registerUser(user!!) } else { val errorMessage = responseBody.getString("errorMessage") GlobalScope.launch(Dispatchers.Main) { @@ -350,7 +370,7 @@ class UserRepo @Inject constructor( return withContext(Dispatchers.IO) { val response = tmcNetworkApiService.getLocDetailsBasedOnSpIDAndPsmID( LocationRequest( - user!!.vanId, + user!!.facilityID, user!!.serviceMapId.toString(), user!!.userId ) @@ -369,17 +389,35 @@ class UserRepo @Inject constructor( if (responseStatusCode == 200) { val data = responseBody.getJSONObject("data") val otherLoc = data.getJSONObject("otherLoc") - val stateId = otherLoc.getString("stateID") - val districtList = otherLoc.getJSONArray("districtList") - val districtObject = districtList.getJSONObject(0) - val districtId = districtObject.getString("districtID") - val districtName = districtObject.getString("districtName") - val blockId = districtObject.getString("blockId") - val blockName = districtObject.getString("blockName") - val villageList = districtObject.getJSONArray("villageList") + val stateId = otherLoc.optString("stateID", "") + val districtList = otherLoc.optJSONArray("districtList") + if (districtList == null || districtList.length() == 0) { + Timber.w("getLocDetailsBasedOnSpIDAndPsmID: districtList empty, skipping location seed") + return@withContext + } + val districtObject = districtList.optJSONObject(0) + if (districtObject == null) { + Timber.w("getLocDetailsBasedOnSpIDAndPsmID: district object missing, skipping location seed") + return@withContext + } + val districtId = districtObject.optString("districtID", "") + val districtName = districtObject.optString("districtName", "") + val blockId = districtObject.optString("blockId", "") + val blockName = districtObject.optString("blockName", "") + val villageList = districtObject.optJSONArray("villageList") ?: JSONArray() + + if (districtId.isBlank() || blockId.isBlank() || stateId.isBlank()) { + Timber.w( + "getLocDetailsBasedOnSpIDAndPsmID: incomplete location payload stateId=%s districtId=%s blockId=%s, skipping DB seed", + stateId, + districtId, + blockId + ) + return@withContext + } val itemType = object : TypeToken>() {}.type - var villageLocationDataList : List = Gson().fromJson(villageList.toString(), itemType) + var villageLocationDataList : List = Gson().fromJson(villageList.toString(), itemType) ?: emptyList() villageLocationDataList = villageLocationDataList.toSet().toList() val stateMaster = data.getJSONArray("stateMaster") @@ -389,10 +427,10 @@ class UserRepo @Inject constructor( val jsonObject = stateMaster.getJSONObject(i) val id = jsonObject.getInt("stateID").toString() val stateName = jsonObject.getString("stateName") - val lgdStateId = jsonObject.getString("govtLGDStateID") + val lgdStateId = jsonObject.optString("govtLGDStateID", "") if (id == stateId) { - stateMasterName = stateName - govtLGDStateID = lgdStateId.toInt() + stateMasterName = stateName + govtLGDStateID = lgdStateId.toIntOrNull() } } if(stateMasterDao.getStateById(stateId.toInt()) == null ){ @@ -436,6 +474,43 @@ class UserRepo @Inject constructor( } } + private fun applyFacilityDataFromLoginResponse(data: JSONObject) { + if (!data.has("facilityData")) return + try { + val facilityData = data.getJSONObject("facilityData") + val facilityUser = facilityData.optJSONObject("user") + val facilityLocation = facilityData.optJSONObject("location") + if (facilityUser != null) { + user?.employeeId = when { + facilityUser.has("employeeId") -> facilityUser.getString("employeeId") + facilityUser.has("employeeID") -> facilityUser.getString("employeeID") + else -> null + }?.nullIfEmpty() + } + if (facilityLocation != null) { + user?.locationType = when { + facilityLocation.has("locationType") -> facilityLocation.getString("locationType") + else -> null + }?.nullIfEmpty() + } + val facilities = facilityData.optJSONArray("facilities") ?: return + if (facilities.length() == 0) return + val facility = facilities.getJSONObject(0) + val facilityId = when { + facility.has("facilityId") -> facility.getInt("facilityId") + facility.has("facilityID") -> facility.getInt("facilityID") + else -> -1 + } + if (facilityId != -1) { + user?.facilityID = facilityId + } + user?.facilityType = facility.optString("facilityType", "").nullIfEmpty() + user?.facilityName = facility.optString("facilityName", "").nullIfEmpty() + } catch (e: JSONException) { + Timber.w(e, "applyFacilityDataFromLoginResponse: failed to parse facilityData") + } + } + private fun extractRoles(privilegesObject : JSONObject) : String{ // return "Lab Technician,MO,Pharmacist,Registrar,Staff Nurse" // return "Lab Technician,MO,Pharmacist" @@ -449,7 +524,7 @@ class UserRepo @Inject constructor( return roles.substring(0, roles.length - 1) } - private fun encrypt(password: String): String { + private fun encrypt(password: String): String { val util = CryptoUtil() return util.encrypt(password) } @@ -542,38 +617,38 @@ class UserRepo @Inject constructor( user.masterVillageName = masterVillageName userDao.update(user) } - suspend fun setUserMasterVillage(user:UserCache, userMasterVillage: UserMasterVillage) { - val response = tmcNetworkApiService.setUserMasterVillage(userMasterVillage) - val statusCode = response.code() - if (statusCode == 200) { - val responseString = response.body()?.string() - val responseJson = JSONObject(responseString!!) - val responseStatusCode = responseJson.getInt("statusCode") - if (responseStatusCode == 200) { - val data = responseJson.getJSONObject("data") - val masterVillageName = data.getString("villageName") - user.masterVillageName = masterVillageName - val masterVillageId = data.getInt("districtBranchID") - user.masterVillageID = masterVillageId - val masterLocAddress = data.getString("address") - user.masterLocationAddress = masterLocAddress - val loginDistance = data.getInt("loginDistance") - user.loginDistance = loginDistance - val blockId = data.getInt("blockID") - user.masterBlockID = blockId - val masterLatitude = data.getDouble("latitude") - user.masterLatitude = masterLatitude - val masterLongitude = data.getDouble("longitude") - user.masterLongitude = masterLongitude - - val use = user - use.masterVillageName - - userDao.update(user) - - } - } - } + suspend fun setUserMasterVillage(user:UserCache, userMasterVillage: UserMasterVillage) { + val response = tmcNetworkApiService.setUserMasterVillage(userMasterVillage) + val statusCode = response.code() + if (statusCode == 200) { + val responseString = response.body()?.string() + val responseJson = JSONObject(responseString!!) + val responseStatusCode = responseJson.getInt("statusCode") + if (responseStatusCode == 200) { + val data = responseJson.getJSONObject("data") + val masterVillageName = data.getString("villageName") + user.masterVillageName = masterVillageName + val masterVillageId = data.getInt("districtBranchID") + user.masterVillageID = masterVillageId + val masterLocAddress = data.getString("address") + user.masterLocationAddress = masterLocAddress + val loginDistance = data.getInt("loginDistance") + user.loginDistance = loginDistance + val blockId = data.getInt("blockID") + user.masterBlockID = blockId + val masterLatitude = data.getDouble("latitude") + user.masterLatitude = masterLatitude + val masterLongitude = data.getDouble("longitude") + user.masterLongitude = masterLongitude + + val use = user + use.masterVillageName + + userDao.update(user) + + } + } + } suspend fun processUnsyncedAuditData(): Boolean{ val loginAuditDataListUnsynced: List = userDao.getLoginAuditDataListUnsynced() if(loginAuditDataListUnsynced.isNotEmpty()){ @@ -586,8 +661,8 @@ class UserRepo @Inject constructor( } is NetworkResult.Error -> { if(response.code == socketTimeoutException){ - throw SocketTimeoutException("caught exception") - } + throw SocketTimeoutException("caught exception") + } return false } else ->{ @@ -598,4 +673,4 @@ class UserRepo @Inject constructor( return true } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/repositories/VisitReasonsAndCategoriesRepo.kt b/app/src/main/java/org/piramalswasthya/cho/repositories/VisitReasonsAndCategoriesRepo.kt index b7581299f..67425eced 100644 --- a/app/src/main/java/org/piramalswasthya/cho/repositories/VisitReasonsAndCategoriesRepo.kt +++ b/app/src/main/java/org/piramalswasthya/cho/repositories/VisitReasonsAndCategoriesRepo.kt @@ -125,6 +125,12 @@ class VisitReasonsAndCategoriesRepo @Inject constructor( suspend fun getVisitDbByPatientIDAndBenVisitNo(patientID: String, benVisitNo: Int) : VisitDB?{ return visitReasonsAndCategoriesDao.getVisitDbByBenRegIdAndBenVisitNo(patientID, benVisitNo) } + + suspend fun getLatestVisitDbByPatientIDAndBenVisitNo(patientID: String, benVisitNo: Int): VisitDB? { + return withContext(Dispatchers.IO) { + visitReasonsAndCategoriesDao.getLatestVisitDbByPatientIDAndBenVisitNo(patientID, benVisitNo) + } + } fun getVisitDbByPatientIDAndBenVisitNo(patientID: String) : LiveData{ return visitReasonsAndCategoriesDao.getLatestVisitIdByPatientId(patientID) } diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/BindingUtils.kt b/app/src/main/java/org/piramalswasthya/cho/ui/BindingUtils.kt index 159a11b8a..90396ff7d 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/BindingUtils.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/BindingUtils.kt @@ -21,6 +21,7 @@ import android.widget.RadioGroup.LayoutParams import androidx.annotation.RequiresApi import androidx.cardview.widget.CardView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.google.android.material.color.MaterialColors import com.google.android.material.divider.MaterialDivider @@ -31,7 +32,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import org.piramalswasthya.cho.database.room.SyncState import org.piramalswasthya.cho.helpers.Konstants +import org.piramalswasthya.cho.model.AbortionDomain import org.piramalswasthya.cho.model.AncFormState +import org.piramalswasthya.cho.model.InfantRegDomain import org.piramalswasthya.cho.model.AncFormState.* //import org.piramalswasthya.cho.model.BenBasicDomain import org.piramalswasthya.cho.model.FormInputOld @@ -87,6 +90,21 @@ fun Button.setVaccineState(syncState: VaccineState?) { } } +@BindingAdapter("abortionActionText") +fun Button.setAbortionActionText(item: AbortionDomain?) { + item ?: return + text = context.getString( + if (item.isAbortionFormFilled) Resource.string.view else Resource.string.add + ) +} + +@BindingAdapter("infantRegActionText") +fun Button.setInfantRegActionText(item: InfantRegDomain?) { + item ?: return + text = context.getString( + if (item.shouldShowRegisterAction()) Resource.string.register else Resource.string.view + ) +} @BindingAdapter("scope", "recordCount") fun TextView.setRecordCount(scope: CoroutineScope, count: Flow?) { @@ -290,21 +308,32 @@ private val rotate = RotateAnimation( @BindingAdapter("syncState") fun ImageView.setSyncState(syncState: SyncState?) { + clearAnimation() syncState?.let { visibility = View.VISIBLE val drawable = when (it) { SyncState.UNSYNCED -> Resource.drawable.ic_unsynced SyncState.SYNCING -> Resource.drawable.ic_syncing SyncState.SYNCED -> Resource.drawable.ic_synced - else -> { - Resource.drawable.ic_unsynced - } + else -> Resource.drawable.ic_unsynced } setImageResource(drawable) + imageTintList = android.content.res.ColorStateList.valueOf( + ContextCompat.getColor( + context, + when (it) { + SyncState.SYNCED -> android.R.color.holo_green_dark + SyncState.UNSYNCED -> android.R.color.holo_red_dark + SyncState.SYNCING -> android.R.color.holo_orange_light + else -> android.R.color.darker_gray + } + ) + ) isClickable = it == SyncState.UNSYNCED if (it == SyncState.SYNCING) startAnimation(rotate) } ?: run { visibility = View.INVISIBLE + isClickable = false } } @@ -424,4 +453,3 @@ fun TextView.setAsteriskTextView(required: Boolean?, title: String?) { } } - diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/ENT/EarDiagnosisFormFragment.kt b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/EarDiagnosisFormFragment.kt new file mode 100644 index 000000000..7e5baa57a --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/EarDiagnosisFormFragment.kt @@ -0,0 +1,71 @@ +package org.piramalswasthya.cho.ui.ENT + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.databinding.FragmentEarDiagnosisFormBinding +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.ui.commons.BaseAssessmentFormFragment +import org.piramalswasthya.cho.work.WorkerUtils + +@AndroidEntryPoint +class EarDiagnosisFormFragment : + BaseAssessmentFormFragment() { + + private var _binding: FragmentEarDiagnosisFormBinding? = null + private val binding get() = _binding!! + + override val viewModel: EarDiagnosisFormViewModel by viewModels() + + // ── View references ─────────────────────────────────────────────────────── + + override val inputFormRecyclerView: RecyclerView get() = binding.form.rvInputForm + override val contentLayout: View get() = binding.llContent + override val progressBar: View get() = binding.pbForm + override val benNameTextView: TextView get() = binding.tvBenName + override val ageGenderTextView: TextView get() = binding.tvAgeGender + override val submitButton: View get() = binding.btnSubmit + override val cancelButton: View get() = binding.btnCancel + + // ── Form-specific values ────────────────────────────────────────────────── + + override fun getFormTitle(): String = getString(R.string.title_ear_diagnosis) + override fun getSaveSuccessMessage(): String = getString(R.string.ear_diagnosis_saved) + override fun getFormFlow(): Flow> = viewModel.formList + override fun onUpdateFormValue(formId: Int, index: Int) = + viewModel.updateListOnValueChanged(formId, index) + override fun onSaveForm() = viewModel.saveForm() + override fun getFragmentId(): Int = R.id.fragment_ear_diagnosis_form + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEarDiagnosisFormBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() // shows bottom_navigation + _binding = null + } + + // Stamp Ear visit metadata onto MasterDb from arguments and navigate to the vitals screen. + override fun onSaveSuccess() { + WorkerUtils.earPushWorker(requireContext()) + navigateToCphcVitalsAfterSave( + subCategory = org.piramalswasthya.cho.ui.commons.DropdownConst.ear, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/ENT/EarDiagnosisFormViewModel.kt b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/EarDiagnosisFormViewModel.kt new file mode 100644 index 000000000..4e4772416 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/EarDiagnosisFormViewModel.kt @@ -0,0 +1,83 @@ +package org.piramalswasthya.cho.ui.ENT + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import org.piramalswasthya.cho.configuration.EarDiagnosisDataset +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.EarDiagnosisAssessment +import org.piramalswasthya.cho.repositories.EarDiagnosisRepo +import org.piramalswasthya.cho.repositories.PatientRepo +import org.piramalswasthya.cho.repositories.UserRepo +import org.piramalswasthya.cho.ui.commons.BaseFormViewModel +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class EarDiagnosisFormViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + preferenceDao: PreferenceDao, + @ApplicationContext context: Context, + private val patientRepo: PatientRepo, + private val userRepo: UserRepo, + private val earDiagnosisRepo: EarDiagnosisRepo +) : BaseFormViewModel() { + + val patientID: String? = savedStateHandle["patientID"] + val benVisitNo: Int? = savedStateHandle["benVisitNo"] + + private val dataset = EarDiagnosisDataset(context, preferenceDao.getCurrentLanguage()) + val formList = dataset.listFlow + + private lateinit var assessmentCache: EarDiagnosisAssessment + + init { + setupDatasetCallbacks() + + viewModelScope.launch { + try { + val patient = loadPatientDetails(userRepo, patientRepo, patientID) + ?: return@launch + + val existingRecord = if (benVisitNo != null) { + earDiagnosisRepo.getAssessmentByPatientIdAndVisitNo( + patient.patient.patientID, benVisitNo + ) + } else { + earDiagnosisRepo.getAssessmentByPatientId(patient.patient.patientID) + } + + assessmentCache = existingRecord ?: EarDiagnosisAssessment( + patientId = patient.patient.patientID, + benVisitNo = benVisitNo + ) + + dataset.setUpPage(savedRecord = existingRecord) + + } catch (e: Exception) { + Timber.e(e, "Error initializing EarDiagnosisFormViewModel") + } + } + } + + private fun setupDatasetCallbacks() { + dataset.onShowAlert = { message -> + _showAlert.postValue(message) + } + } + + fun updateListOnValueChanged(formId: Int, index: Int) { + launchUpdateList(dataset, formId, index, "Error updating ear diagnosis form") + } + + fun saveForm() { + launchSave("Saving Ear Diagnosis failed") { + check(::assessmentCache.isInitialized) { "Assessment cache not initialized" } + dataset.mapValues(assessmentCache, 1) + earDiagnosisRepo.saveAssessment(assessmentCache) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/ENT/NoseDiagnosisFormFragment.kt b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/NoseDiagnosisFormFragment.kt new file mode 100644 index 000000000..a4ea01208 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/NoseDiagnosisFormFragment.kt @@ -0,0 +1,188 @@ +package org.piramalswasthya.cho.ui.ENT + +import android.app.AlertDialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.adapter.FormInputAdapter +import org.piramalswasthya.cho.databinding.FragmentNoseDiagnosisFormBinding +import org.piramalswasthya.cho.ui.commons.CphcFormNavigation +import org.piramalswasthya.cho.ui.commons.BaseFormViewModel +import org.piramalswasthya.cho.ui.commons.NavigationAdapter +import org.piramalswasthya.cho.work.WorkerUtils + + +@AndroidEntryPoint +class NoseDiagnosisFormFragment : Fragment(), NavigationAdapter { + + private var _binding: FragmentNoseDiagnosisFormBinding? = null + private val binding get() = _binding!! + + private val viewModel: NoseDiagnosisFormViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentNoseDiagnosisFormBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + onBackPressedCallback + ) + + setupForm() + observeViewModel() + + viewModel.benName.observe(viewLifecycleOwner) { + binding.tvBenName.text = it + } + + viewModel.benAgeGender.observe(viewLifecycleOwner) { + binding.tvAgeGender.text = it + } + + binding.btnSubmit.setOnClickListener { submitForm() } + binding.btnCancel.setOnClickListener { onCancelAction() } + } + + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onCancelAction() + } + } + + private fun setupForm() { + + val adapter = FormInputAdapter( + formValueListener = FormInputAdapter.FormValueListener { formId, index -> + viewModel.updateListOnValueChanged(formId, index) + }, + isEnabled = true + ) + + binding.form.rvInputForm.layoutManager = + LinearLayoutManager(requireContext()) + + binding.form.rvInputForm.adapter = adapter + + lifecycleScope.launch { + viewModel.formList.collect { list -> + if (list.isNotEmpty()) { + adapter.submitList(list) + } + } + } + + (activity as? AppCompatActivity)?.supportActionBar?.title = + getString(R.string.title_nose_diagnosis) + + activity?.findViewById(R.id.bottom_navigation)?.visibility = View.GONE + } + + private fun observeViewModel() { + + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state) { + BaseFormViewModel.State.IDLE -> Unit + + BaseFormViewModel.State.SAVING -> { + binding.llContent.visibility = View.GONE + binding.pbForm.visibility = View.VISIBLE + } + + BaseFormViewModel.State.SAVE_SUCCESS -> { + binding.llContent.visibility = View.VISIBLE + binding.pbForm.visibility = View.GONE + WorkerUtils.nosePushWorker(requireContext()) + Toast.makeText(context, getString(R.string.nose_diagnosis_saved), Toast.LENGTH_LONG).show() + // Prevent re-delivery of SAVE_SUCCESS when returning from Vitals via back/cancel. + viewModel.resetState() + findNavController().navigate( + org.piramalswasthya.cho.R.id.customVitalsFragment, + CphcFormNavigation.buildVitalsBundle( + arguments = arguments, + subCategory = org.piramalswasthya.cho.ui.commons.DropdownConst.nose, + reasonForVisit = org.piramalswasthya.cho.ui.commons.DropdownConst.nose, + ), + ) + } + + BaseFormViewModel.State.SAVE_FAILED -> { + binding.llContent.visibility = View.VISIBLE + binding.pbForm.visibility = View.GONE + Toast.makeText(context, getString(R.string.save_failed_retry), Toast.LENGTH_LONG).show() + } + else -> Unit + } + } + + viewModel.showAlert.observe(viewLifecycleOwner) { message -> + message?.let { + AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.alert)) + .setMessage(it) + .setPositiveButton(getString(R.string.ok_button)) { dialog, _ -> + dialog.dismiss() + viewModel.clearAlert() + } + .setCancelable(false) + .show() + } + } + } + + private fun submitForm() { + val adapter = binding.form.rvInputForm.adapter as? FormInputAdapter ?: return + + val result = adapter.validateInput(resources) + if (result == -1) { + viewModel.saveForm() + } else { + binding.form.rvInputForm.scrollToPosition(result) + } + } + + override fun onSubmitAction() { + submitForm() + } + + override fun onCancelAction() { + if (!findNavController().navigateUp()) { + onBackPressedCallback.remove() + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + override fun getFragmentId(): Int = + R.id.fragment_nose_diagnosis_form + + override fun onDestroyView() { + activity?.findViewById(R.id.bottom_navigation)?.visibility = View.VISIBLE + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/ENT/NoseDiagnosisFormViewModel.kt b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/NoseDiagnosisFormViewModel.kt new file mode 100644 index 000000000..26f585767 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/NoseDiagnosisFormViewModel.kt @@ -0,0 +1,100 @@ +package org.piramalswasthya.cho.ui.ENT + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import org.piramalswasthya.cho.configuration.NoseDiagnosisDataset +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.NoseDiagnosisAssessment +import org.piramalswasthya.cho.repositories.NoseDiagnosisRepo +import org.piramalswasthya.cho.repositories.PatientRepo +import org.piramalswasthya.cho.repositories.UserRepo +import org.piramalswasthya.cho.ui.commons.BaseFormViewModel +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class NoseDiagnosisFormViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + preferenceDao: PreferenceDao, + @ApplicationContext context: Context, + private val patientRepo: PatientRepo, + private val userRepo: UserRepo, + private val noseDiagnosisRepo: NoseDiagnosisRepo +) : BaseFormViewModel() { + + /* -------------------- BEN DETAILS -------------------- */ + + val patientID: String? = savedStateHandle["patientID"] + val benVisitNo: Int? = savedStateHandle["benVisitNo"] + + /* -------------------- DATASET -------------------- */ + + private val dataset = + NoseDiagnosisDataset(context, preferenceDao.getCurrentLanguage()) + + val formList = dataset.listFlow + + private lateinit var assessmentCache: NoseDiagnosisAssessment + + init { + setupDatasetCallbacks() + + viewModelScope.launch { + try { + val patient = loadPatientDetails(userRepo, patientRepo, patientID) + ?: return@launch + + if (benVisitNo == null) { + Timber.e("Missing benVisitNo ($benVisitNo)") + return@launch + } + + val existingRecord = noseDiagnosisRepo.getAssessmentByPatientIdAndVisitNo( + patient.patient.patientID, + benVisitNo + ) + + assessmentCache = existingRecord ?: NoseDiagnosisAssessment( + patientId = patient.patient.patientID, + benVisitNo = benVisitNo + ) + + dataset.setUpPage(savedRecord = existingRecord) + + } catch (e: Exception) { + Timber.e(e, "Error initializing NoseDiagnosisFormViewModel") + } + } + } + + private fun setupDatasetCallbacks() { + dataset.onShowAlert = { message -> + Timber.Forest.d("Dataset requested alert: $message") + _showAlert.postValue(message) + } + } + + /* -------------------- FORM EVENTS -------------------- */ + + fun updateListOnValueChanged(formId: Int, index: Int) { + Timber.d("updateListOnValueChanged: formId=$formId, index=$index") + launchUpdateList(dataset, formId, index, "Error updating nose diagnosis form") + } + + fun saveForm() { + launchSave("Saving Nose Diagnosis failed") { + check(::assessmentCache.isInitialized) { "Assessment cache not initialized" } + dataset.mapValues(assessmentCache, 1) + noseDiagnosisRepo.saveAssessment(assessmentCache) + Timber.d("Nose Diagnosis saved successfully") + } + } +} + diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/ENT/ThroatDiagnosisFormFragment.kt b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/ThroatDiagnosisFormFragment.kt new file mode 100644 index 000000000..9424dfe02 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/ThroatDiagnosisFormFragment.kt @@ -0,0 +1,113 @@ +package org.piramalswasthya.cho.ui.ENT + +import android.app.AlertDialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.Flow +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.adapter.FormInputAdapter +import org.piramalswasthya.cho.databinding.FragmentThroatDiagnosisFormBinding +import org.piramalswasthya.cho.model.FormElement +import org.piramalswasthya.cho.model.MasterDb +import org.piramalswasthya.cho.model.VisitMasterDb +import org.piramalswasthya.cho.ui.commons.BaseAssessmentFormFragment +import org.piramalswasthya.cho.ui.commons.DropdownConst +import org.piramalswasthya.cho.work.WorkerUtils +import androidx.navigation.fragment.findNavController + +@AndroidEntryPoint +class ThroatDiagnosisFormFragment : + BaseAssessmentFormFragment() { + + private var _binding: FragmentThroatDiagnosisFormBinding? = null + private val binding get() = _binding!! + + override val viewModel: ThroatDiagnosisFormViewModel by viewModels() + + // ── View references ─────────────────────────────────────────────────────── + + override val inputFormRecyclerView: RecyclerView get() = binding.form.rvInputForm + override val contentLayout: View get() = binding.llContent + override val progressBar: View get() = binding.pbForm + override val benNameTextView: TextView get() = binding.tvBenName + override val ageGenderTextView: TextView get() = binding.tvAgeGender + override val submitButton: View get() = binding.btnSubmit + override val cancelButton: View get() = binding.btnCancel + + // ── Form-specific values ────────────────────────────────────────────────── + + override fun getFormTitle(): String = getString(R.string.title_throat_diagnosis) + override fun getSaveSuccessMessage(): String = getString(R.string.throat_diagnosis_saved) + override fun getFormFlow(): Flow> = viewModel.formList + override fun onUpdateFormValue(formId: Int, index: Int) = + viewModel.updateListOnValueChanged(formId, index) + override fun onSaveForm() = viewModel.saveForm() + override fun getFragmentId(): Int = R.id.throatDiagnosisFormFragment + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentThroatDiagnosisFormBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeMultiSelect() + } + + // ── Throat-specific: multi-select dialog ────────────────────────────────── + + private fun observeMultiSelect() { + viewModel.triggerMultiSelect.observe(viewLifecycleOwner) { data -> + data ?: return@observe + val selectedItemsList = mutableListOf() + AlertDialog.Builder(requireContext()) + .setTitle(data.title) + .setMultiChoiceItems(data.items, data.selectedItems) { _, which, isChecked -> + data.selectedItems[which] = isChecked + } + .setPositiveButton(getString(android.R.string.ok)) { _, _ -> + for (i in data.selectedItems.indices) { + if (data.selectedItems[i]) selectedItemsList.add(data.items[i]) + } + viewModel.updateMultiSelectValue(data.formId, selectedItemsList) + val adapter = binding.form.rvInputForm.adapter as? FormInputAdapter + adapter?.let { adp -> + val position = adp.currentList.indexOfFirst { it.id == data.formId } + if (position != -1) adp.notifyItemChanged(position) + } + viewModel.onMultiSelectDialogDismissed() + } + .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> + dialog.dismiss() + viewModel.onMultiSelectDialogDismissed() + } + .show() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + // Stamp Throat visit metadata onto MasterDb from arguments and navigate to vitals; replaces fresh MasterDb construction that lost chief-complaint data. + override fun onSaveSuccess() { + WorkerUtils.throatPushWorker(requireContext()) + navigateToCphcVitalsAfterSave( + subCategory = org.piramalswasthya.cho.ui.commons.DropdownConst.throat, + ) + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/ENT/ThroatDiagnosisFormViewModel.kt b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/ThroatDiagnosisFormViewModel.kt new file mode 100644 index 000000000..625862630 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/ENT/ThroatDiagnosisFormViewModel.kt @@ -0,0 +1,146 @@ +package org.piramalswasthya.cho.ui.ENT + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import org.piramalswasthya.cho.configuration.ThroatDiagnosisDataset +import org.piramalswasthya.cho.database.shared_preferences.PreferenceDao +import org.piramalswasthya.cho.model.ThroatDiagnosisAssessment +import org.piramalswasthya.cho.repositories.ThroatDiagnosisRepo +import org.piramalswasthya.cho.repositories.PatientRepo +import org.piramalswasthya.cho.repositories.UserRepo +import org.piramalswasthya.cho.ui.commons.BaseFormViewModel +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class ThroatDiagnosisFormViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + preferenceDao: PreferenceDao, + @ApplicationContext context: Context, + private val patientRepo: PatientRepo, + private val userRepo: UserRepo, + private val throatDiagnosisRepo: ThroatDiagnosisRepo +) : BaseFormViewModel() { + + /* -------------------- MULTI-SELECT -------------------- */ + + private val _triggerMultiSelect = MutableLiveData() + val triggerMultiSelect: LiveData get() = _triggerMultiSelect + + data class MultiSelectData( + val formId: Int, + val title: String, + val items: Array, + val selectedItems: BooleanArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MultiSelectData) return false + return formId == other.formId && + title == other.title && + items.contentEquals(other.items) && + selectedItems.contentEquals(other.selectedItems) + } + + override fun hashCode(): Int { + var result = formId + result = 31 * result + title.hashCode() + result = 31 * result + items.contentHashCode() + result = 31 * result + selectedItems.contentHashCode() + return result + } + } + + /* -------------------- BEN DETAILS -------------------- */ + + val patientID: String? = savedStateHandle["patientID"] + val benVisitNo: Int? = savedStateHandle["benVisitNo"] + + /* -------------------- DATASET -------------------- */ + + private val dataset = + ThroatDiagnosisDataset(context, preferenceDao.getCurrentLanguage()) + + val formList = dataset.listFlow + + private lateinit var assessmentCache: ThroatDiagnosisAssessment + + init { + viewModelScope.launch { + try { + val patient = loadPatientDetails(userRepo, patientRepo, patientID) + ?: return@launch + + val existingRecord = if (benVisitNo != null) { + throatDiagnosisRepo.getAssessmentByPatientIdAndVisitNo( + patient.patient.patientID, + benVisitNo + ) + } else { + throatDiagnosisRepo.getAssessmentByPatientId(patient.patient.patientID) + } + + assessmentCache = existingRecord ?: ThroatDiagnosisAssessment( + patientId = patient.patient.patientID, + benVisitNo = benVisitNo + ) + + setupDatasetCallbacks() + + dataset.setUpPage(savedRecord = existingRecord) + + } catch (e: Exception) { + Timber.e(e, "Error initializing ThroatDiagnosisFormViewModel") + } + } + } + + private fun setupDatasetCallbacks() { + dataset.onShowAlert = { message -> + _showAlert.postValue(message) + } + + dataset.onTriggerMultiSelect = { formId, title, items, selectedItems -> + _triggerMultiSelect.value = + MultiSelectData(formId, title, items, selectedItems) + } + } + + /* -------------------- FORM EVENTS -------------------- */ + + fun updateListOnValueChanged(formId: Int, index: Int) { + launchUpdateList(dataset, formId, index, "Error updating throat diagnosis form") + } + + fun onMultiSelectClick(formId: Int) { + dataset.triggerMultiSelect(formId) + } + + fun updateMultiSelectValue(formId: Int, selectedItems: List) { + viewModelScope.launch { + try { + dataset.updateMultiSelectValue(formId, selectedItems) + } catch (e: Exception) { + Timber.e(e, "Error updating multi-select value") + } + } + } + + fun onMultiSelectDialogDismissed() { + _triggerMultiSelect.value = null + } + + fun saveForm() { + launchSave("Saving Throat Diagnosis failed") { + check(::assessmentCache.isInitialized) { "Assessment cache not initialized" } + dataset.mapValues(assessmentCache, 1) + throatDiagnosisRepo.saveAssessment(assessmentCache) + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_id/aadhaar_num_asha/AadhaarNumberAshaFragment.kt b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_id/aadhaar_num_asha/AadhaarNumberAshaFragment.kt index ca67fa5cd..aeb7ad45e 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_id/aadhaar_num_asha/AadhaarNumberAshaFragment.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_id/aadhaar_num_asha/AadhaarNumberAshaFragment.kt @@ -156,7 +156,7 @@ class AadhaarNumberAshaFragment : Fragment() { viewModel.aadhaarNumber.value = binding.tietAadhaarNumber.text.toString() parentViewModel.navigateToAadhaarConsent(true) }else{ - Toast.makeText(requireContext(),"Please Enter Beneficiary Name",Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), getString(R.string.please_enter_beneficiary_name), Toast.LENGTH_SHORT).show() } // aadhaarDisclaimer.show() } diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpFragment.kt b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpFragment.kt index 9b8457304..4b246c8b0 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpFragment.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpFragment.kt @@ -90,7 +90,7 @@ class AadhaarOtpFragment : Fragment() { } if (parentViewModel.abhaMode.value == AadhaarIdViewModel.Abha.SEARCH) { - binding.textView6.text = "Verify ABHA OTP" + binding.textView6.text = getString(R.string.verify_abha_otp) } diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpViewModel.kt b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpViewModel.kt index c34ac6e02..73a6679d4 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpViewModel.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/aadhaar_otp/AadhaarOtpViewModel.kt @@ -128,7 +128,11 @@ class AadhaarOtpViewModel @Inject constructor( _name = result.data.ABHAProfile.firstName + " " + result.data.ABHAProfile.lastName } - _phrAddress = result.data.ABHAProfile.phrAddress!![0] + try { + _phrAddress = result.data.ABHAProfile.phrAddress!![0] + } catch (e: Exception) { + e.printStackTrace() + } _abhaNumber = result.data.ABHAProfile.ABHANumber val gsonData = Gson().toJson(result.data) diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/create_abha_id/CreateAbhaFragment.kt b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/create_abha_id/CreateAbhaFragment.kt index edd3d9448..410309a52 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/create_abha_id/CreateAbhaFragment.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/abha_id_activity/create_abha_id/CreateAbhaFragment.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.ContentValues import android.content.Intent import android.media.MediaScannerConnection import android.net.Uri @@ -27,6 +28,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData import androidx.navigation.NavController import androidx.navigation.fragment.findNavController +import android.provider.MediaStore import androidx.work.Operation import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar @@ -109,9 +111,9 @@ class CreateAbhaFragment : Fragment() { private val beneficiaryDisclaimer by lazy { AlertDialog.Builder(requireContext()) - .setTitle("beneficiary abha mapping.") - .setMessage("linking abha to beneficiary") - .setPositiveButton("Ok") { dialog, _ -> dialog.dismiss() } + .setTitle(getString(R.string.beneficiary_abha_mapping)) + .setMessage(getString(R.string.linking_abha_to_beneficiary)) + .setPositiveButton(getString(R.string.ok_button)) { dialog, _ -> dialog.dismiss() } .create() } @@ -253,27 +255,58 @@ class CreateAbhaFragment : Fragment() { } private fun showFileNotification(fileStr: ResponseBody) { - val fileName = - "${benId}_${System.currentTimeMillis()}.png" + val fileName = "${benId}_${System.currentTimeMillis()}.png" val notificationManager = requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( - channelId, channelId, + channelId, + channelId, NotificationManager.IMPORTANCE_HIGH ) notificationManager.createNotificationChannel(channel) } - val directory = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val file = File(directory, fileName) - FileOutputStream(file).use { stream -> stream.write(fileStr.bytes()) } - MediaScannerConnection.scanFile( - requireContext(), - arrayOf(file.toString()), - null, - ) { _, uri -> - run { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = requireContext().contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "image/png") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val collection = + MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val itemUri = resolver.insert(collection, contentValues) + + if (itemUri != null) { + resolver.openOutputStream(itemUri)?.use { stream -> + stream.write(fileStr.bytes()) + } + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(itemUri, contentValues, null, null) + + showDownload(fileName, itemUri, notificationManager) + } else { + Toast.makeText( + requireContext(), + getString(R.string.download_failed_retry), + Toast.LENGTH_SHORT + ).show() + } + } else { + val directory = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val file = File(directory, fileName) + FileOutputStream(file).use { stream -> stream.write(fileStr.bytes()) } + MediaScannerConnection.scanFile( + requireContext(), + arrayOf(file.toString()), + null, + ) { _, uri -> showDownload(fileName, uri, notificationManager) } } @@ -363,7 +396,7 @@ class CreateAbhaFragment : Fragment() { } is Operation.State.FAILURE -> { - Toast.makeText(context, "Failed to download , Please retry", Toast.LENGTH_SHORT) + Toast.makeText(context, getString(R.string.download_failed_retry), Toast.LENGTH_SHORT) .show() } } diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/commons/BaseAssessmentFormFragment.kt b/app/src/main/java/org/piramalswasthya/cho/ui/commons/BaseAssessmentFormFragment.kt new file mode 100644 index 000000000..622655741 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/commons/BaseAssessmentFormFragment.kt @@ -0,0 +1,224 @@ +package org.piramalswasthya.cho.ui.commons + +import android.app.AlertDialog +import android.os.Bundle +import android.view.View +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import org.piramalswasthya.cho.R +import org.piramalswasthya.cho.adapter.FormInputAdapter +import org.piramalswasthya.cho.model.FormElement + +/** + * Base [Fragment] for single-page assessment / diagnosis forms. + * + * Concrete subclasses must: + * - provide view references via the abstract `val` properties (delegating to their + * specific ViewBinding after inflation) + * - implement the handful of abstract methods that carry form-specific values + * - call `super.onDestroyView()` **before** nulling their `_binding` + * + * @param VM A [BaseFormViewModel] subclass that drives this form. + */ +abstract class BaseAssessmentFormFragment : Fragment(), NavigationAdapter { + + // ── Required ViewModel ──────────────────────────────────────────────────── + + protected abstract val viewModel: VM + + // ── View references (delegated to concrete binding in subclass) ─────────── + + protected abstract val inputFormRecyclerView: RecyclerView + protected abstract val contentLayout: View + protected abstract val progressBar: View + protected abstract val benNameTextView: TextView + protected abstract val ageGenderTextView: TextView + protected abstract val submitButton: View + protected abstract val cancelButton: View + + // ── Form-specific overrides ─────────────────────────────────────────────── + + /** ActionBar title for this form. */ + protected abstract fun getFormTitle(): String + + /** Toast text shown on SAVE_SUCCESS. */ + protected abstract fun getSaveSuccessMessage(): String + + /** StateFlow / SharedFlow of [FormElement] lists supplied by the ViewModel. */ + protected abstract fun getFormFlow(): Flow> + + /** Called when a form value changes; delegates to ViewModel. */ + protected abstract fun onUpdateFormValue(formId: Int, index: Int) + + /** Triggers the ViewModel save. */ + protected abstract fun onSaveForm() + + // ── Back-press ──────────────────────────────────────────────────────────── + + protected val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = onCancelAction() + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + requireActivity().onBackPressedDispatcher + .addCallback(viewLifecycleOwner, onBackPressedCallback) + + setupFormAdapter() + observeState() + observeAlert() + + viewModel.benName.observe(viewLifecycleOwner) { benNameTextView.text = it } + viewModel.benAgeGender.observe(viewLifecycleOwner) { ageGenderTextView.text = it } + + submitButton.setOnClickListener { submitForm() } + cancelButton.setOnClickListener { onCancelAction() } + } + + // ── Form adapter ────────────────────────────────────────────────────────── + + private fun setupFormAdapter() { + val adapter = FormInputAdapter( + formValueListener = FormInputAdapter.FormValueListener { formId, index -> + onUpdateFormValue(formId, index) + }, + isEnabled = true + ) + + inputFormRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + inputFormRecyclerView.adapter = adapter + + viewLifecycleOwner.lifecycleScope.launch { + getFormFlow().collect { list -> + if (list.isNotEmpty()) { + adapter.submitList(list.toMutableList()) { + //This are required to reflected without requiring manual scroll. + inputFormRecyclerView.post { adapter.notifyDataSetChanged() } + } + } + } + } + + (activity as? AppCompatActivity)?.supportActionBar?.title = getFormTitle() + activity?.findViewById(R.id.bottom_navigation)?.visibility = View.GONE + } + + // ── State / alert observers ─────────────────────────────────────────────── + + private fun observeState() { + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state) { + BaseFormViewModel.State.IDLE -> Unit + + BaseFormViewModel.State.SAVING -> { + contentLayout.visibility = View.GONE + progressBar.visibility = View.VISIBLE + } + + BaseFormViewModel.State.SAVE_SUCCESS -> { + contentLayout.visibility = View.VISIBLE + progressBar.visibility = View.GONE + Toast.makeText(requireContext(), getSaveSuccessMessage(), Toast.LENGTH_LONG).show() + viewModel.resetState() // Reset to IDLE so back-stack re-delivery does not retrigger onSaveSuccess(). + onSaveSuccess() + } + + BaseFormViewModel.State.SAVE_FAILED -> { + contentLayout.visibility = View.VISIBLE + progressBar.visibility = View.GONE + Toast.makeText(requireContext(), getString(R.string.form_save_failed), Toast.LENGTH_LONG).show() + } + } + } + } + + /** Override in subclasses to customise post-save navigation. Default: pop back. */ + protected open fun onSaveSuccess() { + findNavController().navigateUp() + } + + protected fun navigateToCphcVitalsAfterSave( + subCategory: String, + reasonForVisit: String? = null, + category: String = CphcFormNavigation.OTHER_CPHC_CATEGORY, + requireMasterDb: Boolean = false, + ) { + findNavController().navigate( + R.id.customVitalsFragment, + CphcFormNavigation.buildVitalsBundle( + arguments = arguments, + category = category, + subCategory = subCategory, + reasonForVisit = reasonForVisit, + requireMasterDb = requireMasterDb, + ), + ) + } + + private fun observeAlert() { + viewModel.showAlert.observe(viewLifecycleOwner) { message -> + message?.let { showAlertDialog(it) } + } + } + + private fun showAlertDialog(message: String) { + AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.form_alert_title)) + .setMessage(message) + .setPositiveButton("OK") { dialog, _ -> + dialog.dismiss() + viewModel.clearAlert() + } + .setCancelable(false) + .show() + } + + + protected fun submitForm() { + val adapter = inputFormRecyclerView.adapter as? FormInputAdapter ?: return + val result = adapter.validateInput(resources) + if (result == -1) { + onSaveForm() + } else { + val fieldName = adapter.currentList.getOrNull(result)?.title ?: getString(R.string.form_input_empty_error) + Toast.makeText( + requireContext(), + getString(R.string.form_fill_field_error, fieldName), + Toast.LENGTH_SHORT + ).show() + inputFormRecyclerView.scrollToPosition(result) + } + } + + override fun onSubmitAction() = submitForm() + + override fun onCancelAction() { + onBackPressedCallback.isEnabled = false + if (!findNavController().navigateUp()) { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + override fun onDestroyView() { + super.onDestroyView() + activity?.findViewById(R.id.bottom_navigation)?.visibility = View.VISIBLE + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/commons/BaseFormViewModel.kt b/app/src/main/java/org/piramalswasthya/cho/ui/commons/BaseFormViewModel.kt new file mode 100644 index 000000000..bf80f2b14 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/commons/BaseFormViewModel.kt @@ -0,0 +1,118 @@ +package org.piramalswasthya.cho.ui.commons + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.piramalswasthya.cho.configuration.Dataset +import org.piramalswasthya.cho.model.PatientDisplay +import org.piramalswasthya.cho.repositories.PatientRepo +import org.piramalswasthya.cho.repositories.UserRepo +import timber.log.Timber + +/** + * Base ViewModel that holds UI-state boilerplate shared by all single-page + * assessment/diagnosis form screens (State enum, showAlert, form load/save + * state, beneficiary header LiveData). + * + * Subclasses should use the protected backing fields directly and call + * [clearAlert] through the public API exposed here. + */ +abstract class BaseFormViewModel : ViewModel() { + + // ── Save / Load state ──────────────────────────────────────────────────── + + enum class State { + IDLE, SAVING, SAVE_SUCCESS, SAVE_FAILED + } + + protected val _state = MutableLiveData(State.IDLE) + val state: LiveData get() = _state + + // ── Alert message ──────────────────────────────────────────────────────── + + protected val _showAlert = MutableLiveData() + val showAlert: LiveData get() = _showAlert + + fun clearAlert() { + _showAlert.value = null + } + + /** Resets [state] back to [State.IDLE]. Call after consuming [State.SAVE_SUCCESS]. */ + fun resetState() { + _state.value = State.IDLE + } + + // ── Beneficiary header info ────────────────────────────────────────────── + + protected val _benName = MutableLiveData() + val benName: LiveData get() = _benName + + protected val _benAgeGender = MutableLiveData() + val benAgeGender: LiveData get() = _benAgeGender + + // ── Shared init helper ─────────────────────────────────────────────────── + + /** + * Loads the logged-in user and the patient for [patientID], populates + * [benName] / [benAgeGender], and returns the [PatientDisplay]. + * Returns `null` (and logs an error) if either is missing. + */ + protected suspend fun loadPatientDetails( + userRepo: UserRepo, + patientRepo: PatientRepo, + patientID: String? + ): PatientDisplay? { + val user = userRepo.getLoggedInUser() + if (user == null) { + Timber.e("No logged in user found") + return null + } + + val patient = patientID?.let { patientRepo.getPatientDisplay(it) } + if (patient == null) { + Timber.e("Patient not found for ID: $patientID") + return null + } + + _benName.value = "${patient.patient.firstName} ${patient.patient.lastName ?: ""}" + _benAgeGender.value = + "${patient.patient.age} ${patient.ageUnit?.name} | ${patient.gender?.genderName}" + + return patient + } + + + // ── Shared coroutine helpers ───────────────────────────────────────────── + + /** + * Launches a coroutine that calls [dataset].updateList and logs any error. + */ + protected fun launchUpdateList(dataset: Dataset, formId: Int, index: Int, errorTag: String) { + viewModelScope.launch { + try { + dataset.updateList(formId, index) + } catch (e: Exception) { + Timber.e(e, errorTag) + } + } + } + + /** + * Launches a save coroutine: sets SAVING state, runs [block], then + * posts SAVE_SUCCESS or SAVE_FAILED. + */ + protected fun launchSave(errorTag: String, block: suspend () -> Unit) { + viewModelScope.launch { + try { + _state.postValue(State.SAVING) + block() + _state.postValue(State.SAVE_SUCCESS) + } catch (e: Exception) { + Timber.e(e, errorTag) + _state.postValue(State.SAVE_FAILED) + } + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/commons/CphcFormNavigation.kt b/app/src/main/java/org/piramalswasthya/cho/ui/commons/CphcFormNavigation.kt new file mode 100644 index 000000000..0a5339c20 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/commons/CphcFormNavigation.kt @@ -0,0 +1,43 @@ +package org.piramalswasthya.cho.ui.commons + +import android.os.Bundle +import org.piramalswasthya.cho.model.MasterDb +import org.piramalswasthya.cho.model.VisitMasterDb + +object CphcFormNavigation { + + const val OTHER_CPHC_CATEGORY = "Other CPHC Services" + + fun buildVitalsBundle( + arguments: Bundle?, + subCategory: String, + reasonForVisit: String? = null, + category: String = OTHER_CPHC_CATEGORY, + requireMasterDb: Boolean = false, + ): Bundle { + val masterDb = (arguments?.getSerializable("MasterDb") as? MasterDb) + ?: if (requireMasterDb) { + error("MasterDb is required but was not provided") + } else { + MasterDb( + patientId = arguments?.getString("patientID").orEmpty(), + visitMasterDb = VisitMasterDb(), + ) + } + + masterDb.visitMasterDb?.apply { + this.category = category + this.subCategory = subCategory + this.reason = reasonForVisit + ?.takeIf { it.isNotBlank() } + ?: arguments?.getString("reasonForVisit")?.takeIf { it.isNotBlank() } + ?: this.reason?.takeIf { it.isNotBlank() } + ?: subCategory + } + + return Bundle().apply { + putSerializable("MasterDb", masterDb) + arguments?.getInt("benVisitNo", -1)?.takeIf { it > 0 }?.let { putInt("benVisitNo", it) } + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/commons/CphcFormTypeResolver.kt b/app/src/main/java/org/piramalswasthya/cho/ui/commons/CphcFormTypeResolver.kt new file mode 100644 index 000000000..7ede47975 --- /dev/null +++ b/app/src/main/java/org/piramalswasthya/cho/ui/commons/CphcFormTypeResolver.kt @@ -0,0 +1,51 @@ +package org.piramalswasthya.cho.ui.commons + +enum class CphcFormType { + EAR, + NOSE, + THROAT, + ORAL, + OPHTHALMIC, + ELDERLY, + MENTAL, + PAIN, + PSYCHOSOCIAL, + UNKNOWN, +} + +object CphcFormTypeResolver { + + fun resolve(reasonForVisit: String?, subCategory: String?): CphcFormType { + val key = (reasonForVisit?.takeIf { it.isNotBlank() } ?: subCategory) + ?.trim() + ?.lowercase() + .orEmpty() + if (key.isEmpty()) return CphcFormType.UNKNOWN + + return when (key) { + DropdownConst.ear.lowercase() -> CphcFormType.EAR + DropdownConst.nose.lowercase() -> CphcFormType.NOSE + DropdownConst.throat.lowercase() -> CphcFormType.THROAT + DropdownConst.dental.lowercase() -> CphcFormType.ORAL + DropdownConst.elderlyHealthAssessment.lowercase(), + DropdownConst.functionalDeclineOrDependency.lowercase() -> CphcFormType.ELDERLY + DropdownConst.persistentPain.lowercase(), + DropdownConst.distressingSymptoms.lowercase() -> CphcFormType.PAIN + DropdownConst.psychosocialCaregiverSupport.lowercase(), + DropdownConst.caregiverSupportCounselling.lowercase() -> CphcFormType.PSYCHOSOCIAL + DropdownConst.mentalHealth.lowercase(), + DropdownConst.mentalHealthScreening.lowercase(), + DropdownConst.emotionalBehaviouralConcerns.lowercase(), + DropdownConst.substanceUseConcerns.lowercase(), + DropdownConst.selfHarmSuicideThoughts.lowercase(), + DropdownConst.memoryLossConfusion.lowercase(), + DropdownConst.seizuresFitsLoc.lowercase() -> CphcFormType.MENTAL + DropdownConst.screening.lowercase(), + DropdownConst.REASON_SYMPTOMATIC.lowercase(), + DropdownConst.REASON_FIRST_AID_EYE_INJURY.lowercase(), + DropdownConst.REASON_FIRST_AID_INJURY_TRAUMA.lowercase(), + DropdownConst.ophthalmic.lowercase() -> CphcFormType.OPHTHALMIC + else -> CphcFormType.UNKNOWN + } + } +} diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/commons/DropdownConst.kt b/app/src/main/java/org/piramalswasthya/cho/ui/commons/DropdownConst.kt index 33c1f0f60..61674d008 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/commons/DropdownConst.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/commons/DropdownConst.kt @@ -4,21 +4,197 @@ class DropdownConst { companion object { val careAndPreg: String = "Care in Pregnancy & Childbirth" + val pwr: String = "Pregnant Women Registration" + val pregnancyRegistration: String = "Pregnancy Registration" val anc: String = "ANC" val pnc: String = "PNC" + val deliveryOutcome: String = "Delivery Outcome" val fpAndOtherRep: String = "Family Planning, Contraceptives Services & other Reproductive Health Care Services" val fpAndCs: String = "Eligible couple tracking" + val ncd: String = "Non-Communicable Diseases (NCD)" val ncdScreening: String = "NCD screening" val neonatalAndInfant: String = "Neonatal & Infant Health" val immunization: String = "Immunization Services" + val ophthalmic: String = "Ophthalmic" + val screening: String = "Screening" + val ent: String = "ENT" + val oral: String = "Oral" + val ear: String = "Ear (E)" + val nose: String = "Nose (N)" + val throat: String = "Throat (T)" + val dental: String = "Dental" + val entReasons: List = listOf(ear, nose, throat) + val oralReasons: List = listOf(dental) + val oralChiefComplaints: Set = setOf( + "Dental Decay", + "Gum diseases", + "Irregular arrangement of teeth and jaws", + "Abnormal growth, patch or ulcers", + "Cleft lip/ palate", + "Dental Fluorosis", + "Dental Emergencies" + ) + val entChiefComplaints: Set = setOf( + "Neck swelling", + "Dysphagia", + "Cleft lip", + "Cleft palate", + "Tonsillitis", + "Pharyngitis", + "Laryngitis", + "Sinusitis", + "Difficulty in hearing", + "Ear wax", + "Congenital Ear Malformation", + "Foreign body in ear", + "open mouth breathing", + "Nosebleed", + "Foreign body in nose" + ) + + val elderlyAndPalliative: String = "Elderly & Palliative" + val persistentPain: String = "Persistent pain" + val caregiverSupportCounselling: String = "Caregiver support / counselling" + val psychosocialCaregiverSupport: String = caregiverSupportCounselling + val functionalDeclineOrDependency: String = "Functional decline or dependency" + val distressingSymptoms: String = "Distressing symptoms" + val mentalHealth: String = "Mental Health" + val mentalHealthScreening: String = "Mental Health Screening" + val elderlyHealthAssessment: String = "General geriatric complaints" + + val emotionalBehaviouralConcerns: String = "Emotional or behavioural concerns" + val substanceUseConcerns: String = "Substance use related concerns" + val selfHarmSuicideThoughts: String = "Thoughts of self-harm or suicide" + val memoryLossConfusion: String = "Memory loss or confusion" + val seizuresFitsLoc: String = "Seizures / fits or loss of consciousness" + + val mentalHealthReasons: List = listOf( + emotionalBehaviouralConcerns, + substanceUseConcerns, + selfHarmSuicideThoughts, + memoryLossConfusion, + seizuresFitsLoc + ) + + val elderlyAndPalliativeReasons: List = listOf( + elderlyHealthAssessment, + functionalDeclineOrDependency, + persistentPain, + distressingSymptoms, + caregiverSupportCounselling + ) + + /** Sub categories available for all genders and age groups. */ + val cphcSubCategoriesAllAges: List = listOf(ent, ophthalmic, oral) + + val visualAcuityList = listOf("6/6", "6/9", "6/12", "6/18", "6/24", "6/36", "6/60", "<6/60") + val visualImpairmentList = listOf("6/18", "6/24", "6/36", "6/60", "<6/60") + val nearVisualAcuityList = listOf("N6", "N8", "N10", "N12") + val nearVAReducedList = listOf("N8", "N10", "N12") + + const val CHART_SNELLENS = "Snellen's distance chart" + const val CHART_NEAR_VISION = "Near vision chart" + val visualAcuityChartList = listOf(CHART_SNELLENS, CHART_NEAR_VISION) + + const val REASON_SYMPTOMATIC = "Symptomatic" + const val REASON_FIRST_AID_INJURY_TRAUMA = "First aid for eye injury, trauma" + const val REASON_FIRST_AID_EYE_INJURY = REASON_FIRST_AID_INJURY_TRAUMA + val ophthalmicReasonForVisitList = listOf( + screening, + REASON_SYMPTOMATIC, + REASON_FIRST_AID_INJURY_TRAUMA + ) + + val reasonForVisitBySubCategory: Map> = mapOf( + ncd to listOf(ncdScreening), + ncdScreening to listOf(ncdScreening), + ophthalmic to ophthalmicReasonForVisitList, + ent to entReasons, + oral to oralReasons, + elderlyAndPalliative to elderlyAndPalliativeReasons, + mentalHealth to mentalHealthReasons, + ) + + const val CONDITION_CATARACT = "Cataract" + const val CONDITION_GLAUCOMA = "Glaucoma" + const val CONDITION_DIABETIC_RETINOPATHY = "Diabetic retinopathy" + const val CONDITION_PRESBYOPIA = "Presbyopia" + const val CONDITION_TRACHOMA = "Trachoma" + const val CONDITION_CORNEAL_DISEASE = "Corneal disease" + const val CONDITION_CONJUNCTIVITIS = "Conjunctivitis/Acute red eye" + const val CONDITION_DRY_EYE = "Dry eye / xerophthalmia" + const val CONDITION_DRY_EYE_ALT = "Dry eye/ xerophthalmia" + const val CONDITION_EYE_ALLERGY = "Eye allergy" + + val caseIdConditionsList = listOf( + CONDITION_CATARACT, + CONDITION_GLAUCOMA, + CONDITION_DIABETIC_RETINOPATHY, + CONDITION_PRESBYOPIA, + CONDITION_TRACHOMA, + CONDITION_CORNEAL_DISEASE, + CONDITION_CONJUNCTIVITIS, + CONDITION_DRY_EYE, + CONDITION_EYE_ALLERGY + ) + + const val CONDITION_EYE_INJURY_BLUNT_PENETRATING = + "Eye injuries from blunt trauma, penetrating injury to eye," + const val CONDITION_EYE_INJURY_BLUNT_PENETRATING_ALT = + "Eye injuries from blunt trauma, penetrating injury to eye" + const val CONDITION_CHEMICAL_EXPOSURE = "Chemical exposure (acid/ alkali/other)," + const val CONDITION_CHEMICAL_EXPOSURE_ALT = "Chemical exposure (acid/ alkali/other)" + const val CONDITION_FOREIGN_BODY_EYE = "Foreign body lodged in the eye" + + val ophthalmicChiefComplaints: Set = setOf( + CONDITION_DIABETIC_RETINOPATHY, + CONDITION_GLAUCOMA, + CONDITION_CATARACT, + CONDITION_PRESBYOPIA, + CONDITION_TRACHOMA, + CONDITION_CORNEAL_DISEASE, + CONDITION_CONJUNCTIVITIS, + CONDITION_DRY_EYE, + CONDITION_DRY_EYE_ALT, + CONDITION_EYE_ALLERGY, + CONDITION_EYE_INJURY_BLUNT_PENETRATING, + CONDITION_EYE_INJURY_BLUNT_PENETRATING_ALT, + CONDITION_CHEMICAL_EXPOSURE, + CONDITION_CHEMICAL_EXPOSURE_ALT, + CONDITION_FOREIGN_BODY_EYE + ) + + const val INJURY_MECHANICAL_FOREIGN_BODY = "Mechanical foreign body" + const val INJURY_BLUNT_TRAUMA = "Blunt trauma" + const val INJURY_PENETRATING = "Penetrating injury suspected" + const val INJURY_CHEMICAL = "Chemical (acid/alkali/other)" + val injuryTypeList = listOf( + INJURY_MECHANICAL_FOREIGN_BODY, + INJURY_BLUNT_TRAUMA, + INJURY_PENETRATING, + INJURY_CHEMICAL + ) + + const val FOREIGN_BODY_NOT_ATTEMPTED = "Not attempted" + const val FOREIGN_BODY_ATTEMPTED_CONJUNCTIVAL_SAC = "Attempted from conjunctival sac" + const val FOREIGN_BODY_LODGED_IN_CORNEA = "Foreign body lodged in cornea" + val foreignBodyRemovalOptions = listOf( + FOREIGN_BODY_NOT_ATTEMPTED, + FOREIGN_BODY_ATTEMPTED_CONJUNCTIVAL_SAC, + FOREIGN_BODY_LODGED_IN_CORNEA + ) + + const val TRACHOMA_SUSPECTED_ACTIVE = "Suspected active trachoma" + const val TRACHOMA_SUSPECTED_TT = "Suspected TT/TI" + const val TRACHOMA_NONE = "No trachoma" + val trachomaStatusList = listOf(TRACHOMA_SUSPECTED_ACTIVE, TRACHOMA_SUSPECTED_TT, TRACHOMA_NONE) - val male_ncd: List = listOf(ncdScreening) - val female_1_to_59: List = listOf(careAndPreg, fpAndOtherRep) - val female_15_to_18: List = listOf(careAndPreg, fpAndOtherRep, immunization) - val female_ncd: List = listOf(careAndPreg, fpAndOtherRep, ncdScreening) - val age_0_to_1: List = listOf(neonatalAndInfant) + const val CORNEAL_OPACITY = "Corneal opacity" + const val CORNEAL_ULCER = "Corneal ulcer suspected" + const val CORNEAL_OTHER = "Other corneal pathology" + val cornealDiseaseTypeList = listOf(CORNEAL_OPACITY, CORNEAL_ULCER, CORNEAL_OTHER) val consciousnessList = mutableListOf("Conscious", "Semi Conscious", "Unconscious") val dangerSignList = mutableListOf("Fast Breathing", "Chest Indrawing", "Stridor", "Grunt", "Respiratory Distress", "Cold and Calm Peripheral Pulses", "Convulsions", "Hypothermia", "Delirium", "Drowsy", "Uncontrolled Bleeding", "Hematemesis", "Refusal of Fits") @@ -78,4 +254,4 @@ class DropdownConst { val mutualVisitUnitsVal = mutableListOf("Hour(s)", "Day(s)", "Week(s)", "Month(s)", "Year(s)") } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/commons/OtherCPHCServicesViewModel.kt b/app/src/main/java/org/piramalswasthya/cho/ui/commons/OtherCPHCServicesViewModel.kt index ed041a7ab..c6c96dd57 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/commons/OtherCPHCServicesViewModel.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/commons/OtherCPHCServicesViewModel.kt @@ -11,6 +11,7 @@ import org.piramalswasthya.cho.model.ChiefComplaintDB import org.piramalswasthya.cho.model.PatientVisitInfoSync import org.piramalswasthya.cho.model.PatientVitalsModel import org.piramalswasthya.cho.model.VisitDB +import org.piramalswasthya.cho.repositories.CphcDetailsRepository import org.piramalswasthya.cho.repositories.PatientVisitInfoSyncRepo import org.piramalswasthya.cho.repositories.VisitReasonsAndCategoriesRepo import org.piramalswasthya.cho.repositories.VitalsRepo @@ -22,6 +23,7 @@ class OtherCPHCServicesViewModel @Inject constructor( private val visitReasonsAndCategoriesRepo: VisitReasonsAndCategoriesRepo, private val vitalsRepo: VitalsRepo, private val patientVisitInfoSyncRepo: PatientVisitInfoSyncRepo, + private val cphcDetailsRepo: CphcDetailsRepository, ) : ViewModel() { @@ -38,7 +40,12 @@ class OtherCPHCServicesViewModel @Inject constructor( ){ viewModelScope.launch { try { - saveVisitDbToCatche(visitDB) + val benVisitNo = visitDB.benVisitNo ?: patientVisitInfoSync.benVisitNo + cphcDetailsRepo.replaceVisitDb( + visitDB = visitDB, + patientID = visitDB.patientID, + benVisitNo = benVisitNo, + ) savePatientVitalInfoToCache(patientVitals) savePatientVisitInfoSync(patientVisitInfoSync) _isDataSaved.value = true diff --git a/app/src/main/java/org/piramalswasthya/cho/ui/commons/case_record/CaseRecordCustom.kt b/app/src/main/java/org/piramalswasthya/cho/ui/commons/case_record/CaseRecordCustom.kt index 3dac016d3..d5a859a83 100644 --- a/app/src/main/java/org/piramalswasthya/cho/ui/commons/case_record/CaseRecordCustom.kt +++ b/app/src/main/java/org/piramalswasthya/cho/ui/commons/case_record/CaseRecordCustom.kt @@ -2,11 +2,19 @@ package org.piramalswasthya.cho.ui.commons.case_record import android.app.AlertDialog -import android.content.Intent +import android.graphics.Typeface import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned import android.text.Editable import android.text.InputType +import android.text.TextUtils import android.text.TextWatcher +import android.text.style.StyleSpan +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.util.TypedValue +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -14,6 +22,8 @@ import android.view.inputmethod.InputMethodManager import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView import android.widget.TableRow import android.widget.TextView import android.widget.Toast @@ -26,11 +36,15 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager +import android.text.method.LinkMovementMethod +import android.view.Gravity import com.google.android.material.card.MaterialCardView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.piramalswasthya.cho.R import org.piramalswasthya.cho.adapter.CHOCaseRecordItemAdapter import org.piramalswasthya.cho.adapter.ChiefComplaintMultiAdapter @@ -57,11 +71,15 @@ import org.piramalswasthya.cho.model.PrescriptionTemplateDB import org.piramalswasthya.cho.model.PrescriptionValues import org.piramalswasthya.cho.model.PrescriptionValuesForTemplate import org.piramalswasthya.cho.model.ProceduresMasterData +import org.piramalswasthya.cho.model.ReferralFollowUpFields +import org.piramalswasthya.cho.model.ReferralFollowUpModel import org.piramalswasthya.cho.model.UserDomain import org.piramalswasthya.cho.model.VisitDB import org.piramalswasthya.cho.model.VitalsMasterDb import org.piramalswasthya.cho.repositories.PrescriptionTemplateRepo import org.piramalswasthya.cho.repositories.UserRepo +import org.piramalswasthya.cho.ui.commons.CphcFormType +import org.piramalswasthya.cho.ui.commons.CphcFormTypeResolver import org.piramalswasthya.cho.ui.commons.DropdownConst.Companion.frequencyMap import org.piramalswasthya.cho.ui.commons.DropdownConst.Companion.instructionDropdownList import org.piramalswasthya.cho.ui.commons.DropdownConst.Companion.medicalReferDropdownVal @@ -69,9 +87,9 @@ import org.piramalswasthya.cho.ui.commons.DropdownConst.Companion.medicationFreq import org.piramalswasthya.cho.ui.commons.DropdownConst.Companion.tabletDosageList import org.piramalswasthya.cho.ui.commons.DropdownConst.Companion.unitVal import org.piramalswasthya.cho.ui.commons.NavigationAdapter -import org.piramalswasthya.cho.ui.home_activity.HomeActivity import org.piramalswasthya.cho.utils.Constants.pattern import org.piramalswasthya.cho.utils.HelperUtil +import org.piramalswasthya.cho.utils.HelperUtil.disableDropdownField import org.piramalswasthya.cho.utils.HelperUtil.disableTextInputLayout import org.piramalswasthya.cho.utils.generateIntFromUuid import org.piramalswasthya.cho.utils.generateUuid @@ -86,6 +104,10 @@ import javax.inject.Inject @AndroidEntryPoint class CaseRecordCustom : Fragment(R.layout.case_record_custom_layout), NavigationAdapter { + companion object { + private const val DEFAULT_DURATION_UNIT = "Day(s)" + } + private var _binding: CaseRecordCustomLayoutBinding? = null private val binding: CaseRecordCustomLayoutBinding get() = _binding!! @@ -143,6 +165,11 @@ class CaseRecordCustom : Fragment(R.layout.case_record_custom_layout), Navigatio var isAddTemplateClicked = false private val benFlowMap = mutableMapOf() private var benFlowListCache: List = emptyList() + private var patientVisitNosCache: List = emptyList() + private var effectivePharmacistFlagForVisibility: Int? = null + private var isAlreadyFilledReadOnlyForVisibility: Boolean = false + private var dispensedLockedPrescriptionCount: Int = 0 + private var isFreshCaseEntryFromVisitDetails: Boolean = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -154,6 +181,95 @@ class CaseRecordCustom : Fragment(R.layout.case_record_custom_layout), Navigatio return binding.root } + private fun setCaseEditorVisibility(isVisible: Boolean) { + val visibility = if (isVisible) View.VISIBLE else View.GONE + binding.plusButtonD.visibility = visibility + binding.plusButtonP.visibility = visibility + binding.useTempForFields.visibility = visibility + binding.tvAddTemplateTitle.visibility = visibility + binding.tempName.visibility = visibility + binding.saveTemplate.visibility = visibility + binding.deleteTemp.visibility = visibility + binding.externalI.visibility = visibility + binding.testName.visibility = visibility + binding.referReason.visibility = visibility + binding.referDropdown.visibility = visibility + binding.textReferHeading.visibility = visibility + } + + private fun hideReferSummaryLabels() { + binding.referDateLabel.visibility = View.GONE + binding.referToLabel.visibility = View.GONE + binding.referalReasonLabel.visibility = View.GONE + } + + private fun applyEditableCaseUi(btnSubmit: Button?, btnCancel: Button?, submitTextRes: Int) { + btnSubmit?.visibility = View.VISIBLE + btnSubmit?.text = getString(submitTextRes) + btnCancel?.visibility = View.VISIBLE + btnCancel?.text = getString(R.string.close) + setCaseEditorVisibility(true) + } + + private fun applyReadOnlyCaseUi(btnSubmit: Button?, btnCancel: Button?) { + btnSubmit?.visibility = View.GONE + btnCancel?.visibility = View.VISIBLE + btnCancel?.text = getString(R.string.close) + setCaseEditorVisibility(false) + hideReferSummaryLabels() + } + + private fun isDoctorWorkflowRole(): Boolean { + return preferenceDao.isDoctorSelected() || + (preferenceDao.isUserCHO() && preferenceDao.isNurseSelected()) || + preferenceDao.isRegistrarSelected() + } + + private fun isDoctorExistingVisitFlow(): Boolean { + return (isDoctorWorkflowRole() && !isFreshCaseEntryFromVisitDetails) || viewRecordFragment == true + } + + private fun resolveBenVisitInfo(): PatientDisplayWithVisitInfo { + val fromArguments = arguments?.getSerializable("benVisitInfo") as? PatientDisplayWithVisitInfo + if (fromArguments != null) return fromArguments + return requireNotNull( + requireActivity().intent?.getSerializableExtra("benVisitInfo") as? PatientDisplayWithVisitInfo + ) { + "benVisitInfo is required (fragment arguments or activity intent)" + } + } + + private fun isOtherCphcCategory(category: String?): Boolean { + val c = category?.trim().orEmpty() + return c.equals(getString(R.string.other_cphc_category_key), ignoreCase = true) + } + + private fun updateShowCphcDetailsButtonVisibility(visitInfo: PatientDisplayWithVisitInfo?) { + val visitCategory = visitInfo?.visitCategory + val masterDbCategory = + (arguments?.getSerializable("MasterDb") as? MasterDb)?.visitMasterDb?.category + + // Fast local check first + if (isOtherCphcCategory(visitCategory) || isOtherCphcCategory(masterDbCategory)) { + binding.btnShowCphcDetails.visibility = View.VISIBLE + return + } + + // Doctor-tab fallback: fetch visit category from Visit_DB for this patient+visit. + val patientID = visitInfo?.patient?.patientID + val benVisitNo = visitInfo?.benVisitNo + if (patientID.isNullOrBlank() || benVisitNo == null || benVisitNo <= 0) { + binding.btnShowCphcDetails.visibility = View.GONE + return + } + + lifecycleScope.launch { + val dbCategory = viewModel.getVisitCategoryByPatientAndVisit(patientID, benVisitNo) + binding.btnShowCphcDetails.visibility = + if (isOtherCphcCategory(dbCategory)) View.VISIBLE else View.GONE + } + } + private val onBackPressedCallback by lazy { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -168,6 +284,8 @@ class CaseRecordCustom : Fragment(R.layout.case_record_custom_layout), Navigatio familyM = binding.testName selectF = binding.selectF referDropdown = binding.referDropdownText + resetTestNameFieldToDefault(readOnly = false) + binding.btnShowCphcDetails.setOnClickListener { showPreviousCphcDetailsPopup() } binding.tvAddTemplateTitle.setOnClickListener { @@ -192,13 +310,40 @@ class CaseRecordCustom : Fragment(R.layout.case_record_custom_layout), Navigatio viewRecordFragment = arguments?.getBoolean("viewRecord") isFlowComplete = arguments?.getBoolean("isFlowComplete") isFollowupVisit = arguments?.getBoolean("isFollowupVisit") + var isClosedViewOnly = (viewRecordFragment == true && isFlowComplete == true) + isFreshCaseEntryFromVisitDetails = + // CHO-role and Register-role enter the fresh-case-entry flow. + viewRecordFragment != true && + (arguments?.getSerializable("MasterDb") as? MasterDb) != null + + masterDb = arguments?.getSerializable("MasterDb") as? MasterDb + benVisitInfo = resolveBenVisitInfo() + + // Use DB value for pharmacist_flag in edit path so completed (lab+pharmacist) cases get correct UI even if intent was stale + var effectivePharmacistFlag: Int? = null if (viewRecordFragment == true) { + updateShowCphcDetailsButtonVisibility(benVisitInfo) + // Pending pharmacist cycle is not a closed case, even if stale extras say flowComplete=true. + if ((benVisitInfo.pharmacist_flag ?: 0) == 1) { + isClosedViewOnly = false + } + // Lab reviewed + medicine dispensed (doctorFlag=3, pharmacist_flag=9) is editable review cycle. + if (benVisitInfo.nurseFlag == 9 && + benVisitInfo.doctorFlag == 3 && + (benVisitInfo.pharmacist_flag ?: 0) == 9 && + isDoctorWorkflowRole() + ) { + isClosedViewOnly = false + } + effectivePharmacistFlag = benVisitInfo.pharmacist_flag + effectivePharmacistFlagForVisibility = effectivePharmacistFlag viewModel.getFormMaster() val btnSubmit = activity?.findViewById