login fix

This commit is contained in:
Ricardo Araújo 2026-09-15 17:22:42 +01:00
parent 7ffbe04e37
commit d082bf699f
5 changed files with 293 additions and 138 deletions

View File

@ -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))

View File

@ -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.
//

View File

@ -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.
//

View File

@ -90,4 +90,18 @@ public class PatientsController {
public ResponseEntity<ListOfPatientPeriods> 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<PatientDTO> 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));
}
}

View File

@ -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<PatientDTO> getPatientsListByProfessional(AppUser sessionUser, ListPatientsRequest request) {
if (sessionUser.getRole().equals(Role.PATIENT)) {