patient detail

This commit is contained in:
Ricardo Araújo 2026-09-16 11:16:54 +01:00
parent c49034bd93
commit ba34d0e59b
7 changed files with 286 additions and 7 deletions

View File

@ -0,0 +1,29 @@
//
// PatientMonthlyHistoryResponse.swift
// medmap
//
import Foundation
struct PatientMonthlyHistoryResponse: Decodable {
let periodLabel: String
let selectedDate: String
let leadingDays: Int
let adherencePercent: Int
let takenCount: Int
let totalCount: Int
let days: [PatientHistoryDay]
}
struct PatientHistoryDay: Decodable, Identifiable {
let date: String
let number: String
let weekday: String
let total: Int
let taken: Int
let percent: Int
let tone: String
let today: Bool
var id: String { date }
}

View File

@ -66,6 +66,18 @@ class MedMapAPI {
return try await apiClient.request(url, method: .get)
}
func getMyMonthlyHistory(date: String? = nil) async throws -> PatientMonthlyHistoryResponse {
var components = URLComponents(string: "\(baseURL)/api/patients/me/history")
if let date {
components?.queryItems = [URLQueryItem(name: "date", value: date)]
}
guard let url = components?.url else {
throw URLError(.badURL)
}
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

@ -0,0 +1,54 @@
//
// PatientMonthlyHistoryViewModel.swift
// medmap
//
import Foundation
@MainActor
final class PatientMonthlyHistoryViewModel: ViewModel {
@Published private(set) var history: PatientMonthlyHistoryResponse?
@Published private(set) var errorMessage: String?
private let api: MedMapAPI
private let calendar = Calendar(identifier: .gregorian)
private var selectedMonth = Date.now
init(api: MedMapAPI = MedMapAPI()) {
self.api = api
super.init()
}
func loadHistory() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
history = try await api.getMyMonthlyHistory(date: formattedMonth)
} catch {
errorMessage = "Nao foi possivel carregar o historico mensal."
fluxlog("Monthly history load failed: \(error.localizedDescription)")
}
}
func showPreviousMonth() async {
selectedMonth = calendar.date(byAdding: .month, value: -1, to: selectedMonth) ?? selectedMonth
await loadHistory()
}
func showNextMonth() async {
selectedMonth = calendar.date(byAdding: .month, value: 1, to: selectedMonth) ?? selectedMonth
await loadHistory()
}
private var formattedMonth: String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: selectedMonth)
}
}

View File

