diff --git a/app/build.gradle b/app/build.gradle index 18f94621d..9d68806af 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,7 +5,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.5.1' + classpath 'com.android.tools.build:gradle:8.13.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20" classpath "org.jetbrains.kotlin:kotlin-android-extensions:1.9.20" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31d6a312a..5a671bc2a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,9 @@ - + @@ -34,9 +36,9 @@ android:supportsRtl="true" android:theme="@style/AppTheme" android:usesCleartextTraffic="true" + tools:ignore="ExtraText" tools:replace="android:allowBackup,android:label" - tools:targetApi="31" - tools:ignore="ExtraText"> + tools:targetApi="31"> @@ -75,6 +77,9 @@ + @@ -93,6 +98,7 @@ + @@ -161,7 +167,8 @@ - + @@ -228,7 +238,7 @@ android:name="preloaded_fonts" android:resource="@array/preloaded_fonts" /> - + \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/adapter/PatientListAdapter.kt b/app/src/main/java/deakin/gopher/guardian/adapter/PatientListAdapter.kt index 297137b43..fe023914e 100644 --- a/app/src/main/java/deakin/gopher/guardian/adapter/PatientListAdapter.kt +++ b/app/src/main/java/deakin/gopher/guardian/adapter/PatientListAdapter.kt @@ -16,7 +16,9 @@ class PatientListAdapter( private var patients: List, private val onPatientClick: ((Patient) -> Unit)? = null, private val onAssignNurseClick: ((Patient) -> Unit)? = null, + private val onEditClick: ((Patient) -> Unit)? = null, private val onDeleteClick: ((Patient) -> Unit)? = null, + private val showMoreMenu: Boolean = true ) : RecyclerView.Adapter() { inner class PatientViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val nameText: TextView = itemView.findViewById(R.id.tvName) @@ -61,6 +63,8 @@ class PatientListAdapter( onPatientClick?.invoke(patient) } + holder.moreIcon.visibility = if (showMoreMenu) View.VISIBLE else View.GONE + holder.moreIcon.setOnClickListener { val popupMenu = PopupMenu(holder.itemView.context, it) popupMenu.inflate(R.menu.menu_patient_item) @@ -71,6 +75,10 @@ class PatientListAdapter( onAssignNurseClick?.invoke(patient) true } + R.id.action_edit_patient -> { + onEditClick?.invoke(patient) + true + } R.id.action_delete -> { // Handle delete click onDeleteClick?.invoke(patient) true diff --git a/app/src/main/java/deakin/gopher/guardian/adapter/PrescriptionAdapter.kt b/app/src/main/java/deakin/gopher/guardian/adapter/PrescriptionAdapter.kt new file mode 100644 index 000000000..8fc389b2e --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/adapter/PrescriptionAdapter.kt @@ -0,0 +1,73 @@ +package deakin.gopher.guardian.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import deakin.gopher.guardian.databinding.ItemPrescriptionBinding +import deakin.gopher.guardian.model.Prescription +import java.util.Locale + +class PrescriptionAdapter( + private var prescriptions: List, + private val showEditButton: Boolean, + private val onEditClick: (Prescription) -> Unit +) : RecyclerView.Adapter() { + + inner class PrescriptionViewHolder( + private val binding: ItemPrescriptionBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(prescription: Prescription) { + val firstItem = prescription.items.firstOrNull() + + binding.tvPrescriptionTitle.text = firstItem?.name ?: "Prescription" + + binding.tvPrescriptionItems.text = if (prescription.items.isNotEmpty()) { + prescription.items.joinToString("\n") { item -> + "• ${item.name} - ${item.dose}, ${item.frequency}, for ${item.durationDays} days" + } + } else { + "No medicine details available" + } + + binding.tvPrescriptionStatus.text = "Status: ${ + prescription.status?.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } ?: "Unknown" + }" + + binding.tvPrescriptionDate.text = "Created: ${ + prescription.createdAt?.substringBefore("T") ?: "N/A" + }" + + binding.btnEditPrescription.visibility = if (showEditButton) { + View.VISIBLE + } else { + View.GONE + } + + binding.btnEditPrescription.setOnClickListener { + onEditClick(prescription) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PrescriptionViewHolder { + val binding = ItemPrescriptionBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return PrescriptionViewHolder(binding) + } + + override fun onBindViewHolder(holder: PrescriptionViewHolder, position: Int) { + holder.bind(prescriptions[position]) + } + + override fun getItemCount(): Int = prescriptions.size + + fun updateData(newPrescriptions: List) { + prescriptions = newPrescriptions + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/model/Patient.kt b/app/src/main/java/deakin/gopher/guardian/model/Patient.kt index 968538510..6b65b5821 100644 --- a/app/src/main/java/deakin/gopher/guardian/model/Patient.kt +++ b/app/src/main/java/deakin/gopher/guardian/model/Patient.kt @@ -45,6 +45,11 @@ private fun calculateAge( } } +data class AssignNurseRequest( + @SerializedName("nurseId") val nurseId: String, + @SerializedName("patientId") val patientId: String, +) + data class AddPatientResponse( @SerializedName("patient") val patient: Patient, ) : BaseModel() @@ -59,3 +64,26 @@ data class PatientActivity( data class AddPatientActivityResponse( @SerializedName("activity") val activity: PatientActivity, ) : BaseModel() + +data class UpdatePatientRequest( + @SerializedName("fullName") val fullName: String, + @SerializedName("dateOfBirth") val dateOfBirth: String, + @SerializedName("gender") val gender: String, +) + +data class PatientListResponse( + @SerializedName("page") + val page: Int = 1, + + @SerializedName("limit") + val limit: Int = 50, + + @SerializedName("total") + val total: Int = 0, + + @SerializedName("totalPages") + val totalPages: Int = 1, + + @SerializedName("patients") + val patients: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/model/Prescription.kt b/app/src/main/java/deakin/gopher/guardian/model/Prescription.kt new file mode 100644 index 000000000..a3aa45168 --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/model/Prescription.kt @@ -0,0 +1,64 @@ +package deakin.gopher.guardian.model + +import com.google.gson.annotations.SerializedName +import java.io.Serializable + +data class PrescriptionListResponse( + @SerializedName("prescriptions") val prescriptions: List = emptyList() +) + +data class Prescription( + @SerializedName("_id") val id: String, + + @SerializedName("patient") val patientId: String? = null, + + @SerializedName("prescriber") val prescriber: PrescriptionPrescriber? = null, + + @SerializedName("items") val items: List = emptyList(), + + @SerializedName("status") val status: String? = null, + + @SerializedName("createdAt") val createdAt: String? = null, + + @SerializedName("updatedAt") val updatedAt: String? = null +) : Serializable + +data class PrescriptionPrescriber( + @SerializedName("_id") val id: String? = null, + + @SerializedName("fullName") val fullName: String? = null, + + @SerializedName("email") val email: String? = null +) : Serializable + +data class PrescriptionItem( + @SerializedName("name") val name: String, + + @SerializedName("dose") val dose: String, + + @SerializedName("frequency") val frequency: String, + + @SerializedName("durationDays") val durationDays: Int +) : Serializable + +data class CreatePrescriptionRequest( + @SerializedName("patientId") val patientId: String, + + @SerializedName("items") val items: List +) + +data class UpdatePrescriptionRequest( + @SerializedName("patientId") val patientId: String? = null, + + @SerializedName("items") val items: List +) + +data class PrescriptionItemRequest( + @SerializedName("name") val name: String, + + @SerializedName("dose") val dose: String, + + @SerializedName("frequency") val frequency: String, + + @SerializedName("durationDays") val durationDays: Int +) \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/model/register/User.kt b/app/src/main/java/deakin/gopher/guardian/model/register/User.kt index c906ee66e..930cc7620 100644 --- a/app/src/main/java/deakin/gopher/guardian/model/register/User.kt +++ b/app/src/main/java/deakin/gopher/guardian/model/register/User.kt @@ -6,19 +6,50 @@ import deakin.gopher.guardian.model.login.Role import java.io.Serializable data class User( - @SerializedName("id") val id: String, - @SerializedName("email") val email: String, - @SerializedName("fullname") val name: String, - @SerializedName("role") val roleName: String, - @SerializedName("photoUrl") val photoUrl: String, + @SerializedName(value = "id", alternate = ["_id"]) val id: String = "", + + @SerializedName(value = "fullname", alternate = ["fullName"]) val name: String = "", + + @SerializedName("email") val email: String = "", + + @SerializedName("role") val roleName: String? = null, + + @SerializedName("photoUrl") val photoUrl: String? = null, + @SerializedName("organization") val organization: String? = null, ) : Serializable { val role: Role - get() { - return Role.create(roleName) - } + get() = Role.create(roleName ?: "") } +data class NurseListResponse( + @SerializedName("nurses") val nurses: List, +) + +data class NurseListItem( + @SerializedName("_id") val id: String, + @SerializedName("fullname") val fullName: String?, + @SerializedName("email") val email: String?, + @SerializedName("photoUrl") val photoUrl: String? = null, + @SerializedName("role") val role: NurseRole?, +) { + fun toUser(): User { + return User( + id = id, + email = email.orEmpty(), + name = fullName.orEmpty(), + roleName = role?.name.orEmpty(), + photoUrl = photoUrl.orEmpty(), + organization = null, + ) + } +} + +data class NurseRole( + @SerializedName("_id") val id: String, + @SerializedName("name") val name: String, +) + data class RegisterRequest( @SerializedName("email") val email: String, @SerializedName("password") val password: String, @@ -29,4 +60,4 @@ data class RegisterRequest( data class AuthResponse( @SerializedName("user") val user: User, @SerializedName("token") val token: String, -) : BaseModel() +) : BaseModel() \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/services/api/ApiClient.kt b/app/src/main/java/deakin/gopher/guardian/services/api/ApiClient.kt index b98828cc9..ec0c258a4 100644 --- a/app/src/main/java/deakin/gopher/guardian/services/api/ApiClient.kt +++ b/app/src/main/java/deakin/gopher/guardian/services/api/ApiClient.kt @@ -7,18 +7,18 @@ import retrofit2.converter.gson.GsonConverterFactory object RetrofitClient { // private const val BASE_URL = "http://10.0.2.2:3000/api/v1/" + private const val BASE_URL = "https://guardian-backend-ashen.vercel.app/api/v1/" +// private const val BASE_URL = "https://guardian-backend-git-fix-cors-patelrudra2306-5873s-projects.vercel.app/api/v1/" + private val client = OkHttpClient() private val interceptor = HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY) private val clientBuilder = client.newBuilder().addInterceptor(interceptor) val retrofit: Retrofit by lazy { - Retrofit.Builder() - .baseUrl(BASE_URL) - .addConverterFactory(GsonConverterFactory.create()) - .client(clientBuilder.build()) - .build() + Retrofit.Builder().baseUrl(BASE_URL).addConverterFactory(GsonConverterFactory.create()) + .client(clientBuilder.build()).build() } } diff --git a/app/src/main/java/deakin/gopher/guardian/services/api/ApiService.kt b/app/src/main/java/deakin/gopher/guardian/services/api/ApiService.kt index 0e9ea177e..d59f0292a 100644 --- a/app/src/main/java/deakin/gopher/guardian/services/api/ApiService.kt +++ b/app/src/main/java/deakin/gopher/guardian/services/api/ApiService.kt @@ -1,12 +1,16 @@ package deakin.gopher.guardian.services.api + import deakin.gopher.guardian.model.AddPatientActivityResponse import deakin.gopher.guardian.model.AddPatientResponse +import deakin.gopher.guardian.model.AssignNurseRequest +import deakin.gopher.guardian.model.UpdatePatientRequest import deakin.gopher.guardian.model.BaseModel import deakin.gopher.guardian.model.Patient import deakin.gopher.guardian.model.PatientActivity import deakin.gopher.guardian.model.register.AuthResponse import deakin.gopher.guardian.model.register.RegisterRequest +import deakin.gopher.guardian.model.register.NurseListResponse import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.Call @@ -19,9 +23,15 @@ import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query +import deakin.gopher.guardian.model.CreatePrescriptionRequest +import deakin.gopher.guardian.model.PrescriptionListResponse +import deakin.gopher.guardian.model.UpdatePrescriptionRequest +import retrofit2.http.PATCH +import deakin.gopher.guardian.model.PatientListResponse interface ApiService { @POST("auth/register") @@ -70,6 +80,38 @@ interface ApiService { @Part photo: MultipartBody.Part?, ): Response + @GET("nurse/all") + suspend fun getAllNurses( + @Header("Authorization") token: String, + @Query("q") query: String? = null, + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 50, + ): Response + + @PUT("patients/{patientId}") + suspend fun updatePatient( + @Header("Authorization") token: String, + @Path("patientId") patientId: String, + @Body request: UpdatePatientRequest, + ): Response + + @Multipart + @PUT("patients/{patientId}") + suspend fun updatePatientWithPhoto( + @Header("Authorization") token: String, + @Path("patientId") patientId: String, + @Part("fullName") fullName: RequestBody, + @Part("dateOfBirth") dateOfBirth: RequestBody, + @Part("gender") gender: RequestBody, + @Part photo: MultipartBody.Part, + ): Response + + @POST("patients/assign-nurse") + suspend fun assignNurseToPatient( + @Header("Authorization") token: String, + @Body request: AssignNurseRequest, + ): Response + @FormUrlEncoded @POST("patients/entryreport") suspend fun logPatientActivity( @@ -91,4 +133,33 @@ interface ApiService { @Header("Authorization") token: String, @Path("id") patientId: String, ): Response -} + + @GET("patients/{patientId}/prescriptions") + suspend fun getPatientPrescriptions( + @Header("Authorization") token: String, + @Path("patientId") patientId: String, + @Query("status") status: String? = null, + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 10 + ): Response + + @POST("prescriptions") + suspend fun createPrescription( + @Header("Authorization") token: String, @Body request: CreatePrescriptionRequest + ): Response + + @PATCH("prescriptions/{id}") + suspend fun updatePrescription( + @Header("Authorization") token: String, + @Path("id") prescriptionId: String, + @Body request: UpdatePrescriptionRequest + ): Response + + @GET("patients") + suspend fun getAllPatientsForDoctor( + @Header("Authorization") token: String, + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 50 + ): Response + +} \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/AddEditPrescriptionActivity.kt b/app/src/main/java/deakin/gopher/guardian/view/general/AddEditPrescriptionActivity.kt new file mode 100644 index 000000000..9373d502d --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/view/general/AddEditPrescriptionActivity.kt @@ -0,0 +1,264 @@ +package deakin.gopher.guardian.view.general + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.widget.Toast +import com.google.gson.Gson +import deakin.gopher.guardian.databinding.ActivityAddEditPrescriptionBinding +import deakin.gopher.guardian.model.ApiErrorResponse +import deakin.gopher.guardian.model.CreatePrescriptionRequest +import deakin.gopher.guardian.model.Prescription +import deakin.gopher.guardian.model.PrescriptionItemRequest +import deakin.gopher.guardian.model.UpdatePrescriptionRequest +import deakin.gopher.guardian.model.login.SessionManager +import deakin.gopher.guardian.services.api.ApiClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import retrofit2.Response +import deakin.gopher.guardian.model.login.Role + + +class AddEditPrescriptionActivity : BaseActivity() { + + private lateinit var binding: ActivityAddEditPrescriptionBinding + + private var patientId: String = "" + private var patientName: String = "" + private var existingPrescription: Prescription? = null + + private val isEditMode: Boolean + get() = existingPrescription != null + + companion object { + const val EXTRA_PATIENT_ID = "extra_patient_id" + const val EXTRA_PATIENT_NAME = "extra_patient_name" + const val EXTRA_PRESCRIPTION = "extra_prescription" + } + + @SuppressLint("SetTextI18n") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityAddEditPrescriptionBinding.inflate(layoutInflater) + setContentView(binding.root) + + val currentUser = SessionManager.getCurrentUser() + + if (currentUser.role != Role.Doctor) { + Toast.makeText( + this, "Only doctor can add or edit prescription", Toast.LENGTH_SHORT + ).show() + finish() + return + } + + patientId = intent.getStringExtra(EXTRA_PATIENT_ID).orEmpty() + patientName = intent.getStringExtra(EXTRA_PATIENT_NAME).orEmpty() + + @Suppress("DEPRECATION") existingPrescription = + intent.getSerializableExtra(EXTRA_PRESCRIPTION) as? Prescription + + if (patientId.isBlank()) { + showMessage("Patient ID missing") + finish() + return + } + + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + binding.toolbar.title = if (isEditMode) { + "Edit Prescription" + } else { + "Add Prescription" + } + + binding.tvPatientName.text = if (patientName.isNotBlank()) { + "Patient: $patientName" + } else { + "Patient ID: $patientId" + } + + binding.btnSavePrescription.text = if (isEditMode) { + "Update Prescription" + } else { + "Create Prescription" + } + + prefillPrescription() + + binding.btnSavePrescription.setOnClickListener { + validateAndSubmit() + } + } + + private fun prefillPrescription() { + val firstItem = existingPrescription?.items?.firstOrNull() ?: return + + binding.etMedicineName.setText(firstItem.name) + binding.etDose.setText(firstItem.dose) + binding.etFrequency.setText(firstItem.frequency) + binding.etDurationDays.setText(firstItem.durationDays.toString()) + } + + private fun validateAndSubmit() { + clearErrors() + + val medicineName = binding.etMedicineName.text.toString().trim() + val dose = binding.etDose.text.toString().trim() + val frequency = binding.etFrequency.text.toString().trim() + val durationText = binding.etDurationDays.text.toString().trim() + + var hasError = false + + if (medicineName.isBlank()) { + binding.tilMedicineName.error = "Medicine name is required" + hasError = true + } + + if (dose.isBlank()) { + binding.tilDose.error = "Dose is required" + hasError = true + } + + if (frequency.isBlank()) { + binding.tilFrequency.error = "Frequency is required" + hasError = true + } + + val durationDays = durationText.toIntOrNull() + if (durationText.isBlank()) { + binding.tilDurationDays.error = "Duration is required" + hasError = true + } else if (durationDays == null || durationDays <= 0) { + binding.tilDurationDays.error = "Enter a valid number of days" + hasError = true + } + + if (hasError || durationDays == null) { + return + } + + val item = PrescriptionItemRequest( + name = medicineName, dose = dose, frequency = frequency, durationDays = durationDays + ) + + if (isEditMode) { + updatePrescription(item) + } else { + createPrescription(item) + } + } + + private fun createPrescription(item: PrescriptionItemRequest) { + val token = "Bearer ${SessionManager.getToken()}" + + val request = CreatePrescriptionRequest( + patientId = patientId, items = listOf(item) + ) + + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { + setLoading(true) + } + + val response = try { + ApiClient.apiService.createPrescription(token, request) + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + setLoading(false) + + if (response?.isSuccessful == true) { + showMessage("Prescription created successfully") + finish() + } else { + showMessage(getErrorMessage(response, "Failed to create prescription")) + } + } + } + } + + private fun updatePrescription(item: PrescriptionItemRequest) { + val prescriptionId = existingPrescription?.id + + if (prescriptionId.isNullOrBlank()) { + showMessage("Prescription ID missing") + return + } + + val token = "Bearer ${SessionManager.getToken()}" + + val request = UpdatePrescriptionRequest( + patientId = patientId, items = listOf(item) + ) + + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { + setLoading(true) + } + + val response = try { + ApiClient.apiService.updatePrescription(token, prescriptionId, request) + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + setLoading(false) + + if (response?.isSuccessful == true) { + showMessage("Prescription updated successfully") + finish() + } else { + showMessage(getErrorMessage(response, "Failed to update prescription")) + } + } + } + } + + private fun clearErrors() { + binding.tilMedicineName.error = null + binding.tilDose.error = null + binding.tilFrequency.error = null + binding.tilDurationDays.error = null + } + + private fun setLoading(isLoading: Boolean) { + binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE + + binding.btnSavePrescription.isEnabled = !isLoading + binding.etMedicineName.isEnabled = !isLoading + binding.etDose.isEnabled = !isLoading + binding.etFrequency.isEnabled = !isLoading + binding.etDurationDays.isEnabled = !isLoading + } + + private fun getErrorMessage(response: Response<*>?, fallbackMessage: String): String { + val rawErrorBody = try { + response?.errorBody()?.string() + } catch (e: Exception) { + null + } + + val errorResponse = try { + Gson().fromJson(rawErrorBody, ApiErrorResponse::class.java) + } catch (e: Exception) { + null + } + + return errorResponse?.apiError?.takeIf { it.isNotBlank() } + ?: rawErrorBody?.takeIf { it.isNotBlank() } ?: response?.message() + ?.takeIf { it.isNotBlank() } ?: fallbackMessage + } + + private fun showMessage(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/AddNewPatientActivity.kt b/app/src/main/java/deakin/gopher/guardian/view/general/AddNewPatientActivity.kt index df96346ac..df89ecf20 100644 --- a/app/src/main/java/deakin/gopher/guardian/view/general/AddNewPatientActivity.kt +++ b/app/src/main/java/deakin/gopher/guardian/view/general/AddNewPatientActivity.kt @@ -34,6 +34,8 @@ import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream import java.util.Calendar +import androidx.core.widget.doAfterTextChanged +import com.google.android.material.textfield.TextInputLayout import java.util.Locale class AddNewPatientActivity : BaseActivity() { @@ -44,6 +46,8 @@ class AddNewPatientActivity : BaseActivity() { companion object { private const val CAMERA_PERMISSION_CODE = 1001 + private const val MAX_ALLOWED_AGE = 120 + private val NAME_REGEX = Regex("^[A-Za-z][A-Za-z .'-]{1,49}$") } // Launcher for picking image from gallery @@ -69,7 +73,9 @@ class AddNewPatientActivity : BaseActivity() { val localBinding = binding when (localBinding) { is ActivityAddNewPatientBinding -> localBinding.imgPreview.setImageBitmap(bitmap) - is ActivityAddNewPatientNurseBinding -> localBinding.imgPreview.setImageBitmap(bitmap) + is ActivityAddNewPatientNurseBinding -> localBinding.imgPreview.setImageBitmap( + bitmap + ) } } } @@ -98,6 +104,7 @@ class AddNewPatientActivity : BaseActivity() { } setupUI(localBinding) } + is ActivityAddNewPatientNurseBinding -> { setSupportActionBar(localBinding.toolbar) localBinding.toolbar.setNavigationOnClickListener { @@ -114,13 +121,16 @@ class AddNewPatientActivity : BaseActivity() { is ActivityAddNewPatientBinding -> { setupGenderSpinner(localBinding.genderSpinner) setupDOBPicker(localBinding.txtDob) + setupValidationListeners(localBinding.txtName, localBinding.txtDob) localBinding.btnSelectFromGallery.setOnClickListener { openGallery() } localBinding.btnTakePhoto.setOnClickListener { checkCameraPermissionAndOpen() } localBinding.btnSave.setOnClickListener { savePatientInfo() } } + is ActivityAddNewPatientNurseBinding -> { setupGenderSpinner(localBinding.genderSpinner) setupDOBPicker(localBinding.txtDob) + setupValidationListeners(localBinding.txtName, localBinding.txtDob) localBinding.btnSelectFromGallery.setOnClickListener { openGallery() } localBinding.btnTakePhoto.setOnClickListener { checkCameraPermissionAndOpen() } localBinding.btnSave.setOnClickListener { savePatientInfo() } @@ -142,53 +152,53 @@ class AddNewPatientActivity : BaseActivity() { val month = calendar.get(Calendar.MONTH) val day = calendar.get(Calendar.DAY_OF_MONTH) - val datePickerDialog = - DatePickerDialog( - this, - Theme_Holo_Light_Dialog, - { _, selectedYear, selectedMonth, selectedDay -> - val formattedDate = - String.format( - Locale.getDefault(), - "%04d-%02d-%02d", - selectedYear, - selectedMonth + 1, - selectedDay, - ) - txtDob.setText(formattedDate) - }, - year, - month, - day, - ) + val datePickerDialog = DatePickerDialog( + this, + Theme_Holo_Light_Dialog, + { _, selectedYear, selectedMonth, selectedDay -> + val formattedDate = String.format( + Locale.getDefault(), + "%04d-%02d-%02d", + selectedYear, + selectedMonth + 1, + selectedDay, + ) + txtDob.setText(formattedDate) + updateAgeField(selectedYear, selectedMonth, selectedDay) + }, + year, + month, + day, + ) // Force spinner mode (for API 21+) try { - val datePickerField = datePickerDialog.datePicker.javaClass.getDeclaredField("mDelegate") + val datePickerField = + datePickerDialog.datePicker.javaClass.getDeclaredField("mDelegate") datePickerField.isAccessible = true val delegate = datePickerField.get(datePickerDialog.datePicker) val spinnerDelegateClass = Class.forName("android.widget.DatePickerSpinnerDelegate") if (delegate.javaClass != spinnerDelegateClass) { - datePickerField.set(datePickerDialog.datePicker, null) // Clear the current delegate - - val constructor = - datePickerDialog.datePicker.javaClass.getDeclaredConstructor( - Context::class.java, - android.util.AttributeSet::class.java, - Int::class.javaPrimitiveType, - Int::class.javaPrimitiveType, - ) + datePickerField.set( + datePickerDialog.datePicker, null + ) // Clear the current delegate + + val constructor = datePickerDialog.datePicker.javaClass.getDeclaredConstructor( + Context::class.java, + android.util.AttributeSet::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) constructor.isAccessible = true - val spinnerDelegate = - constructor.newInstance( - datePickerDialog.datePicker.context, - null, - android.R.attr.datePickerStyle, - 0, - ) + val spinnerDelegate = constructor.newInstance( + datePickerDialog.datePicker.context, + null, + android.R.attr.datePickerStyle, + 0, + ) datePickerField.set(datePickerDialog.datePicker, spinnerDelegate) // Re-initialize the date picker with current date @@ -205,8 +215,10 @@ class AddNewPatientActivity : BaseActivity() { // Permission check before opening camera private fun checkCameraPermissionAndOpen() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED ) { openCamera() } else { @@ -246,37 +258,37 @@ class AddNewPatientActivity : BaseActivity() { val localBinding = binding - val fullname = - when (localBinding) { - is ActivityAddNewPatientBinding -> localBinding.txtName.text.toString().trim() - is ActivityAddNewPatientNurseBinding -> localBinding.txtName.text.toString().trim() - else -> "" - } + val fullname = when (localBinding) { + is ActivityAddNewPatientBinding -> localBinding.txtName.text.toString().trim() + is ActivityAddNewPatientNurseBinding -> localBinding.txtName.text.toString().trim() + else -> "" + } - val dob = - when (localBinding) { - is ActivityAddNewPatientBinding -> localBinding.txtDob.text.toString().trim() - is ActivityAddNewPatientNurseBinding -> localBinding.txtDob.text.toString().trim() - else -> "" - } + val dob = when (localBinding) { + is ActivityAddNewPatientBinding -> localBinding.txtDob.text.toString().trim() + is ActivityAddNewPatientNurseBinding -> localBinding.txtDob.text.toString().trim() + else -> "" + } - val gender = - when (localBinding) { - is ActivityAddNewPatientBinding -> localBinding.genderSpinner.selectedItem?.toString()?.lowercase() ?: "" - is ActivityAddNewPatientNurseBinding -> localBinding.genderSpinner.selectedItem?.toString()?.lowercase() ?: "" - else -> "" - } + val gender = when (localBinding) { + is ActivityAddNewPatientBinding -> localBinding.genderSpinner.selectedItem?.toString() + ?.lowercase() ?: "" + + is ActivityAddNewPatientNurseBinding -> localBinding.genderSpinner.selectedItem?.toString() + ?.lowercase() ?: "" + + else -> "" + } val namePart = fullname.toRequestBody("text/plain".toMediaTypeOrNull()) val dobPart = dob.toRequestBody("text/plain".toMediaTypeOrNull()) val genderPart = gender.toRequestBody("text/plain".toMediaTypeOrNull()) - val photoPart: MultipartBody.Part? = - when { - selectedPhotoUri != null -> prepareFilePart("photo", selectedPhotoUri!!, this) - capturedPhotoBitmap != null -> prepareBitmapPart("photo", capturedPhotoBitmap!!) - else -> null - } + val photoPart: MultipartBody.Part? = when { + selectedPhotoUri != null -> prepareFilePart("photo", selectedPhotoUri!!, this) + capturedPhotoBitmap != null -> prepareBitmapPart("photo", capturedPhotoBitmap!!) + else -> null + } val token = "Bearer ${SessionManager.getToken()}" @@ -287,6 +299,7 @@ class AddNewPatientActivity : BaseActivity() { localBinding.progressBar.show() localBinding.btnSave.visibility = View.GONE } + is ActivityAddNewPatientNurseBinding -> { localBinding.progressBar.show() localBinding.btnSave.visibility = View.GONE @@ -294,7 +307,8 @@ class AddNewPatientActivity : BaseActivity() { } } - val response = ApiClient.apiService.addPatient(token, namePart, dobPart, genderPart, photoPart) + val response = + ApiClient.apiService.addPatient(token, namePart, dobPart, genderPart, photoPart) withContext(Dispatchers.Main) { when (localBinding) { @@ -302,6 +316,7 @@ class AddNewPatientActivity : BaseActivity() { localBinding.progressBar.hide() localBinding.btnSave.visibility = View.VISIBLE } + is ActivityAddNewPatientNurseBinding -> { localBinding.progressBar.hide() localBinding.btnSave.visibility = View.VISIBLE @@ -316,26 +331,55 @@ class AddNewPatientActivity : BaseActivity() { showMessage(response.body()?.apiError ?: "Failed to add patient") } } else { - val errorResponse = - Gson().fromJson( - response.errorBody()?.string(), - ApiErrorResponse::class.java, - ) - showMessage(errorResponse.apiError ?: response.message()) + val errorBody = response.errorBody()?.string() + val errorResponse = try { + Gson().fromJson(errorBody, ApiErrorResponse::class.java) + } catch (e: Exception) { + null + } + showMessage(errorResponse?.apiError ?: response.message()) } } } } + private fun setupValidationListeners( + nameEditText: android.widget.EditText, + dobEditText: android.widget.EditText, + ) { + nameEditText.doAfterTextChanged { + findViewById(R.id.nameInputLayout)?.error = null + } + + dobEditText.doAfterTextChanged { + findViewById(R.id.dobInputLayout)?.error = null + } + } + private fun validateInputs(): Boolean { + clearErrors() + val localBinding = binding return when (localBinding) { is ActivityAddNewPatientBinding -> { - if (localBinding.txtName.text.toString().trim().isEmpty()) { - showMessage(getString(R.string.validation_empty_name)) + val name = + localBinding.txtName.text.toString().trim().replace("\\s+".toRegex(), " ") + val dobText = localBinding.txtDob.text.toString().trim() + + if (name.isEmpty()) { + setNameError(getString(R.string.validation_empty_name)) + false + } else if (name.length < 2) { + setNameError("Name must be at least 2 characters") false - } else if (localBinding.txtDob.text.toString().trim().isEmpty()) { - showMessage(getString(R.string.validation_empty_dob)) + } else if (!NAME_REGEX.matches(name)) { + setNameError("Name can contain only letters, spaces, apostrophes, dots or hyphens") + false + } else if (dobText.isEmpty()) { + setDobError(getString(R.string.validation_empty_dob)) + false + } else if (!isValidDob(dobText)) { + setDobError("Please select a valid date of birth") false } else if (localBinding.genderSpinner.selectedItemPosition == 0) { showMessage(getString(R.string.validation_empty_gender)) @@ -344,12 +388,26 @@ class AddNewPatientActivity : BaseActivity() { true } } + is ActivityAddNewPatientNurseBinding -> { - if (localBinding.txtName.text.toString().trim().isEmpty()) { - showMessage(getString(R.string.validation_empty_name)) + val name = + localBinding.txtName.text.toString().trim().replace("\\s+".toRegex(), " ") + val dobText = localBinding.txtDob.text.toString().trim() + + if (name.isEmpty()) { + setNameError(getString(R.string.validation_empty_name)) + false + } else if (name.length < 2) { + setNameError("Name must be at least 2 characters") + false + } else if (!NAME_REGEX.matches(name)) { + setNameError("Name can contain only letters, spaces, apostrophes, dots or hyphens") + false + } else if (dobText.isEmpty()) { + setDobError(getString(R.string.validation_empty_dob)) false - } else if (localBinding.txtDob.text.toString().trim().isEmpty()) { - showMessage(getString(R.string.validation_empty_dob)) + } else if (!isValidDob(dobText)) { + setDobError("Please select a valid date of birth") false } else if (localBinding.genderSpinner.selectedItemPosition == 0) { showMessage(getString(R.string.validation_empty_gender)) @@ -358,10 +416,55 @@ class AddNewPatientActivity : BaseActivity() { true } } + else -> false } } + private fun clearErrors() { + findViewById(R.id.nameInputLayout)?.error = null + findViewById(R.id.dobInputLayout)?.error = null + } + + private fun setNameError(message: String) { + findViewById(R.id.nameInputLayout)?.error = message + + when (val localBinding = binding) { + is ActivityAddNewPatientBinding -> localBinding.txtName.requestFocus() + is ActivityAddNewPatientNurseBinding -> localBinding.txtName.requestFocus() + } + } + + private fun setDobError(message: String) { + findViewById(R.id.dobInputLayout)?.error = message + + when (val localBinding = binding) { + is ActivityAddNewPatientBinding -> localBinding.txtDob.requestFocus() + is ActivityAddNewPatientNurseBinding -> localBinding.txtDob.requestFocus() + } + } + + private fun isValidDob(dobText: String): Boolean { + val dobParts = dobText.split("-") + if (dobParts.size != 3) return false + + val year = dobParts[0].toIntOrNull() ?: return false + val month = dobParts[1].toIntOrNull()?.minus(1) ?: return false + val day = dobParts[2].toIntOrNull() ?: return false + + val age = calculateAge(year, month, day) + return age in 0..MAX_ALLOWED_AGE + } + + private fun updateAgeField(year: Int, month: Int, day: Int) { + val age = calculateAge(year, month, day).coerceAtLeast(0) + + when (val localBinding = binding) { + is ActivityAddNewPatientBinding -> localBinding.txtAge.setText(age.toString()) + is ActivityAddNewPatientNurseBinding -> localBinding.txtAge.setText(age.toString()) + } + } + private fun showMessage(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/AssignNurseActivity.kt b/app/src/main/java/deakin/gopher/guardian/view/general/AssignNurseActivity.kt new file mode 100644 index 000000000..49c0b7c38 --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/view/general/AssignNurseActivity.kt @@ -0,0 +1,203 @@ +package deakin.gopher.guardian.view.general + +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.gson.Gson +import deakin.gopher.guardian.R +import deakin.gopher.guardian.adapter.NurseListAdapter +import deakin.gopher.guardian.databinding.ActivityAssignNurseBinding +import deakin.gopher.guardian.model.ApiErrorResponse +import deakin.gopher.guardian.model.login.Role +import deakin.gopher.guardian.model.login.SessionManager +import deakin.gopher.guardian.model.AssignNurseRequest +import deakin.gopher.guardian.model.register.User +import deakin.gopher.guardian.services.api.ApiClient +import deakin.gopher.guardian.view.hide +import deakin.gopher.guardian.view.show +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import deakin.gopher.guardian.model.register.NurseListItem +import deakin.gopher.guardian.model.register.NurseListResponse +import kotlinx.coroutines.withContext + +class AssignNurseActivity : BaseActivity() { + private lateinit var binding: ActivityAssignNurseBinding + private lateinit var nurseListAdapter: NurseListAdapter + + private var patientId: String = "" + private var patientName: String = "" + + private val currentUser = SessionManager.getCurrentUser() + + companion object { + const val EXTRA_PATIENT_ID = "patientId" + const val EXTRA_PATIENT_NAME = "patientName" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityAssignNurseBinding.inflate(layoutInflater) + setContentView(binding.root) + + patientId = intent.getStringExtra(EXTRA_PATIENT_ID).orEmpty() + patientName = intent.getStringExtra(EXTRA_PATIENT_NAME).orEmpty() + + if (patientId.isBlank()) { + showMessage("Patient information is missing") + finish() + return + } + + setupToolbar() + setupRecyclerView() + setupUi() + fetchNurses() + } + + private fun setupToolbar() { + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + if (currentUser.role == Role.Nurse) { + binding.toolbar.setBackgroundColor(getColor(R.color.TG_blue)) + } + } + + private fun setupUi() { + binding.tvSelectedPatient.text = if (patientName.isNotBlank()) { + "Assign a nurse to $patientName" + } else { + "Assign a nurse" + } + } + + private fun setupRecyclerView() { + nurseListAdapter = NurseListAdapter(emptyList()) { nurse -> + showAssignConfirmation(nurse) + } + + binding.recyclerViewNurses.layoutManager = LinearLayoutManager(this) + binding.recyclerViewNurses.adapter = nurseListAdapter + } + + private fun fetchNurses() { + val token = "Bearer ${SessionManager.getToken()}" + + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { + binding.progressBar.show() + } + + val response = try { + ApiClient.apiService.getAllNurses( + token = token, + page = 1, + limit = 50, + ) + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + binding.progressBar.hide() + + if (response?.isSuccessful == true) { + val nurses = response.body()?.nurses.orEmpty().map { it.toUser() } + .filter { it.role == Role.Nurse } + + if (nurses.isNotEmpty()) { + nurseListAdapter.updateData(nurses) + binding.tvEmptyMessage.visibility = android.view.View.GONE + } else { + binding.tvEmptyMessage.visibility = android.view.View.VISIBLE + } + } else { + val rawErrorBody = try { + response?.errorBody()?.string() + } catch (e: Exception) { + null + } + + val errorResponse = try { + Gson().fromJson(rawErrorBody, ApiErrorResponse::class.java) + } catch (e: Exception) { + null + } + + val errorMessage = errorResponse?.apiError?.takeIf { it.isNotBlank() } + ?: rawErrorBody?.takeIf { it.isNotBlank() } ?: response?.message() + ?.takeIf { it.isNotBlank() } ?: "Failed to load nurses" + + showMessage(errorMessage) + } + } + } + } + + private fun showAssignConfirmation(nurse: User) { + AlertDialog.Builder(this).setTitle("Assign Nurse") + .setMessage("Assign ${nurse.name} to ${patientName.ifBlank { "this patient" }}?") + .setPositiveButton("Assign") { _, _ -> + assignNurseToPatient(nurse) + }.setNegativeButton("Cancel", null).show() + } + + private fun assignNurseToPatient(nurse: User) { + val token = "Bearer ${SessionManager.getToken()}" + + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { + binding.progressBar.show() + } + + val response = try { + ApiClient.apiService.assignNurseToPatient( + token = token, + request = AssignNurseRequest( + nurseId = nurse.id, + patientId = patientId, + ), + ) + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + binding.progressBar.hide() + + if (response?.isSuccessful == true) { + showMessage("Nurse assigned successfully") + setResult(RESULT_OK) + finish() + } else { + val rawErrorBody = try { + response?.errorBody()?.string() + } catch (e: Exception) { + null + } + + val errorResponse = try { + Gson().fromJson(rawErrorBody, ApiErrorResponse::class.java) + } catch (e: Exception) { + null + } + + val errorMessage = errorResponse?.apiError?.takeIf { it.isNotBlank() } + ?: rawErrorBody?.takeIf { it.isNotBlank() } ?: response?.message() + ?.takeIf { it.isNotBlank() } ?: "Failed to assign nurse" + + showMessage(errorMessage) + } + } + } + } + + private fun showMessage(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/EditPatientActivity.kt b/app/src/main/java/deakin/gopher/guardian/view/general/EditPatientActivity.kt new file mode 100644 index 000000000..f8920f7f5 --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/view/general/EditPatientActivity.kt @@ -0,0 +1,415 @@ +package deakin.gopher.guardian.view.general + +import android.Manifest +import android.R.style.Theme_Holo_Light_Dialog +import android.app.DatePickerDialog +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.widget.doAfterTextChanged +import com.bumptech.glide.Glide +import com.google.android.material.textfield.TextInputLayout +import com.google.gson.Gson +import deakin.gopher.guardian.R +import deakin.gopher.guardian.databinding.ActivityEditPatientBinding +import deakin.gopher.guardian.model.ApiErrorResponse +import deakin.gopher.guardian.model.Patient +import deakin.gopher.guardian.model.UpdatePatientRequest +import deakin.gopher.guardian.model.login.Role +import deakin.gopher.guardian.model.login.SessionManager +import deakin.gopher.guardian.services.api.ApiClient +import deakin.gopher.guardian.view.hide +import deakin.gopher.guardian.view.show +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.util.Calendar +import java.util.Locale + +class EditPatientActivity : BaseActivity() { + private lateinit var binding: ActivityEditPatientBinding + private lateinit var patient: Patient + + private var selectedPhotoUri: Uri? = null + private var capturedPhotoBitmap: Bitmap? = null + + companion object { + const val EXTRA_PATIENT = "patient" + private const val CAMERA_PERMISSION_CODE = 1002 + private const val MAX_ALLOWED_AGE = 120 + private val NAME_REGEX = Regex("^[A-Za-z][A-Za-z .'-]{1,49}$") + } + + private val galleryLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri != null) { + selectedPhotoUri = uri + capturedPhotoBitmap = null + binding.imgPreview.setImageURI(uri) + } + } + + private val cameraLauncher = + registerForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap: Bitmap? -> + if (bitmap != null) { + capturedPhotoBitmap = bitmap + selectedPhotoUri = null + binding.imgPreview.setImageBitmap(bitmap) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityEditPatientBinding.inflate(layoutInflater) + setContentView(binding.root) + + patient = intent.getSerializableExtra(EXTRA_PATIENT) as? Patient ?: run { + showMessage("Patient data missing") + finish() + return + } + + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + if (SessionManager.getCurrentUser().role == Role.Nurse) { + binding.toolbar.setBackgroundColor(getColor(R.color.TG_blue)) + } + + setupGenderSpinner() + setupDOBPicker() + setupValidationListeners() + populatePatientData() + + binding.btnSelectFromGallery.setOnClickListener { openGallery() } + binding.btnTakePhoto.setOnClickListener { checkCameraPermissionAndOpen() } + binding.btnSave.setOnClickListener { updatePatientInfo() } + } + + private fun setupGenderSpinner() { + val genderOptions = listOf("Select gender", "Male", "Female", "Other") + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, genderOptions) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.genderSpinner.adapter = adapter + } + + private fun populatePatientData() { + binding.txtName.setText(patient.fullname) + binding.txtDob.setText(patient.dateOfBirth.substringBefore("T")) + binding.txtAge.setText(patient.age.toString()) + + when (patient.gender.lowercase()) { + "male" -> binding.genderSpinner.setSelection(1) + "female" -> binding.genderSpinner.setSelection(2) + "other" -> binding.genderSpinner.setSelection(3) + else -> binding.genderSpinner.setSelection(0) + } + + Glide.with(this).load(patient.photoUrl).placeholder(R.drawable.profile).circleCrop() + .into(binding.imgPreview) + } + + private fun setupDOBPicker() { + binding.txtDob.setOnClickListener { + val calendar = Calendar.getInstance() + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + + val datePickerDialog = DatePickerDialog( + this, + Theme_Holo_Light_Dialog, + { _, selectedYear, selectedMonth, selectedDay -> + val formattedDate = String.format( + Locale.getDefault(), + "%04d-%02d-%02d", + selectedYear, + selectedMonth + 1, + selectedDay, + ) + binding.txtDob.setText(formattedDate) + updateAgeField(selectedYear, selectedMonth, selectedDay) + }, + year, + month, + day, + ) + + datePickerDialog.datePicker.maxDate = System.currentTimeMillis() + datePickerDialog.show() + } + } + + private fun setupValidationListeners() { + binding.txtName.doAfterTextChanged { + binding.nameInputLayout.error = null + } + binding.txtDob.doAfterTextChanged { + binding.dobInputLayout.error = null + } + } + + private fun checkCameraPermissionAndOpen() { + if (ContextCompat.checkSelfPermission( + this, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + openCamera() + } else { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.CAMERA), + CAMERA_PERMISSION_CODE, + ) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == CAMERA_PERMISSION_CODE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + openCamera() + } else { + showMessage("Camera permission is required to take photos") + } + } + } + + private fun openGallery() { + galleryLauncher.launch("image/*") + } + + private fun openCamera() { + cameraLauncher.launch(null) + } + + private fun updatePatientInfo() { + if (!validateInputs()) return + + val fullName = binding.txtName.text.toString().trim().replace("\\s+".toRegex(), " ") + val dob = binding.txtDob.text.toString().trim() + val gender = binding.genderSpinner.selectedItem?.toString()?.trim()?.lowercase() ?: "" + + if (!hasChanges(fullName, dob, gender)) { + showMessage("No changes to update") + return + } + + val token = "Bearer ${SessionManager.getToken()}" + + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { + binding.progressBar.show() + binding.btnSave.visibility = android.view.View.GONE + } + + val response = try { + val photoPart = when { + selectedPhotoUri != null -> prepareFilePart( + "photo", selectedPhotoUri!!, this@EditPatientActivity + ) + + capturedPhotoBitmap != null -> prepareBitmapPart("photo", capturedPhotoBitmap!!) + else -> null + } + + if (photoPart != null) { + val fullNamePart = fullName.toRequestBody("text/plain".toMediaTypeOrNull()) + val dobPart = dob.toRequestBody("text/plain".toMediaTypeOrNull()) + val genderPart = gender.toRequestBody("text/plain".toMediaTypeOrNull()) + + ApiClient.apiService.updatePatientWithPhoto( + token = token, + patientId = patient.id, + fullName = fullNamePart, + dateOfBirth = dobPart, + gender = genderPart, + photo = photoPart, + ) + } else { + ApiClient.apiService.updatePatient( + token = token, + patientId = patient.id, + request = UpdatePatientRequest( + fullName = fullName, + dateOfBirth = dob, + gender = gender, + ), + ) + } + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + binding.progressBar.hide() + binding.btnSave.visibility = android.view.View.VISIBLE + + if (response?.isSuccessful == true) { + showMessage(response.body()?.apiMessage ?: "Patient updated successfully") + finish() + } else { + val rawErrorBody = try { + response?.errorBody()?.string() + } catch (e: Exception) { + null + } + + val errorResponse = try { + Gson().fromJson(rawErrorBody, ApiErrorResponse::class.java) + } catch (e: Exception) { + null + } + + val errorMessage = errorResponse?.apiError?.takeIf { it.isNotBlank() } + ?: rawErrorBody?.takeIf { it.isNotBlank() } ?: response?.message() + ?.takeIf { it.isNotBlank() } ?: "Failed to update patient" + + showMessage(errorMessage) + } + } + } + } + + private fun validateInputs(): Boolean { + clearErrors() + + val name = binding.txtName.text.toString().trim().replace("\\s+".toRegex(), " ") + val dobText = binding.txtDob.text.toString().trim() + + return when { + name.isEmpty() -> { + setNameError(getString(R.string.validation_empty_name)) + false + } + + name.length < 2 -> { + setNameError("Name must be at least 2 characters") + false + } + + !NAME_REGEX.matches(name) -> { + setNameError("Name can contain only letters, spaces, apostrophes, dots or hyphens") + false + } + + dobText.isEmpty() -> { + setDobError(getString(R.string.validation_empty_dob)) + false + } + + !isValidDob(dobText) -> { + setDobError("Please select a valid date of birth") + false + } + + binding.genderSpinner.selectedItemPosition == 0 -> { + showMessage(getString(R.string.validation_empty_gender)) + false + } + + else -> true + } + } + + private fun clearErrors() { + binding.nameInputLayout.error = null + binding.dobInputLayout.error = null + } + + private fun setNameError(message: String) { + binding.nameInputLayout.error = message + binding.txtName.requestFocus() + } + + private fun setDobError(message: String) { + binding.dobInputLayout.error = message + binding.txtDob.requestFocus() + } + + private fun isValidDob(dobText: String): Boolean { + val dobParts = dobText.split("-") + if (dobParts.size != 3) return false + + val year = dobParts[0].toIntOrNull() ?: return false + val month = dobParts[1].toIntOrNull()?.minus(1) ?: return false + val day = dobParts[2].toIntOrNull() ?: return false + + val age = calculateAge(year, month, day) + return age in 0..MAX_ALLOWED_AGE + } + + private fun updateAgeField(year: Int, month: Int, day: Int) { + val age = calculateAge(year, month, day).coerceAtLeast(0) + binding.txtAge.setText(age.toString()) + } + + private fun calculateAge(year: Int, month: Int, day: Int): Int { + val today = Calendar.getInstance() + val birthDate = Calendar.getInstance() + birthDate.set(year, month, day) + var age = today.get(Calendar.YEAR) - birthDate.get(Calendar.YEAR) + + if (today.get(Calendar.DAY_OF_YEAR) < birthDate.get(Calendar.DAY_OF_YEAR)) { + age-- + } + return age + } + + private fun prepareFilePart( + partName: String, + fileUri: Uri, + context: Context, + ): MultipartBody.Part? { + val inputStream = context.contentResolver.openInputStream(fileUri) + val fileBytes = inputStream?.readBytes() ?: return null + val requestFile = fileBytes.toRequestBody("image/*".toMediaTypeOrNull()) + return MultipartBody.Part.createFormData(partName, "profile.jpg", requestFile) + } + + private fun prepareBitmapPart( + partName: String, + bitmap: Bitmap, + ): MultipartBody.Part { + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream) + val byteArray = stream.toByteArray() + val requestFile = byteArray.toRequestBody("image/jpeg".toMediaTypeOrNull()) + return MultipartBody.Part.createFormData(partName, "profile.jpg", requestFile) + } + + private fun showMessage(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun hasChanges( + fullName: String, + dob: String, + gender: String, + ): Boolean { + val currentName = patient.fullname.trim().replace("\\s+".toRegex(), " ") + val currentDob = patient.dateOfBirth.substringBefore("T") + val currentGender = patient.gender.trim().lowercase() + + return fullName != currentName || dob != currentDob || gender != currentGender || selectedPhotoUri != null || capturedPhotoBitmap != null + } + +} \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/Homepage4doctor.kt b/app/src/main/java/deakin/gopher/guardian/view/general/Homepage4doctor.kt index fb2461467..cdcc1c270 100644 --- a/app/src/main/java/deakin/gopher/guardian/view/general/Homepage4doctor.kt +++ b/app/src/main/java/deakin/gopher/guardian/view/general/Homepage4doctor.kt @@ -1,5 +1,6 @@ package deakin.gopher.guardian.view.general +import android.content.Intent import android.os.Bundle import android.widget.Button import androidx.appcompat.app.AppCompatActivity @@ -16,5 +17,15 @@ class Homepage4doctor : AppCompatActivity() { EmailPasswordAuthService.signOut(this) finish() } + + val patientsListButton: Button = findViewById(R.id.patientListButton_doctor) + + patientsListButton.setOnClickListener { + val intent = Intent(this, PatientListActivity::class.java) + intent.putExtra( + PatientListActivity.EXTRA_MODE, PatientListActivity.MODE_DOCTOR_PRESCRIPTION + ) + startActivity(intent) + } } -} +} \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/PatientDetailsActivity.kt b/app/src/main/java/deakin/gopher/guardian/view/general/PatientDetailsActivity.kt index 0e9f2ae4d..708ca25af 100644 --- a/app/src/main/java/deakin/gopher/guardian/view/general/PatientDetailsActivity.kt +++ b/app/src/main/java/deakin/gopher/guardian/view/general/PatientDetailsActivity.kt @@ -1,6 +1,7 @@ package deakin.gopher.guardian.view.general import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle import android.view.View import android.widget.Toast @@ -9,11 +10,14 @@ import com.bumptech.glide.Glide import com.google.gson.Gson import deakin.gopher.guardian.R import deakin.gopher.guardian.adapter.PatientActivityAdapter +import deakin.gopher.guardian.adapter.PrescriptionAdapter import deakin.gopher.guardian.databinding.ActivityPatientDetailsBinding import deakin.gopher.guardian.model.ApiErrorResponse import deakin.gopher.guardian.model.Patient +import deakin.gopher.guardian.model.Prescription import deakin.gopher.guardian.model.login.Role import deakin.gopher.guardian.model.login.SessionManager +import deakin.gopher.guardian.services.api.ApiClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -23,7 +27,26 @@ import java.util.Locale class PatientDetailsActivity : BaseActivity() { private lateinit var binding: ActivityPatientDetailsBinding private val currentUser = SessionManager.getCurrentUser() + + private lateinit var patient: Patient private lateinit var activitiesAdapter: PatientActivityAdapter + private lateinit var prescriptionAdapter: PrescriptionAdapter + + companion object { + const val EXTRA_MODE = "extra_mode" + const val MODE_DEFAULT = "mode_default" + const val MODE_DOCTOR_PRESCRIPTION = "mode_doctor_prescription" + } + + private val screenMode: String by lazy { + intent.getStringExtra(EXTRA_MODE) ?: MODE_DEFAULT + } + + private val isDoctorPrescriptionMode: Boolean + get() = screenMode == MODE_DOCTOR_PRESCRIPTION + + private val isCurrentUserDoctor: Boolean + get() = currentUser.role == Role.Doctor @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { @@ -41,9 +64,8 @@ class PatientDetailsActivity : BaseActivity() { binding.containerPatientInfo.setBackgroundColor(getColor(R.color.TG_blue)) } - val patient = intent.getSerializableExtra("patient") as Patient + @Suppress("DEPRECATION") patient = intent.getSerializableExtra("patient") as Patient - // Set patient info views binding.tvName.text = patient.fullname binding.tvAge.text = "Age: ${patient.age}" binding.tvDob.text = "Date of Birth: ${patient.dateOfBirth?.substringBefore("T")}" @@ -53,51 +75,159 @@ class PatientDetailsActivity : BaseActivity() { } }" - if (patient.healthConditions.isNotEmpty()) { - val formattedConditions = - patient.healthConditions.joinToString(", ") { condition -> - condition.split(" ").joinToString(" ") { word -> - word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + if (!patient.healthConditions.isNullOrEmpty()) { + val formattedConditions = patient.healthConditions.joinToString(", ") { condition -> + condition.split(" ").joinToString(" ") { word -> + word.replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() } } + } binding.tvHealthConditions.text = "Health Conditions: $formattedConditions" } else { binding.tvHealthConditions.text = "Health Conditions: No conditions listed" } - Glide.with(this) - .load(patient.photoUrl) - .placeholder(R.drawable.profile) - .circleCrop() + Glide.with(this).load(patient.photoUrl).placeholder(R.drawable.profile).circleCrop() .into(binding.imagePatient) - // Load the assigned nurses fragment dynamically and pass the nurses - val nursesFragment = PatientAssignedNursesFragment() - nursesFragment.setAssignedNurses(patient.assignedNurses ?: emptyList()) - supportFragmentManager.beginTransaction() - .replace(R.id.fragmentAssignedNursesContainer, nursesFragment) - .commit() + showAssignedNurses() + + setupPrescriptionSection() + setupActivityLogSection() + + if (!isDoctorPrescriptionMode) { + fetchPatientActivities(patient.id) + } + } + + override fun onResume() { + super.onResume() + + if (::patient.isInitialized) { + fetchPatientPrescriptions(patient.id) + } + } + + private fun setupPrescriptionSection() { + binding.btnAddPrescription.visibility = if (isCurrentUserDoctor) { + View.VISIBLE + } else { + View.GONE + } + + binding.btnAddPrescription.setOnClickListener { + if (!isCurrentUserDoctor) { + Toast.makeText( + this, "Only doctor can add prescription", Toast.LENGTH_SHORT + ).show() + return@setOnClickListener + } + + openAddPrescriptionScreen() + } + + prescriptionAdapter = PrescriptionAdapter( + prescriptions = emptyList(), + showEditButton = isCurrentUserDoctor, + onEditClick = { prescription -> + if (!isCurrentUserDoctor) { + Toast.makeText( + this, "Only doctor can edit prescription", Toast.LENGTH_SHORT + ).show() + } else { + openEditPrescriptionScreen(prescription) + } + }) - // Setup RecyclerView for activity logs + binding.recyclerViewPrescriptions.layoutManager = LinearLayoutManager(this) + binding.recyclerViewPrescriptions.adapter = prescriptionAdapter + } + + private fun showAssignedNurses() { + val assignedNurses = patient.assignedNurses + + if (assignedNurses.isEmpty()) { + binding.tvAssignedNursesNames.text = "No nurse assigned" + return + } + + val nurseNames = assignedNurses.joinToString(", ") { nurse -> + nurse.name.ifBlank { nurse.email } + } + + binding.tvAssignedNursesNames.text = nurseNames + } + + private fun setupActivityLogSection() { activitiesAdapter = PatientActivityAdapter(emptyList()) binding.recyclerViewActivities.layoutManager = LinearLayoutManager(this) binding.recyclerViewActivities.adapter = activitiesAdapter + } - fetchPatientActivities(patient.id) + private fun openAddPrescriptionScreen() { + val intent = Intent(this, AddEditPrescriptionActivity::class.java) + intent.putExtra(AddEditPrescriptionActivity.EXTRA_PATIENT_ID, patient.id) + intent.putExtra(AddEditPrescriptionActivity.EXTRA_PATIENT_NAME, patient.fullname) + startActivity(intent) + } + + private fun openEditPrescriptionScreen(prescription: Prescription) { + val intent = Intent(this, AddEditPrescriptionActivity::class.java) + intent.putExtra(AddEditPrescriptionActivity.EXTRA_PATIENT_ID, patient.id) + intent.putExtra(AddEditPrescriptionActivity.EXTRA_PATIENT_NAME, patient.fullname) + intent.putExtra(AddEditPrescriptionActivity.EXTRA_PRESCRIPTION, prescription) + startActivity(intent) + } + + private fun fetchPatientPrescriptions(patientId: String) { + val token = "Bearer ${SessionManager.getToken()}" + + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { + binding.progressBarPrescriptions.visibility = View.VISIBLE + } + + val response = try { + ApiClient.apiService.getPatientPrescriptions( + token = token, patientId = patientId, page = 1, limit = 20 + ) + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + binding.progressBarPrescriptions.visibility = View.GONE + + if (response?.isSuccessful == true) { + val prescriptions = response.body()?.prescriptions.orEmpty() + prescriptionAdapter.updateData(prescriptions) + + binding.tvEmptyPrescriptions.visibility = + if (prescriptions.isEmpty()) View.VISIBLE else View.GONE + } else { + showMessage(getErrorMessage(response, "Failed to load prescriptions")) + } + } + } } private fun fetchPatientActivities(patientId: String) { val token = "Bearer ${SessionManager.getToken()}" + CoroutineScope(Dispatchers.IO).launch { withContext(Dispatchers.Main) { binding.progressBar.visibility = View.VISIBLE } - val response = - try { - deakin.gopher.guardian.services.api.ApiClient.apiService.getPatientActivities(token, patientId) - } catch (e: Exception) { - null - } + + val response = try { + ApiClient.apiService.getPatientActivities( + token, patientId + ) + } catch (e: Exception) { + null + } + withContext(Dispatchers.Main) { binding.progressBar.visibility = View.GONE @@ -110,20 +240,31 @@ class PatientDetailsActivity : BaseActivity() { binding.tvEmptyMessage.visibility = View.VISIBLE } } else { - val errorBody = response?.errorBody()?.string() - val errorResponse = - try { - Gson().fromJson(errorBody, ApiErrorResponse::class.java) - } catch (ex: Exception) { - null - } - showMessage(errorResponse?.apiError ?: "Failed to load activities") + showMessage(getErrorMessage(response, "Failed to load activities")) } } } } + private fun getErrorMessage(response: retrofit2.Response<*>?, fallbackMessage: String): String { + val errorBody = try { + response?.errorBody()?.string() + } catch (e: Exception) { + null + } + + val errorResponse = try { + Gson().fromJson(errorBody, ApiErrorResponse::class.java) + } catch (ex: Exception) { + null + } + + return errorResponse?.apiError?.takeIf { it.isNotBlank() } + ?: errorBody?.takeIf { it.isNotBlank() } ?: response?.message() + ?.takeIf { it.isNotBlank() } ?: fallbackMessage + } + private fun showMessage(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } -} +} \ No newline at end of file diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/PatientListActivity.kt b/app/src/main/java/deakin/gopher/guardian/view/general/PatientListActivity.kt index c2bbfe079..40563affc 100644 --- a/app/src/main/java/deakin/gopher/guardian/view/general/PatientListActivity.kt +++ b/app/src/main/java/deakin/gopher/guardian/view/general/PatientListActivity.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem -import android.view.View import android.widget.Toast import androidx.recyclerview.widget.LinearLayoutManager import com.google.gson.Gson @@ -24,52 +23,25 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class PatientListActivity : BaseActivity() { - private lateinit var binding: ActivityPatientListBinding - private val patientListAdapter = - PatientListAdapter( - emptyList(), - onPatientClick = { patient -> - val intent = Intent(this, PatientDetailsActivity::class.java) - intent.putExtra("patient", patient) - startActivity(intent) - }, - onAssignNurseClick = { patient -> -// val intent = Intent(this, AssignNurseActivity::class.java) -// intent.putExtra("patientId", patient.id) -// startActivity(intent) - }, - onDeleteClick = { patient -> - confirmDeletePatient(patient) - }, - ) - private fun confirmDeletePatient(patient: Patient) { - // Optional: show a confirmation dialog before deleting - deletePatient(patient) + companion object { + const val EXTRA_MODE = "extra_mode" + const val MODE_DEFAULT = "mode_default" + const val MODE_DOCTOR_PRESCRIPTION = "mode_doctor_prescription" } - private fun deletePatient(patient: Patient) { - val token = "Bearer ${SessionManager.getToken()}" - CoroutineScope(Dispatchers.IO).launch { - val response = - try { - ApiClient.apiService.deletePatient(token, patient.id) - } catch (e: Exception) { - null - } - withContext(Dispatchers.Main) { - if (response?.isSuccessful == true) { - showMessage("Patient deleted") - fetchPatients() // Refresh the patient list - } else { - showMessage("Failed to delete patient") - } - } - } - } + private lateinit var binding: ActivityPatientListBinding + private lateinit var patientListAdapter: PatientListAdapter private val currentUser = SessionManager.getCurrentUser() + private val screenMode: String by lazy { + intent.getStringExtra(EXTRA_MODE) ?: MODE_DEFAULT + } + + private val isDoctorPrescriptionMode: Boolean + get() = screenMode == MODE_DOCTOR_PRESCRIPTION + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityPatientListBinding.inflate(layoutInflater) @@ -80,12 +52,17 @@ class PatientListActivity : BaseActivity() { onBackPressedDispatcher.onBackPressed() } + binding.toolbar.title = if (isDoctorPrescriptionMode) { + "Select Patient" + } else { + "Patients" + } + if (currentUser.role == Role.Nurse) { binding.toolbar.setBackgroundColor(getColor(R.color.TG_blue)) } - binding.recyclerViewPatients.layoutManager = LinearLayoutManager(this) - binding.recyclerViewPatients.adapter = patientListAdapter + setupPatientList() } override fun onResume() { @@ -93,47 +70,144 @@ class PatientListActivity : BaseActivity() { fetchPatients() } + private fun setupPatientList() { + patientListAdapter = PatientListAdapter( + emptyList(), onPatientClick = { patient -> + val intent = Intent(this, PatientDetailsActivity::class.java) + intent.putExtra("patient", patient) + + intent.putExtra( + PatientDetailsActivity.EXTRA_MODE, if (isDoctorPrescriptionMode) { + PatientDetailsActivity.MODE_DOCTOR_PRESCRIPTION + } else { + PatientDetailsActivity.MODE_DEFAULT + } + ) + + startActivity(intent) + }, onAssignNurseClick = { patient -> + if (currentUser.role == Role.Nurse) { + Toast.makeText( + this, "Only caretaker can assign nurse to the patient", Toast.LENGTH_SHORT + ).show() + } else { + val intent = Intent(this, AssignNurseActivity::class.java) + intent.putExtra(AssignNurseActivity.EXTRA_PATIENT_ID, patient.id) + intent.putExtra(AssignNurseActivity.EXTRA_PATIENT_NAME, patient.fullname) + startActivity(intent) + } + }, onEditClick = { patient -> + if (currentUser.role == Role.Nurse) { + Toast.makeText( + this, "Only caretaker can edit patient info", Toast.LENGTH_SHORT + ).show() + } else { + val intent = Intent(this, EditPatientActivity::class.java) + intent.putExtra(EditPatientActivity.EXTRA_PATIENT, patient) + startActivity(intent) + } + }, onDeleteClick = { patient -> + confirmDeletePatient(patient) + }, showMoreMenu = !isDoctorPrescriptionMode + ) + + binding.recyclerViewPatients.layoutManager = LinearLayoutManager(this) + binding.recyclerViewPatients.adapter = patientListAdapter + } + + private fun confirmDeletePatient(patient: Patient) { + deletePatient(patient) + } + + private fun deletePatient(patient: Patient) { + val token = "Bearer ${SessionManager.getToken()}" + + CoroutineScope(Dispatchers.IO).launch { + val response = try { + ApiClient.apiService.deletePatient(token, patient.id) + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + if (response?.isSuccessful == true) { + showMessage("Patient deleted") + fetchPatients() + } else { + showMessage("Failed to delete patient") + } + } + } + } + private fun fetchPatients() { val token = "Bearer ${SessionManager.getToken()}" + CoroutineScope(Dispatchers.IO).launch { if (patientListAdapter.itemCount <= 0) { withContext(Dispatchers.Main) { binding.progressBar.show() } } - val response = ApiClient.apiService.getAssignedPatients(token) - withContext(Dispatchers.Main) { - withContext(Dispatchers.Main) { - binding.progressBar.hide() - } - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) { - patientListAdapter.updateData(response.body()!!) - withContext(Dispatchers.Main) { - binding.tvEmptyMessage.visibility = View.GONE - } - } else { - withContext(Dispatchers.Main) { - binding.tvEmptyMessage.visibility = View.VISIBLE + + try { + if (isDoctorPrescriptionMode) { + val response = ApiClient.apiService.getAllPatientsForDoctor(token) + + withContext(Dispatchers.Main) { + binding.progressBar.hide() + + if (response.isSuccessful) { + val patients = response.body()?.patients.orEmpty() + + if (patients.isNotEmpty()) { + patientListAdapter.updateData(patients) + binding.tvEmptyMessage.visibility = android.view.View.GONE + } else { + binding.tvEmptyMessage.visibility = android.view.View.VISIBLE + } + } else { + showMessage(getErrorMessage(response, "Failed to load patients")) } } } else { - // Handle error - val errorResponse = - Gson().fromJson( - response.errorBody()?.string(), - ApiErrorResponse::class.java, - ) - showMessage(errorResponse.apiError ?: response.message()) + val response = ApiClient.apiService.getAssignedPatients(token) + + withContext(Dispatchers.Main) { + binding.progressBar.hide() + + if (response.isSuccessful) { + val patients = response.body().orEmpty() + + if (patients.isNotEmpty()) { + patientListAdapter.updateData(patients) + binding.tvEmptyMessage.visibility = android.view.View.GONE + } else { + binding.tvEmptyMessage.visibility = android.view.View.VISIBLE + } + } else { + showMessage(getErrorMessage(response, "Failed to load patients")) + } + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + binding.progressBar.hide() + showMessage("Failed to load patients: ${e.message}") } } } } override fun onCreateOptionsMenu(menu: Menu?): Boolean { + if (isDoctorPrescriptionMode) { + return false + } + if (currentUser.organization != null) { return false } + menuInflater.inflate(R.menu.menu_patient_list, menu) return true } @@ -143,10 +217,31 @@ class PatientListActivity : BaseActivity() { startActivity(Intent(this, AddNewPatientActivity::class.java)) return true } + return super.onOptionsItemSelected(item) } + private fun getErrorMessage( + response: retrofit2.Response<*>?, fallbackMessage: String + ): String { + val rawErrorBody = try { + response?.errorBody()?.string() + } catch (e: Exception) { + null + } + + val errorResponse = try { + Gson().fromJson(rawErrorBody, ApiErrorResponse::class.java) + } catch (e: Exception) { + null + } + + return errorResponse?.apiError?.takeIf { it.isNotBlank() } + ?: rawErrorBody?.takeIf { it.isNotBlank() } ?: response?.message() + ?.takeIf { it.isNotBlank() } ?: fallbackMessage + } + private fun showMessage(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } -} +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_edit_prescription.xml b/app/src/main/res/layout/activity_add_edit_prescription.xml new file mode 100644 index 000000000..b165355f5 --- /dev/null +++ b/app/src/main/res/layout/activity_add_edit_prescription.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_new_patient_nurse.xml b/app/src/main/res/layout/activity_add_new_patient_nurse.xml index 05368bbcc..91d12ddff 100644 --- a/app/src/main/res/layout/activity_add_new_patient_nurse.xml +++ b/app/src/main/res/layout/activity_add_new_patient_nurse.xml @@ -1,6 +1,5 @@ - - + app:subtitleTextColor="@android:color/white" + app:title="@string/add_new_patient" + app:titleCentered="true" + app:titleTextColor="@android:color/white" /> - + app:layout_constraintTop_toBottomOf="@id/toolbar"> - + android:hint="@string/full_name"> - + android:hint="@string/date_of_birth"> - + android:hint="Age"> - + + + + + android:textStyle="bold" /> - - - + app:iconTint="@color/teal_600" /> - + app:iconTint="@color/teal_600" /> - - - - - - - - + app:cornerRadius="8dp" /> - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_assign_nurse.xml b/app/src/main/res/layout/activity_assign_nurse.xml new file mode 100644 index 000000000..762f02e32 --- /dev/null +++ b/app/src/main/res/layout/activity_assign_nurse.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_edit_patient.xml b/app/src/main/res/layout/activity_edit_patient.xml new file mode 100644 index 000000000..e17871ac4 --- /dev/null +++ b/app/src/main/res/layout/activity_edit_patient.xml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_homepage4doctor.xml b/app/src/main/res/layout/activity_homepage4doctor.xml index 6e7936f35..c82014d9b 100644 --- a/app/src/main/res/layout/activity_homepage4doctor.xml +++ b/app/src/main/res/layout/activity_homepage4doctor.xml @@ -26,4 +26,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/welcomeDoctorText" /> +