diff --git a/medmap/Models/IO/PatientMonthlyHistoryResponse.swift b/medmap/Models/IO/PatientMonthlyHistoryResponse.swift new file mode 100644 index 0000000..41801e5 --- /dev/null +++ b/medmap/Models/IO/PatientMonthlyHistoryResponse.swift @@ -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 } +} \ No newline at end of file diff --git a/medmap/Network/MedMapAPI.swift b/medmap/Network/MedMapAPI.swift index d56a1ee..39a02c3 100644 --- a/medmap/Network/MedMapAPI.swift +++ b/medmap/Network/MedMapAPI.swift @@ -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)) diff --git a/medmap/ViewModels/PatientMonthlyHistoryViewModel.swift b/medmap/ViewModels/PatientMonthlyHistoryViewModel.swift new file mode 100644 index 0000000..41cbb21 --- /dev/null +++ b/medmap/ViewModels/PatientMonthlyHistoryViewModel.swift @@ -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) + } +} \ No newline at end of file diff --git a/medmap/Views/DashboardView.swift b/medmap/Views/DashboardView.swift index 46c8e67..c8e29a0 100644 --- a/medmap/Views/DashboardView.swift +++ b/medmap/Views/DashboardView.swift @@ -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) diff --git a/medmap/Views/PatientMonthlyHistorySheet.swift b/medmap/Views/PatientMonthlyHistorySheet.swift new file mode 100644 index 0000000..014217d --- /dev/null +++ b/medmap/Views/PatientMonthlyHistorySheet.swift @@ -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.. 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)); + } } diff --git a/src/main/java/com/voidsoftware/map02/services/PatientDetailService.java b/src/main/java/com/voidsoftware/map02/services/PatientDetailService.java index 02d4a2f..f99e027 100644 --- a/src/main/java/com/voidsoftware/map02/services/PatientDetailService.java +++ b/src/main/java/com/voidsoftware/map02/services/PatientDetailService.java @@ -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);