From d082bf699f04df71655ab4f21a35242115220120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ara=C3=BAjo?= Date: Tue, 15 Sep 2026 17:22:42 +0100 Subject: [PATCH] login fix --- medmap/Network/MedMapAPI.swift | 5 + medmap/ViewModels/DashboardViewModel.swift | 75 ++-- medmap/Views/DashboardView.swift | 331 ++++++++++++------ .../map02/controllers/PatientsController.java | 14 + .../map02/services/PatientsService.java | 6 + 5 files changed, 293 insertions(+), 138 deletions(-) diff --git a/medmap/Network/MedMapAPI.swift b/medmap/Network/MedMapAPI.swift index 1063243..6006244 100644 --- a/medmap/Network/MedMapAPI.swift +++ b/medmap/Network/MedMapAPI.swift @@ -61,6 +61,11 @@ class MedMapAPI { return try await apiClient.request(url, method: .get) } + func getMyPatientProfile() async throws -> PatientDTO { + let url = "\(baseURL)/api/patients/me" + return try await apiClient.request(url, method: .get) + } + func listPatients(professionalId: String) async throws -> PatientsByProfessionalResponse { let url = "\(baseURL)/api/patients/list" return try await apiClient.request(url, method: .post, parameters: ListPatientsRequest(professionalId: professionalId)) diff --git a/medmap/ViewModels/DashboardViewModel.swift b/medmap/ViewModels/DashboardViewModel.swift index 0415e18..074b4e9 100644 --- a/medmap/ViewModels/DashboardViewModel.swift +++ b/medmap/ViewModels/DashboardViewModel.swift @@ -1,11 +1,13 @@ import Foundation import SwiftUI import Combine + @MainActor final class DashboardViewModel: ViewModel { + @Published private(set) var patient: PatientDTO? @Published private(set) var prescriptions: [PrescriptionWithSchedulesResponse] = [] - @Published private(set) var medicationNames: [String: String] = [:] + @Published private(set) var medications: [String: MedicationDTO] = [:] @Published private(set) var errorMessage: String? private let api: MedMapAPI @@ -15,23 +17,36 @@ final class DashboardViewModel: ViewModel { super.init() } - var nextDose: DashboardDose? { - scheduledDoses.first(where: { $0.time >= currentTime }) ?? scheduledDoses.first + var patientAge: Int? { + guard let dateOfBirth = patient?.dateOfBirth else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + guard let birthDate = formatter.date(from: dateOfBirth) else { return nil } + return Calendar.current.dateComponents([.year], from: birthDate, to: .now).year } - 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) - ) - } - } + var totalDosesToday: Int { + medicationCards.reduce(0) { $0 + $1.doseTimes.count } + } + + var medicationCards: [MedicationCard] { + prescriptions.map { response in + let times = response.schedules + .flatMap(\.timesOfDay) + .sorted() + let medication = medications[response.medicationId] + + return MedicationCard( + id: response.prescription.id, + medicationId: response.medicationId, + name: medication?.name ?? "Medicacao prescrita", + tag: medication?.activeIngredient ?? medication?.dosageForm ?? "Ativo", + photoBase64: medication?.medicationPhoto, + doseTimes: times, + nextDoseTime: times.first(where: { $0 >= currentTime }) ?? times.first + ) } - .sorted { $0.time < $1.time } } func loadDashboard() async { @@ -42,13 +57,15 @@ final class DashboardViewModel: ViewModel { defer { isLoading = false } do { + async let myPatient = api.getMyPatientProfile() async let activePrescriptions = api.listMyActivePrescriptions() - async let medications = api.listMedications() + async let medicationsResponse = api.listMedications() - let (prescriptions, medicationResponse) = try await (activePrescriptions, medications) + let (patient, prescriptions, medicationsResponseValue) = try await (myPatient, activePrescriptions, medicationsResponse) + self.patient = patient self.prescriptions = prescriptions - medicationNames = Dictionary( - uniqueKeysWithValues: medicationResponse.medications.map { ($0.medicationId, $0.name) } + medications = Dictionary( + uniqueKeysWithValues: medicationsResponseValue.medications.map { ($0.medicationId, $0) } ) } catch { errorMessage = "Nao foi possivel carregar a sua medicacao." @@ -62,20 +79,16 @@ final class DashboardViewModel: ViewModel { 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)" } +struct MedicationCard: Identifiable { + let id: String + let medicationId: String + let name: String + let tag: String + let photoBase64: String? + let doseTimes: [String] + let nextDoseTime: String? } // Created by RSA on 28/08/2026. // diff --git a/medmap/Views/DashboardView.swift b/medmap/Views/DashboardView.swift index 01cb0d6..050ced1 100644 --- a/medmap/Views/DashboardView.swift +++ b/medmap/Views/DashboardView.swift @@ -5,15 +5,33 @@ struct DashboardView: View { @StateObject private var viewModel = DashboardViewModel() var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - header - nextDoseCard - todaySection + ZStack { + Color.appSecondary.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 20) { + topBar + patientHeader + reminderBanner + + if viewModel.isLoading && viewModel.medicationCards.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else if let errorMessage = viewModel.errorMessage { + errorState(errorMessage) + } else if viewModel.medicationCards.isEmpty { + Text("Nao ha medicacao ativa para mostrar.") + .font(.jakartaRegular16) + .foregroundStyle(Color.appTextPrimary.opacity(0.65)) + .padding(.horizontal, 20) + } else { + medicationCarousel + } + + Spacer(minLength: 0) } - .padding(20) + .padding(.top, 12) } - .background(Color.appSecondary.ignoresSafeArea()) .navigationBarHidden(true) .task { await viewModel.loadDashboard() @@ -23,114 +41,216 @@ struct DashboardView: View { } } - 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)) + // MARK: - Top bar + + private var topBar: some View { + HStack { + Circle() + .fill(Color.appBackground) + .frame(width: 40, height: 40) + .overlay { + Image(systemName: "person.fill") + .foregroundStyle(.white) + } + + Spacer() + + HStack(spacing: 6) { + Image(systemName: "dot.radiowaves.left.and.right") + .foregroundStyle(Color.appTextPrimary.opacity(0.55)) + Circle() + .fill(.green) + .frame(width: 8, height: 8) + } } + .padding(.horizontal, 20) + } + + // MARK: - Patient header + + private var patientHeader: some View { + VStack(spacing: 6) { + ZStack { + Circle() + .stroke(Color.green, lineWidth: 3) + .frame(width: 92, height: 92) + + patientAvatar + .frame(width: 78, height: 78) + .clipShape(Circle()) + } + + Text(viewModel.patient?.name ?? "Doente") + .font(.jakartaBold18) + .foregroundStyle(Color.appAccent) + + if let age = viewModel.patientAge { + Text("\(age) Anos") + .font(.jakartaRegular14) + .foregroundStyle(Color.appTextPrimary.opacity(0.55)) + } + + Text("🙂") + .font(.system(size: 20)) + .padding(.top, 2) + } + .frame(maxWidth: .infinity) } @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)) + private var patientAvatar: some View { + if let photo = viewModel.patient?.photo, let image = decodedImage(from: photo) { + image.resizable().scaledToFill() } 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)) + Color.appBorder + .overlay { + Image(systemName: "person.fill") + .foregroundStyle(Color.appTextPrimary.opacity(0.4)) + } } } - private var todaySection: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Medicacao de hoje") - .font(.jakartaBold18) - .foregroundStyle(Color.appTextPrimary) + // MARK: - Reminder banner - 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)) + private var reminderBanner: some View { + VStack(spacing: 4) { + Text("Não se esqueça!") + .font(.jakartaBold16) + .foregroundStyle(Color.appAccent) + + Group { + Text("Tem ") + + Text("\(viewModel.totalDosesToday) Comprimidos") + .fontWeight(.bold) + + Text(" para tomar hoje.") + } + .font(.jakartaRegular14) + .foregroundStyle(Color.appAccent) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + + // MARK: - Medication carousel + + private var medicationCarousel: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(viewModel.medicationCards) { card in + MedicationCardView(card: card) + .frame(width: 260) } } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + + private func errorState(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(message) + .font(.jakartaRegular16) + .foregroundStyle(.red) + Button("Tentar novamente") { + Task { await viewModel.loadDashboard() } + } + .font(.jakartaBold16) + .foregroundStyle(Color.appBackground) + } + .padding(.horizontal, 20) + } + + private func decodedImage(from base64String: String) -> Image? { + guard let data = Data(base64Encoded: base64String), let uiImage = UIImage(data: data) else { + return nil + } + return Image(uiImage: uiImage) + } +} + +private struct MedicationCardView: View { + + let card: MedicationCard + + var body: some View { + VStack(spacing: 14) { + medicationImage + .frame(height: 140) + .frame(maxWidth: .infinity) + .background(Color.appSecondary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(alignment: .topTrailing) { + Image(systemName: "info.circle.fill") + .foregroundStyle(Color.appBackground) + .padding(8) + .background(.white) + .clipShape(Circle()) + .padding(8) + } + + Text(card.name) + .font(.jakartaBold16) + .foregroundStyle(Color.appAccent) + .multilineTextAlignment(.center) + + Text(card.tag) + .font(.jakartaMedium14) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.appBackground) + .clipShape(Capsule()) + + if let nextDoseTime = card.nextDoseTime { + Group { + Text("Próxima toma às ") + + Text("\(nextDoseTime)h").fontWeight(.bold) + } + .font(.jakartaRegular14) + .foregroundStyle(Color.appBackground) + } + + Divider() + + doseTimesGrid + } + .padding(16) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.06), radius: 8, y: 4) + } + + @ViewBuilder + private var medicationImage: some View { + if let photoBase64 = card.photoBase64, + let data = Data(base64Encoded: photoBase64), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .clipped() + } else { + Image(systemName: "pills.fill") + .font(.system(size: 40)) + .foregroundStyle(Color.appBackground.opacity(0.5)) + } + } + + private var doseTimesGrid: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + ForEach(card.doseTimes, id: \.self) { time in + HStack(spacing: 6) { + Image(systemName: time == card.nextDoseTime ? "bell.fill" : "clock") + .font(.system(size: 13)) + Text("\(time)") + .font(time == card.nextDoseTime ? .jakartaBold14 : .jakartaRegular14) + } + .foregroundStyle( + time == card.nextDoseTime + ? Color.appBackground + : Color.appTextPrimary.opacity(0.4) + ) + } } } } @@ -138,6 +258,3 @@ struct DashboardView: View { #Preview { DashboardView() } -// Created by RSA on 28/08/2026. -// - diff --git a/src/main/java/com/voidsoftware/map02/controllers/PatientsController.java b/src/main/java/com/voidsoftware/map02/controllers/PatientsController.java index 3b711db..a8673a0 100644 --- a/src/main/java/com/voidsoftware/map02/controllers/PatientsController.java +++ b/src/main/java/com/voidsoftware/map02/controllers/PatientsController.java @@ -90,4 +90,18 @@ public class PatientsController { public ResponseEntity getPatientMedicationDayPeriods(@CurrentUser AppUser sessionUser) { return ResponseEntity.ok(this.patientsService.getPatientMedicationDayPeriods(sessionUser)); } + + @Operation(summary = "Get my patient profile", description = "Returns the authenticated patient's own profile.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Patient found"), + @ApiResponse(responseCode = "403", description = "Only patient accounts can access this endpoint") + }) + @GetMapping("/me") + public ResponseEntity getMyPatientProfile(@CurrentUser AppUser sessionUser) { + if (sessionUser == null || sessionUser.getRole() != com.voidsoftware.map02.models.Role.PATIENT) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(patientsService.getMyProfile(sessionUser)); + } } diff --git a/src/main/java/com/voidsoftware/map02/services/PatientsService.java b/src/main/java/com/voidsoftware/map02/services/PatientsService.java index 9fc3e18..a783f14 100644 --- a/src/main/java/com/voidsoftware/map02/services/PatientsService.java +++ b/src/main/java/com/voidsoftware/map02/services/PatientsService.java @@ -99,6 +99,12 @@ public class PatientsService { return this.dataConversionService.convertToPatientDTO(patient.get()); } + public PatientDTO getMyProfile(AppUser sessionUser) { + Patient patient = patientRepository.findByUserId(sessionUser.getId()) + .orElseThrow(() -> new IllegalArgumentException("Patient account is not linked to a patient profile")); + return this.dataConversionService.convertToPatientDTO(patient); + } + public List getPatientsListByProfessional(AppUser sessionUser, ListPatientsRequest request) { if (sessionUser.getRole().equals(Role.PATIENT)) {