medmap-ios/medmap/Views/DashboardView.swift
2026-09-16 14:43:57 +01:00

319 lines
11 KiB
Swift

import SwiftUI
struct DashboardView: View {
@StateObject private var viewModel = DashboardViewModel()
@State private var isShowingMonthlyHistory = false
@State private var isShowingLogoutConfirmation = false
var body: some View {
ZStack {
BouncingCirclesBackground(numberOfCircles: 6, blurStrength: 60)
.opacity(0.3)
.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
.frame(maxHeight: .infinity)
}
}
.padding(.top, 12)
}
.navigationBarHidden(true)
.task {
await viewModel.loadDashboard()
}
.refreshable {
await viewModel.loadDashboard()
}
.sheet(isPresented: $isShowingMonthlyHistory) {
PatientMonthlyHistorySheet()
.presentationBackground(.ultraThinMaterial)
}
.alert("Terminar sessao?", isPresented: $isShowingLogoutConfirmation) {
Button("Cancelar", role: .cancel) {}
Button("Terminar sessao", role: .destructive) {
Task { await viewModel.logout() }
}
} message: {
Text("Terá de iniciar sessao novamente para consultar a sua medicacao.")
}
}
// MARK: - Top bar
private var topBar: some View {
VStack(spacing: 8) {
HStack {
Image("timerIcon")
.foregroundStyle(Color.appBackground)
.frame(width: 32, height: 32)
Spacer()
HStack(spacing: 6) {
Image("bluetoothIcon")
.foregroundStyle(Color.appTextPrimary.opacity(0.55))
Circle()
.fill(.green)
.frame(width: 16, height: 16)
}
Button {
isShowingLogoutConfirmation = true
} label: {
Image(systemName: "power")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color.appTextPrimary.opacity(0.52))
.frame(width: 32, height: 32)
.background(.white.opacity(0.45))
.clipShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Terminar sessao")
}
.padding(.horizontal, 32)
if let logoutErrorMessage = viewModel.logoutErrorMessage {
Text(logoutErrorMessage)
.font(.jakartaRegular12)
.foregroundStyle(.red)
.frame(maxWidth: .infinity)
}
}
}
// MARK: - Patient header
// Mock adherence breakdown until the backend exposes real dose stats.
private let mockAdherenceSegments: [RadialRingSegment] = [
RadialRingSegment(percentage: 65, color: .green),
RadialRingSegment(percentage: 20, color: .orange),
RadialRingSegment(percentage: 15, color: .red)
]
private var patientHeader: some View {
VStack(spacing: 6) {
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())
}
}
.buttonStyle(.plain)
.accessibilityLabel("Ver histórico mensal")
Text(viewModel.patient?.name ?? "Doente")
.font(.lineSeedBold22)
.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 patientAvatar: some View {
if let photo = viewModel.patient?.photo, let image = decodedImage(from: photo) {
image.resizable().scaledToFill()
} else {
Color.appBorder
.overlay {
Image(systemName: "person.fill")
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundStyle(Color.appTextPrimary.opacity(0.4))
}
}
}
// MARK: - Reminder banner
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)
.frame(maxHeight: .infinity)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.frame(maxHeight: .infinity)
}
}
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(.lineSeedBold20)
.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 {
Text({
var attributed = AttributedString("Próxima toma às ")
var timePart = AttributedString("\(nextDoseTime)h")
timePart.inlinePresentationIntent = .stronglyEmphasized
attributed.append(timePart)
return attributed
}())
.font(.jakartaRegular14)
.foregroundStyle(Color.appBackground)
}
Divider()
doseTimesGrid
Spacer(minLength: 0)
}
.padding(16)
.frame(maxHeight: .infinity)
.background(Color.appBackgroundLight.opacity(0.11))
.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)
)
}
}
}
}
#Preview {
DashboardView()
}