diff --git a/app/build.gradle b/app/build.gradle index 18f94621d..8a663589d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,7 +35,11 @@ android { } buildTypes { + debug { + buildConfigField "boolean", "USE_MOCK_PATIENT_FLOW", "true" + } release { + buildConfigField "boolean", "USE_MOCK_PATIENT_FLOW", "false" minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } @@ -45,6 +49,7 @@ android { targetCompatibility JavaVersion.VERSION_17 } buildFeatures { + buildConfig true viewBinding true // dataBinding true compose true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31d6a312a..54dc349d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -152,6 +152,9 @@ + diff --git a/app/src/main/java/deakin/gopher/guardian/PatientExerciseModules.kt b/app/src/main/java/deakin/gopher/guardian/PatientExerciseModules.kt index 20e1a52eb..449000379 100644 --- a/app/src/main/java/deakin/gopher/guardian/PatientExerciseModules.kt +++ b/app/src/main/java/deakin/gopher/guardian/PatientExerciseModules.kt @@ -2,6 +2,7 @@ package deakin.gopher.guardian import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.activity.OnBackPressedCallback /** * Host activity for the patient exercise modules @@ -11,6 +12,20 @@ class PatientExerciseModules : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_patient_exercise_container) + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + }, + ) + if (savedInstanceState == null) { val fragment = PatientExercisePortalFragment.newInstance() supportFragmentManager.beginTransaction() @@ -18,12 +33,4 @@ class PatientExerciseModules : AppCompatActivity() { .commit() } } - - override fun onBackPressed() { - if (supportFragmentManager.backStackEntryCount > 0) { - supportFragmentManager.popBackStack() - } else { - super.onBackPressed() - } - } } diff --git a/app/src/main/java/deakin/gopher/guardian/model/MockPatientData.kt b/app/src/main/java/deakin/gopher/guardian/model/MockPatientData.kt new file mode 100644 index 000000000..29f0dd61c --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/model/MockPatientData.kt @@ -0,0 +1,111 @@ +package deakin.gopher.guardian.model + +import deakin.gopher.guardian.model.register.User + +/** Local samples for DEBUG UI review (doctor list + overview + admin overview card). */ +object MockPatientData { + private val stubCaretaker = + User( + id = "mock-caretaker-1", + email = "j.morgan@example.com", + name = "Jamie Morgan", + roleName = "caretaker", + photoUrl = "", + organization = null, + ) + + private val stubNurses = + listOf( + User( + id = "mock-nurse-1", + email = "a.patel@example.com", + name = "Priya Patel", + roleName = "nurse", + photoUrl = "", + organization = null, + ), + User( + id = "mock-nurse-2", + email = "l.chen@example.com", + name = "Liam Chen", + roleName = "nurse", + photoUrl = "", + organization = null, + ), + ) + + val patients: List + get() = + listOf( + Patient( + id = "mock-patient-1", + fullname = "Eleanor Whitmore", + photoUrl = "", + dateOfBirth = "1948-06-15T00:00:00Z", + _age = 77, + gender = "female", + healthConditions = + listOf( + "type 2 diabetes", + "mild hypertension", + "early stage osteoarthritis", + ), + caretaker = stubCaretaker, + assignedNurses = stubNurses, + ), + Patient( + id = "mock-patient-2", + fullname = "Robert Singh", + photoUrl = "", + dateOfBirth = "1939-03-08T10:30:00Z", + _age = 87, + gender = "male", + healthConditions = + listOf( + "congestive heart failure", + "chronic kidney disease stage 3", + ), + caretaker = stubCaretaker, + assignedNurses = listOf(stubNurses.first()), + ), + Patient( + id = "mock-patient-3", + fullname = "Maria Santos", + photoUrl = "", + dateOfBirth = "1962-11-02T00:00:00Z", + _age = 62, + gender = "female", + healthConditions = emptyList(), + caretaker = stubCaretaker, + assignedNurses = emptyList(), + ), + ) + + fun patientById(id: String): Patient? = patients.find { it.id == id } + + fun patientForOverviewOrDefault(id: String): Patient = + patientById(id) ?: patients.first() + + /** Fills the “Clinical / admin overview” section during mock previews. */ + fun adminOverviewForPatient(id: String): PatientAdminOverviewResponse { + val name = patientById(id)?.fullname ?: patients.first().fullname + return PatientAdminOverviewResponse( + summary = + "$name — routine follow-up cadence weekly. Stable on current medication plan.", + overview = + "Focus areas: hydration, mobilisation indoors, adherence to diabetic diet sheet.", + carePlanSummary = "Morning vitals weekly; pharmacist review fortnightly.", + notes = "Prefers reminders via SMS. Son visits Tuesdays.", + riskLevel = when (patientById(id)?.id) { + "mock-patient-2" -> "elevated" + "mock-patient-3" -> "low" + else -> "moderate" + }, + alerts = + listOf( + "Renew script for ACE inhibitor due next month.", + "Updated emergency contact verified Jan 2026.", + ), + ) + } +} diff --git a/app/src/main/java/deakin/gopher/guardian/model/PatientAdminOverviewResponse.kt b/app/src/main/java/deakin/gopher/guardian/model/PatientAdminOverviewResponse.kt new file mode 100644 index 000000000..2ceb6fd3b --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/model/PatientAdminOverviewResponse.kt @@ -0,0 +1,15 @@ +package deakin.gopher.guardian.model + +import com.google.gson.annotations.SerializedName +import java.io.Serializable + +/** Lenient decoding for GET /admin/patient-overview/{patientId}; unknown fields ignored by Gson. */ +data class PatientAdminOverviewResponse( + @SerializedName("patient") val patient: Patient? = null, + @SerializedName("summary") val summary: String? = null, + @SerializedName("notes") val notes: String? = null, + @SerializedName("overview") val overview: String? = null, + @SerializedName("carePlanSummary") val carePlanSummary: String? = null, + @SerializedName("riskLevel") val riskLevel: String? = null, + @SerializedName("alerts") val alerts: List? = null, +) : Serializable 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..3b2fef11c 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 @@ -5,6 +5,7 @@ import deakin.gopher.guardian.model.AddPatientResponse import deakin.gopher.guardian.model.BaseModel import deakin.gopher.guardian.model.Patient import deakin.gopher.guardian.model.PatientActivity +import deakin.gopher.guardian.model.PatientAdminOverviewResponse import deakin.gopher.guardian.model.register.AuthResponse import deakin.gopher.guardian.model.register.RegisterRequest import okhttp3.MultipartBody @@ -60,6 +61,24 @@ interface ApiService { @Header("Authorization") token: String, ): Response> + @GET("patients/{patientId}") + suspend fun getPatientById( + @Header("Authorization") token: String, + @Path("patientId") patientId: String, + ): Response + + @GET("admin/patient-overview/{patientId}") + suspend fun getPatientAdminOverview( + @Header("Authorization") token: String, + @Path("patientId") patientId: String, + ): Response + + @GET("doctors/{doctorId}/patients") + suspend fun getDoctorPatients( + @Header("Authorization") token: String, + @Path("doctorId") doctorId: String, + ): Response> + @Multipart @POST("patients/add") suspend fun addPatient( 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..6e4e11dd2 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,20 +1,104 @@ package deakin.gopher.guardian.view.general +import android.content.Intent import android.os.Bundle import android.widget.Button -import androidx.appcompat.app.AppCompatActivity +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import deakin.gopher.guardian.adapter.PatientListAdapter +import deakin.gopher.guardian.BuildConfig import deakin.gopher.guardian.R +import deakin.gopher.guardian.model.MockPatientData +import deakin.gopher.guardian.model.login.SessionManager import deakin.gopher.guardian.services.EmailPasswordAuthService +import deakin.gopher.guardian.services.api.ApiClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class Homepage4doctor : BaseActivity() { + private lateinit var patientListAdapter: PatientListAdapter + private lateinit var progressBar: ProgressBar + private lateinit var emptyText: TextView -class Homepage4doctor : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_homepage4doctor) + progressBar = findViewById(R.id.progressBarPatients) + emptyText = findViewById(R.id.tvEmptyPatients) + + patientListAdapter = + PatientListAdapter( + emptyList(), + onPatientClick = { patient -> + val intent = Intent(this, PatientOverviewActivity::class.java) + intent.putExtra(PatientOverviewActivity.EXTRA_PATIENT_ID, patient.id) + startActivity(intent) + }, + ) + + val recyclerView: RecyclerView = findViewById(R.id.recyclerViewDoctorPatients) + recyclerView.layoutManager = LinearLayoutManager(this) + recyclerView.adapter = patientListAdapter + val signOutButton: Button = findViewById(R.id.signOutButton_doctor) signOutButton.setOnClickListener { EmailPasswordAuthService.signOut(this) finish() } } + + override fun onResume() { + super.onResume() + fetchPatients() + } + + private fun fetchPatients() { + if (BuildConfig.USE_MOCK_PATIENT_FLOW) { + progressBar.visibility = android.view.View.GONE + val mocks = MockPatientData.patients + patientListAdapter.updateData(mocks) + emptyText.visibility = + if (mocks.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE + return + } + + val token = "Bearer ${SessionManager.getToken()}" + val doctorId = SessionManager.getCurrentUser().id + CoroutineScope(Dispatchers.IO).launch { + withContext(Dispatchers.Main) { + progressBar.visibility = android.view.View.VISIBLE + emptyText.visibility = android.view.View.GONE + } + + val response = + try { + ApiClient.apiService.getDoctorPatients(token, doctorId) + } catch (e: Exception) { + null + } + + withContext(Dispatchers.Main) { + progressBar.visibility = android.view.View.GONE + if (response?.isSuccessful == true) { + val patients = response.body().orEmpty() + patientListAdapter.updateData(patients) + emptyText.visibility = + if (patients.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE + } else { + emptyText.visibility = android.view.View.VISIBLE + Toast.makeText( + this@Homepage4doctor, + "Failed to load patients", + Toast.LENGTH_SHORT, + ).show() + } + } + } + } } 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..beb7569d9 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 @@ -29,8 +29,8 @@ class PatientListActivity : BaseActivity() { PatientListAdapter( emptyList(), onPatientClick = { patient -> - val intent = Intent(this, PatientDetailsActivity::class.java) - intent.putExtra("patient", patient) + val intent = Intent(this, PatientOverviewActivity::class.java) + intent.putExtra(PatientOverviewActivity.EXTRA_PATIENT_ID, patient.id) startActivity(intent) }, onAssignNurseClick = { patient -> diff --git a/app/src/main/java/deakin/gopher/guardian/view/general/PatientOverviewActivity.kt b/app/src/main/java/deakin/gopher/guardian/view/general/PatientOverviewActivity.kt new file mode 100644 index 000000000..ce221e72f --- /dev/null +++ b/app/src/main/java/deakin/gopher/guardian/view/general/PatientOverviewActivity.kt @@ -0,0 +1,267 @@ +package deakin.gopher.guardian.view.general + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.google.gson.Gson +import deakin.gopher.guardian.R +import deakin.gopher.guardian.BuildConfig +import deakin.gopher.guardian.databinding.ActivityPatientOverviewBinding +import deakin.gopher.guardian.model.ApiErrorResponse +import deakin.gopher.guardian.model.MockPatientData +import deakin.gopher.guardian.model.Patient +import deakin.gopher.guardian.model.PatientAdminOverviewResponse +import deakin.gopher.guardian.model.login.Role +import deakin.gopher.guardian.model.login.SessionManager +import deakin.gopher.guardian.services.api.ApiClient +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import retrofit2.Response + +class PatientOverviewActivity : BaseActivity() { + private lateinit var binding: ActivityPatientOverviewBinding + private var patientIdArg: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPatientOverviewBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + binding.toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + + if (SessionManager.getCurrentUser().role == Role.Nurse) { + binding.toolbar.setBackgroundColor(getColor(R.color.TG_blue)) + } + + patientIdArg = intent.getStringExtra(EXTRA_PATIENT_ID)?.trim() + if (patientIdArg.isNullOrEmpty()) { + Toast.makeText(this, R.string.patient_overview_missing_id, Toast.LENGTH_SHORT).show() + finish() + return + } + + binding.btnRetry.setOnClickListener { loadOverview() } + loadOverview() + } + + private fun loadOverview() { + val patientId = patientIdArg ?: return + setLoadingState(true) + binding.layoutError.visibility = View.GONE + binding.layoutEmpty.visibility = View.GONE + binding.svOverviewContent.visibility = View.GONE + resetAdminSection() + + if (BuildConfig.USE_MOCK_PATIENT_FLOW) { + lifecycleScope.launch { + val patient = MockPatientData.patientForOverviewOrDefault(patientId) + setLoadingState(false) + binding.svOverviewContent.visibility = View.VISIBLE + bindPatientDetails(patient) + applyAdminOverview(MockPatientData.adminOverviewForPatient(patientId)) + } + return + } + + lifecycleScope.launch { + val token = "Bearer ${SessionManager.getToken()}" + + val patientResponse = + withContext(Dispatchers.IO) { + try { + ApiClient.apiService.getPatientById(token, patientId) + } catch (_: Exception) { + null + } + } + + if (patientResponse == null) { + setLoadingState(false) + showError(getString(R.string.patient_overview_error_generic)) + return@launch + } + + if (!patientResponse.isSuccessful) { + setLoadingState(false) + showError(resolveErrorMessage(patientResponse)) + return@launch + } + + val patient = patientResponse.body() + if (patient == null) { + setLoadingState(false) + showEmpty() + return@launch + } + + if (patient.id != patientId) { + setLoadingState(false) + showError(getString(R.string.patient_overview_error_generic)) + return@launch + } + + val adminResponse = + withContext(Dispatchers.IO) { + try { + ApiClient.apiService.getPatientAdminOverview(token, patientId) + } catch (_: Exception) { + null + } + } + + setLoadingState(false) + + binding.svOverviewContent.visibility = View.VISIBLE + bindPatientDetails(patient) + + if (adminResponse?.isSuccessful == true) { + adminResponse.body()?.let { applyAdminOverview(it) } + } + } + } + + private fun bindPatientDetails(patient: Patient) { + binding.tvPatientName.text = patient.fullname + binding.tvOverviewAge.text = getString(R.string.patient_overview_age_line, patient.age) + binding.tvOverviewGender.text = + getString(R.string.patient_overview_gender_line, patient.gender.capitalizeWords()) + binding.tvOverviewDob.text = + getString( + R.string.patient_overview_dob_line, + patient.dateOfBirth.substringBefore("T").ifEmpty { "—" }, + ) + + binding.tvOverviewConditions.text = + if (patient.healthConditions.isEmpty()) { + getString(R.string.patient_overview_no_conditions) + } else { + patient.healthConditions.joinToString("\n") { line -> "• ${line.capitalizeWords()}" } + } + + binding.tvCaretakerLine.text = + getString( + R.string.patient_overview_caretaker_line, + patient.caretaker.name, + patient.caretaker.email, + ) + + binding.tvAssignedNursesDetail.text = + if (patient.assignedNurses.isEmpty()) { + getString(R.string.patient_overview_no_nurses) + } else { + patient.assignedNurses.joinToString("\n") { nurse -> + "${nurse.name} (${nurse.email})" + } + } + + Glide.with(this) + .load(patient.photoUrl) + .placeholder(R.drawable.profile) + .circleCrop() + .into(binding.imagePatient) + } + + private fun resetAdminSection() { + binding.cardAdminOverview.visibility = View.GONE + listOf( + binding.tvOverviewRiskLevel, + binding.tvOverviewSummary, + binding.tvOverviewExtended, + binding.tvOverviewCarePlan, + binding.tvOverviewNotes, + binding.tvOverviewAlerts, + ).forEach { view -> + view.visibility = View.GONE + view.text = "" + } + } + + private fun applyAdminOverview(body: PatientAdminOverviewResponse) { + var any = false + + body.riskLevel?.takeIf { it.isNotBlank() }?.let { + binding.tvOverviewRiskLevel.visibility = View.VISIBLE + binding.tvOverviewRiskLevel.text = getString(R.string.patient_overview_risk_level, it) + any = true + } + + body.summary?.takeIf { it.isNotBlank() }?.let { + binding.tvOverviewSummary.visibility = View.VISIBLE + binding.tvOverviewSummary.text = it + any = true + } + + body.overview?.takeIf { it.isNotBlank() }?.let { + binding.tvOverviewExtended.visibility = View.VISIBLE + binding.tvOverviewExtended.text = it + any = true + } + + body.carePlanSummary?.takeIf { it.isNotBlank() }?.let { + binding.tvOverviewCarePlan.visibility = View.VISIBLE + binding.tvOverviewCarePlan.text = it + any = true + } + + body.notes?.takeIf { it.isNotBlank() }?.let { + binding.tvOverviewNotes.visibility = View.VISIBLE + binding.tvOverviewNotes.text = it + any = true + } + + body.alerts?.takeIf { it.isNotEmpty() }?.let { list -> + binding.tvOverviewAlerts.visibility = View.VISIBLE + binding.tvOverviewAlerts.text = list.joinToString("\n") { alert -> "• $alert" } + any = true + } + + if (any) { + binding.cardAdminOverview.visibility = View.VISIBLE + } + } + + private fun setLoadingState(loading: Boolean) { + binding.progressLoading.visibility = if (loading) View.VISIBLE else View.GONE + } + + private fun showError(message: String) { + binding.svOverviewContent.visibility = View.GONE + binding.layoutEmpty.visibility = View.GONE + binding.layoutError.visibility = View.VISIBLE + binding.tvErrorMessage.text = message + } + + private fun showEmpty() { + binding.svOverviewContent.visibility = View.GONE + binding.layoutError.visibility = View.GONE + binding.layoutEmpty.visibility = View.VISIBLE + } + + private fun resolveErrorMessage(response: Response<*>): String { + return try { + val raw = response.errorBody()?.string().orEmpty() + Gson().fromJson(raw, ApiErrorResponse::class.java)?.apiError + } catch (_: Exception) { + null + } ?: response.message().takeIf { it.isNotBlank() } + ?: getString(R.string.patient_overview_error_generic) + } + + private fun String.capitalizeWords(): String = + split(" ").joinToString(" ") { word -> + word.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + } + + companion object { + const val EXTRA_PATIENT_ID = "patient_id" + } +} diff --git a/app/src/main/res/layout/activity_homepage4doctor.xml b/app/src/main/res/layout/activity_homepage4doctor.xml index 6e7936f35..70dc13f60 100644 --- a/app/src/main/res/layout/activity_homepage4doctor.xml +++ b/app/src/main/res/layout/activity_homepage4doctor.xml @@ -1,8 +1,10 @@ + android:layout_height="match_parent" + android:padding="16dp"> @@ -20,10 +20,54 @@ android:id="@+id/signOutButton_doctor" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="24dp" android:text="Sign Out" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBaseline_toBaselineOf="@id/welcomeDoctorText" + app:layout_constraintEnd_toEndOf="parent" /> + + + + + + + + diff --git a/app/src/main/res/layout/activity_patient_overview.xml b/app/src/main/res/layout/activity_patient_overview.xml new file mode 100644 index 000000000..356b98098 --- /dev/null +++ b/app/src/main/res/layout/activity_patient_overview.xml @@ -0,0 +1,311 @@ + + + + + + + + + + + + + +