From a483f06f39d6044abed925a3f3825afb0ee12af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ara=C3=BAjo?= Date: Tue, 15 Sep 2026 15:17:44 +0100 Subject: [PATCH] dashboard --- medmap/AppState.swift | 3 + medmap/Models/IO/MedicationDTO.swift | 18 +++ .../PrescriptionWithSchedulesResponse.swift | 1 + medmap/Network/MedMapAPI.swift | 12 ++ medmap/ViewModels/DashboardViewModel.swift | 82 +++++++++- medmap/ViewModels/LoginViewModel.swift | 1 + medmap/Views/DashboardView.swift | 144 +++++++++++++++++- medmap/medmapApp.swift | 7 + .../controllers/PrescriptionController.java | 16 ++ .../PrescriptionWithSchedulesResponse.java | 3 + .../map02/services/PrescriptionsService.java | 11 ++ 11 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 medmap/Models/IO/MedicationDTO.swift diff --git a/medmap/AppState.swift b/medmap/AppState.swift index 53f9ec3..836056e 100755 --- a/medmap/AppState.swift +++ b/medmap/AppState.swift @@ -10,6 +10,8 @@ enum AppState: Equatable, Hashable { return true case (.login, .login): return true + case (.dashboard, .dashboard): + return true default: return false @@ -18,4 +20,5 @@ enum AppState: Equatable, Hashable { case splashScreen case login + case dashboard } diff --git a/medmap/Models/IO/MedicationDTO.swift b/medmap/Models/IO/MedicationDTO.swift new file mode 100644 index 0000000..b181606 --- /dev/null +++ b/medmap/Models/IO/MedicationDTO.swift @@ -0,0 +1,18 @@ +// +// MedicationDTO.swift +// medmap +// + +import Foundation + +struct MedicationDTO: Decodable { + let medicationId: String + let name: String + let activeIngredient: String? + let dosageForm: String? + let medicationPhoto: String? +} + +struct ListOfMedicationsResponse: Decodable { + let medications: [MedicationDTO] +} \ No newline at end of file diff --git a/medmap/Models/IO/PrescriptionWithSchedulesResponse.swift b/medmap/Models/IO/PrescriptionWithSchedulesResponse.swift index b23c9d1..8c8a93b 100644 --- a/medmap/Models/IO/PrescriptionWithSchedulesResponse.swift +++ b/medmap/Models/IO/PrescriptionWithSchedulesResponse.swift @@ -10,5 +10,6 @@ struct PrescriptionWithSchedulesResponse: Decodable { let prescription: PrescriptionResponse + let medicationId: String let schedules: [PrescriptionScheduleResponse] } \ No newline at end of file diff --git a/medmap/Network/MedMapAPI.swift b/medmap/Network/MedMapAPI.swift index 9646e78..1063243 100644 --- a/medmap/Network/MedMapAPI.swift +++ b/medmap/Network/MedMapAPI.swift @@ -83,4 +83,16 @@ class MedMapAPI { let request = ListPrescriptionsRequest(patientId: patientId, activeOnly: activeOnly, scheduleFilter: scheduleFilter) return try await apiClient.request(url, method: .post, parameters: request) } + + func listMyActivePrescriptions() async throws -> [PrescriptionWithSchedulesResponse] { + let url = "\(baseURL)/api/prescriptions/my-active" + return try await apiClient.request(url, method: .get) + } + + // MARK: - Medications + + func listMedications() async throws -> ListOfMedicationsResponse { + let url = "\(baseURL)/api/medication/list" + return try await apiClient.request(url, method: .get) + } } diff --git a/medmap/ViewModels/DashboardViewModel.swift b/medmap/ViewModels/DashboardViewModel.swift index 2044b25..148c37c 100644 --- a/medmap/ViewModels/DashboardViewModel.swift +++ b/medmap/ViewModels/DashboardViewModel.swift @@ -1,7 +1,81 @@ -// -// DashboardViewModel.swift -// medmap -// +import Foundation + +@MainActor +final class DashboardViewModel: ViewModel { + + @Published private(set) var prescriptions: [PrescriptionWithSchedulesResponse] = [] + @Published private(set) var medicationNames: [String: String] = [:] + @Published private(set) var errorMessage: String? + + private let api: MedMapAPI + + init(api: MedMapAPI = MedMapAPI()) { + self.api = api + super.init() + } + + var nextDose: DashboardDose? { + scheduledDoses.first(where: { $0.time >= currentTime }) ?? scheduledDoses.first + } + + var scheduledDoses: [DashboardDose] { + prescriptions.flatMap { response in + response.schedules.flatMap { schedule in + schedule.timesOfDay.map { time in + DashboardDose( + time: time, + title: medicationNames[response.medicationId] ?? "Medicacao prescrita", + detail: response.prescription.instructions ?? doseDescription(schedule) + ) + } + } + } + .sorted { $0.time < $1.time } + } + + func loadDashboard() async { + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + async let activePrescriptions = api.listMyActivePrescriptions() + async let medications = api.listMedications() + + let (prescriptions, medicationResponse) = try await (activePrescriptions, medications) + self.prescriptions = prescriptions + medicationNames = Dictionary( + uniqueKeysWithValues: medicationResponse.medications.map { ($0.medicationId, $0.name) } + ) + } catch { + errorMessage = "Nao foi possivel carregar a sua medicacao." + fluxlog("Patient dashboard load failed: \(error.localizedDescription)") + } + } + + private var currentTime: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter.string(from: .now) + } + + private func doseDescription(_ schedule: PrescriptionScheduleResponse) -> String { + guard let amount = schedule.doseAmount else { return "Dose conforme prescricao" } + let formattedAmount = amount.formatted(.number.precision(.fractionLength(0...2))) + return schedule.doseUnit.map { "\(formattedAmount) \($0)" } ?? formattedAmount + } +} + +struct DashboardDose: Identifiable { + let time: String + let title: String + let detail: String + + var id: String { "\(time)-\(title)-\(detail)" } +} // Created by RSA on 28/08/2026. // diff --git a/medmap/ViewModels/LoginViewModel.swift b/medmap/ViewModels/LoginViewModel.swift index fe69d56..da88cf8 100644 --- a/medmap/ViewModels/LoginViewModel.swift +++ b/medmap/ViewModels/LoginViewModel.swift @@ -43,6 +43,7 @@ final class LoginViewModel: ViewModel { } authenticatedPatient = response + appStateManager.navigateTo(state: .dashboard) } catch { errorMessage = "Nao foi possivel iniciar sessao. Confirme o email e a palavra-passe." fluxlog("Patient login failed: \(error.localizedDescription)") diff --git a/medmap/Views/DashboardView.swift b/medmap/Views/DashboardView.swift index 005b429..01cb0d6 100644 --- a/medmap/Views/DashboardView.swift +++ b/medmap/Views/DashboardView.swift @@ -1,7 +1,143 @@ -// -// DashboardView.swift -// medmap -// +import SwiftUI + +struct DashboardView: View { + + @StateObject private var viewModel = DashboardViewModel() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + header + nextDoseCard + todaySection + } + .padding(20) + } + .background(Color.appSecondary.ignoresSafeArea()) + .navigationBarHidden(true) + .task { + await viewModel.loadDashboard() + } + .refreshable { + await viewModel.loadDashboard() + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("O seu plano") + .font(.jakartaBold24) + .foregroundStyle(Color.appTextPrimary) + Text("A sua medicacao de hoje") + .font(.jakartaRegular16) + .foregroundStyle(Color.appTextPrimary.opacity(0.65)) + } + } + + @ViewBuilder + private var nextDoseCard: some View { + if viewModel.isLoading && viewModel.prescriptions.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 156) + } else if let errorMessage = viewModel.errorMessage { + VStack(alignment: .leading, spacing: 12) { + Text(errorMessage) + .font(.jakartaRegular16) + .foregroundStyle(.red) + Button("Tentar novamente") { + Task { await viewModel.loadDashboard() } + } + .font(.jakartaBold16) + .foregroundStyle(Color.appBackground) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else if let dose = viewModel.nextDose { + VStack(alignment: .leading, spacing: 12) { + Text("PROXIMA TOMA") + .font(.jakartaBold12) + .foregroundStyle(.white.opacity(0.75)) + HStack(alignment: .center) { + Text(dose.time) + .font(.jakartaBold40) + Spacer() + Image(systemName: "pills.fill") + .font(.system(size: 31)) + } + Text(dose.title) + .font(.jakartaBold18) + Text(dose.detail) + .font(.jakartaRegular16) + .foregroundStyle(.white.opacity(0.82)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(22) + .background(Color.appBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + VStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(Color.appBackground) + Text("Nao tem tomas agendadas.") + .font(.jakartaRegular16) + .foregroundStyle(Color.appTextPrimary) + } + .frame(maxWidth: .infinity, minHeight: 156) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + + private var todaySection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Medicacao de hoje") + .font(.jakartaBold18) + .foregroundStyle(Color.appTextPrimary) + + if viewModel.scheduledDoses.isEmpty && !viewModel.isLoading && viewModel.errorMessage == nil { + Text("Nao ha medicacao ativa para mostrar.") + .font(.jakartaRegular16) + .foregroundStyle(Color.appTextPrimary.opacity(0.65)) + } else { + ForEach(viewModel.scheduledDoses) { dose in + HStack(spacing: 16) { + Text(dose.time) + .font(.jakartaBold16) + .foregroundStyle(Color.appBackground) + .frame(width: 54, alignment: .leading) + Circle() + .fill(Color.appBackground.opacity(0.18)) + .frame(width: 40, height: 40) + .overlay { + Image(systemName: "pills.fill") + .foregroundStyle(Color.appBackground) + } + VStack(alignment: .leading, spacing: 3) { + Text(dose.title) + .font(.jakartaBold16) + .foregroundStyle(Color.appTextPrimary) + Text(dose.detail) + .font(.jakartaRegular14) + .foregroundStyle(Color.appTextPrimary.opacity(0.62)) + } + Spacer(minLength: 0) + } + .padding(16) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } +} + +#Preview { + DashboardView() +} // Created by RSA on 28/08/2026. // diff --git a/medmap/medmapApp.swift b/medmap/medmapApp.swift index ac491bc..c1e9f9f 100644 --- a/medmap/medmapApp.swift +++ b/medmap/medmapApp.swift @@ -92,6 +92,13 @@ struct medmapApp: App { .navigationBarHidden(true) .transition(.opacity) .animation(.easeInOut, value: 10) + + case .dashboard: + + DashboardView() + .navigationBarHidden(true) + .transition(.opacity) + .animation(.easeInOut, value: 10) } } }) diff --git a/src/main/java/com/voidsoftware/map02/controllers/PrescriptionController.java b/src/main/java/com/voidsoftware/map02/controllers/PrescriptionController.java index aa177d2..87aee6b 100644 --- a/src/main/java/com/voidsoftware/map02/controllers/PrescriptionController.java +++ b/src/main/java/com/voidsoftware/map02/controllers/PrescriptionController.java @@ -3,6 +3,7 @@ package com.voidsoftware.map02.controllers; import com.voidsoftware.map02.config.CurrentUser; import com.voidsoftware.map02.entities.AppUser; import com.voidsoftware.map02.entities.Prescription; +import com.voidsoftware.map02.models.Role; import com.voidsoftware.map02.models.inout.ChangePrescriptionScheduleRequest; import com.voidsoftware.map02.models.inout.CreatePrescriptionRequest; import com.voidsoftware.map02.models.inout.ListPrescriptionsRequest; @@ -71,4 +72,19 @@ public class PrescriptionController { @Valid @RequestBody ListPrescriptionsRequest request) { return ResponseEntity.ok(prescriptionsService.listPrescriptions(request)); } + + @Operation(summary = "List my active prescriptions", description = "Returns the authenticated patient's active prescriptions and current schedules.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List returned"), + @ApiResponse(responseCode = "403", description = "Only patient accounts can access this endpoint") + }) + @GetMapping("/my-active") + public ResponseEntity> listMyActivePrescriptions( + @CurrentUser AppUser sessionUser) { + if (sessionUser == null || sessionUser.getRole() != Role.PATIENT) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(prescriptionsService.listActivePrescriptionsForPatient(sessionUser)); + } } diff --git a/src/main/java/com/voidsoftware/map02/models/inout/PrescriptionWithSchedulesResponse.java b/src/main/java/com/voidsoftware/map02/models/inout/PrescriptionWithSchedulesResponse.java index 2cee891..05db245 100644 --- a/src/main/java/com/voidsoftware/map02/models/inout/PrescriptionWithSchedulesResponse.java +++ b/src/main/java/com/voidsoftware/map02/models/inout/PrescriptionWithSchedulesResponse.java @@ -5,17 +5,20 @@ import com.voidsoftware.map02.entities.PrescriptionSchedule; import lombok.Getter; import java.util.List; +import java.util.UUID; /** Bundles a prescription with the schedule versions selected by the caller's filter. */ @Getter public class PrescriptionWithSchedulesResponse { private final Prescription prescription; + private final UUID medicationId; private final List schedules; public PrescriptionWithSchedulesResponse(Prescription prescription, List schedules) { this.prescription = prescription; + this.medicationId = prescription.getMedication().getId(); this.schedules = schedules; } } diff --git a/src/main/java/com/voidsoftware/map02/services/PrescriptionsService.java b/src/main/java/com/voidsoftware/map02/services/PrescriptionsService.java index 02c28f1..ad6204b 100644 --- a/src/main/java/com/voidsoftware/map02/services/PrescriptionsService.java +++ b/src/main/java/com/voidsoftware/map02/services/PrescriptionsService.java @@ -239,4 +239,15 @@ public class PrescriptionsService { }) .toList(); } + + public List listActivePrescriptionsForPatient(AppUser sessionUser) { + Patient patient = patientRepository.findByUserId(sessionUser.getId()) + .orElseThrow(() -> new IllegalArgumentException("Patient account is not linked to a patient profile")); + + ListPrescriptionsRequest request = new ListPrescriptionsRequest(); + request.setPatientId(patient.getId()); + request.setActiveOnly(true); + request.setScheduleFilter(ListPrescriptionsRequest.ScheduleFilter.ACTIVE_ONLY); + return listPrescriptions(request); + } }