319 lines
11 KiB
Swift
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()
|
|
}
|