@ -3,6 +3,7 @@ import SwiftUI
struct DashboardView: View {
@StateObject private var viewModel = DashboardViewModel()
@State private var isShowingMonthlyHistory = false
var body: some View {
ZStack {
@ -41,6 +42,10 @@ struct DashboardView: View {
.refreshable {
await viewModel.loadDashboard()
}
.sheet(isPresented: $isShowingMonthlyHistory) {
PatientMonthlyHistorySheet()
.presentationBackground(.clear)
}
}
// MARK: - Top bar
@ -76,14 +81,20 @@ struct DashboardView: View {
private var patientHeader: some View {
VStack(spacing: 6) {
ZStack {
RadialSegmentedRingView(segments: mockAdherenceSegments, lineWidth: 5, gapDegrees: 8)
.frame(width: 92, height: 92)
Button {
isShowingMonthlyHistory = true
} label: {
ZStack {
RadialSegmentedRingView(segments: mockAdherenceSegments, lineWidth: 5, gapDegrees: 8)
.frame(width: 92, height: 92)
patientAvatar
.frame(width: 78, height: 78)
.clipShape(Circle())
patientAvatar
.frame(width: 78, height: 78)
.clipShape(Circle())
}
}
.buttonStyle(.plain)
.accessibilityLabel("Ver histórico mensal")
Text(viewModel.patient?.name ?? "Doente")
.font(.lineSeedBold22)

View File

@ -0,0 +1,144 @@
//
// PatientMonthlyHistorySheet.swift
// medmap
//
import SwiftUI
struct PatientMonthlyHistorySheet: View {
@StateObject private var viewModel = PatientMonthlyHistoryViewModel()
@Environment(\.dismiss) private var dismiss
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7), count: 7)
var body: some View {
ZStack {
Color.appBackgroundLight.opacity(0.22).ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
VStack(spacing: 20) {
header
content
}
.padding(20)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 22))
.shadow(color: .black.opacity(0.18), radius: 15, y: 8)
.padding(.horizontal, 32)
Spacer(minLength: 0)
Button(action: dismiss.callAsFunction) {
Image(systemName: "xmark")
.font(.system(size: 21, weight: .medium))
.foregroundStyle(Color.appAccent)
.frame(width: 44, height: 44)
}
.padding(.bottom, 48)
}
}
.task {
await viewModel.loadHistory()
}
}
private var header: some View {
HStack {
Label("Histórico", systemImage: "hand.tap")
.font(.jakartaRegular14)
.foregroundStyle(Color.appBackground)
Spacer()
Button {
Task { await viewModel.showPreviousMonth() }
} label: {
Image(systemName: "chevron.left")
}
.buttonStyle(.plain)
.foregroundStyle(Color.appAccent)
Text(viewModel.history?.periodLabel ?? "Histórico")
.font(.jakartaBold16)
.foregroundStyle(Color.appAccent)
.frame(minWidth: 94)
Button {
Task { await viewModel.showNextMonth() }
} label: {
Image(systemName: "chevron.right")
}
.buttonStyle(.plain)
.foregroundStyle(Color.appAccent)
Image(systemName: "calendar")
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(Color.appAccent)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
}
@ViewBuilder
private var content: some View {
if viewModel.isLoading && viewModel.history == nil {
ProgressView().frame(height: 185)
} else if let errorMessage = viewModel.errorMessage {
VStack(spacing: 12) {
Text(errorMessage)
.font(.jakartaRegular14)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
Button("Tentar novamente") {
Task { await viewModel.loadHistory() }
}
.font(.jakartaBold14)
.foregroundStyle(Color.appAccent)
}
.frame(height: 185)
} else if let history = viewModel.history {
LazyVGrid(columns: columns, spacing: 11) {
ForEach(0..<history.leadingDays, id: \.self) { _ in
Color.clear.frame(height: 32)
}
ForEach(history.days) { day in
CalendarDayView(day: day)
}
}
}
}
}
private struct CalendarDayView: View {
let day: PatientHistoryDay
private var toneColor: Color {
switch day.tone {
case "good": return Color(hex: "#6DD655")
case "warning": return Color(hex: "#F3B84E")
case "bad": return Color(hex: "#F45A68")
default: return Color(hex: "#D7DCE5")
}
}
var body: some View {
ZStack {
Circle()
.stroke(toneColor, lineWidth: 3)
.frame(width: 30, height: 30)
VStack(spacing: 0) {
Text(day.number)
.font(.system(size: 9, weight: .bold))
Text(day.weekday)
.font(.system(size: 6, weight: .medium))
}
.foregroundStyle(Color.appAccent)
}
.frame(height: 32)
.accessibilityLabel(day.date)
}
}

View File

@ -5,6 +5,8 @@ import com.voidsoftware.map02.entities.AppUser;
import com.voidsoftware.map02.models.dto.PatientDTO;
import com.voidsoftware.map02.models.inout.*;
import com.voidsoftware.map02.services.PatientsService;
import com.voidsoftware.map02.services.PatientDetailService;
import com.voidsoftware.map02.models.view.PatientDetailView;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@ -15,6 +17,8 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
@Tag(name = "Patients", description = "Manage patients and their prescriptions")
@RestController
@ -22,10 +26,12 @@ import java.util.UUID;
public class PatientsController {
private final PatientsService patientsService;
private final PatientDetailService patientDetailService;
public PatientsController(PatientsService patientsService) {
public PatientsController(PatientsService patientsService, PatientDetailService patientDetailService) {
this.patientsService = patientsService;
this.patientDetailService = patientDetailService;
}
@ -104,4 +110,20 @@ public class PatientsController {
return ResponseEntity.ok(patientsService.getMyProfile(sessionUser));
}
@Operation(summary = "Get my monthly medication history", description = "Returns the authenticated patient's month of dose history and daily adherence.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Monthly history returned"),
@ApiResponse(responseCode = "403", description = "Only patient accounts can access this endpoint")
})
@GetMapping("/me/history")
public ResponseEntity<PatientDetailView> getMyMonthlyHistory(
@CurrentUser AppUser sessionUser,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
if (sessionUser == null || sessionUser.getRole() != com.voidsoftware.map02.models.Role.PATIENT) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(patientDetailService.buildMonthlyForPatient(sessionUser, date));
}
}

View File

@ -52,6 +52,13 @@ public class PatientDetailService {
.map(p -> buildAuthorized(p, "monthly".equals(mode) ? "monthly" : "weekly", selectedDate));
}
@Transactional(readOnly = true)
public PatientDetailView buildMonthlyForPatient(AppUser sessionUser, LocalDate selectedDate) {
Patient patient = patients.findByUserId(sessionUser.getId())
.orElseThrow(() -> new IllegalArgumentException("Patient account is not linked to a patient profile"));
return buildAuthorized(patient, "monthly", selectedDate);
}
private PatientDetailView buildAuthorized(Patient patient, String mode, LocalDate selectedDate) {
ZoneId zone = ZoneId.of(patient.getTimezone());
LocalDate today = LocalDate.now(zone);