dashboard

This commit is contained in:
Ricardo Araújo 2026-09-15 15:17:44 +01:00
parent e095e0d0bb
commit a483f06f39
11 changed files with 290 additions and 8 deletions

View File

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

View File

@ -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]
}

View File

@ -10,5 +10,6 @@
struct PrescriptionWithSchedulesResponse: Decodable {
let prescription: PrescriptionResponse
let medicationId: String
let schedules: [PrescriptionScheduleResponse]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<List<PrescriptionWithSchedulesResponse>> listMyActivePrescriptions(
@CurrentUser AppUser sessionUser) {
if (sessionUser == null || sessionUser.getRole() != Role.PATIENT) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(prescriptionsService.listActivePrescriptionsForPatient(sessionUser));
}
}

View File

@ -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<PrescriptionSchedule> schedules;
public PrescriptionWithSchedulesResponse(Prescription prescription,
List<PrescriptionSchedule> schedules) {
this.prescription = prescription;
this.medicationId = prescription.getMedication().getId();
this.schedules = schedules;
}
}

View File

@ -239,4 +239,15 @@ public class PrescriptionsService {
})
.toList();
}
public List<PrescriptionWithSchedulesResponse> 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);
}
}