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" />
+
+
diff --git a/app/src/main/res/layout/activity_patient_details.xml b/app/src/main/res/layout/activity_patient_details.xml
index 64059d2d1..190089b66 100644
--- a/app/src/main/res/layout/activity_patient_details.xml
+++ b/app/src/main/res/layout/activity_patient_details.xml
@@ -1,6 +1,5 @@
-
-
+ app:layout_constraintTop_toTopOf="parent" />
-
+ app:titleTextColor="@android:color/white" />
-
+ app:layout_constraintTop_toBottomOf="@id/toolbar">
+ android:paddingEnd="16dp">
+
-
-
+ android:text="Assigned Nurses"
+ android:textSize="16sp"
+ android:textStyle="bold"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/containerPatientInfo" />
+
+
-
-
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@id/tvPrescriptionsLabel" />
-
+
+
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@id/tvActivitiesLabel" />
-
+ app:layout_constraintTop_toBottomOf="@id/tvActivitiesLabel" />
-
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/tvActivitiesLabel" />
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_prescription.xml b/app/src/main/res/layout/item_prescription.xml
new file mode 100644
index 000000000..758365ba0
--- /dev/null
+++ b/app/src/main/res/layout/item_prescription.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_patient_item.xml b/app/src/main/res/menu/menu_patient_item.xml
index 1f11cf04f..14de00e8d 100644
--- a/app/src/main/res/menu/menu_patient_item.xml
+++ b/app/src/main/res/menu/menu_patient_item.xml
@@ -3,6 +3,9 @@
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9fe4ecab0..06ffe24c8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -41,7 +41,7 @@
Verified
Welcome, Caretaker
Welcome, Administrator
- Welcome, Nurse
+ Welcome,
@@ -705,7 +705,7 @@
To Do
Mark as Complete
You have not added any Patients yet.\nTap + to add.
- You have not assigned any Nurses to this Patient yet.\nTap + to add.
+ You have not assigned any Nurses to this Patient yet.\
No activity has been recorded for this Patient yet.
Patient photo
More Options
diff --git a/build.gradle b/build.gradle
index 558092873..353446a9c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -12,8 +12,8 @@ buildscript {
}
plugins {
- id 'com.android.application' version '8.5.1' apply false
- id 'com.android.library' version '8.5.1' apply false
+ id 'com.android.application' version '8.13.2' apply false
+ id 'com.android.library' version '8.13.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.20' apply false
}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 404bb61a8..2c628d5ce 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Wed Aug 31 11:56:40 AEST 2022
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME