improvements on design
This commit is contained in:
parent
6c362e0d95
commit
c49034bd93
BIN
Screenshot 2026-09-15 at 20.50.41.png
Normal file
BIN
Screenshot 2026-09-15 at 20.50.41.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@ -35,9 +35,22 @@
|
||||
53AF1523303F8BCD00326308 /* medmapUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = medmapUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
531881B93059F606003A4FAC /* Exceptions for "medmap" folder in "medmap" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 53AF1509303F8BCB00326308 /* medmap */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
53AF150C303F8BCB00326308 /* medmap */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
531881B93059F606003A4FAC /* Exceptions for "medmap" folder in "medmap" target */,
|
||||
);
|
||||
path = medmap;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -417,6 +430,7 @@
|
||||
DEVELOPMENT_TEAM = KNLHKLUVSG;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = medmap/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@ -450,6 +464,7 @@
|
||||
DEVELOPMENT_TEAM = KNLHKLUVSG;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = medmap/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
|
||||
15
medmap/Assets.xcassets/bluetoothIcon.imageset/Contents.json
vendored
Normal file
15
medmap/Assets.xcassets/bluetoothIcon.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bluetoothIcon.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
5
medmap/Assets.xcassets/bluetoothIcon.imageset/bluetoothIcon.svg
vendored
Normal file
5
medmap/Assets.xcassets/bluetoothIcon.imageset/bluetoothIcon.svg
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.3333 15.9994L21.1468 11.4152C22.166 10.4446 22.6754 9.95927 22.6529 9.35837C22.6304 8.75747 22.0857 8.3118 20.9966 7.42047L20.3574 6.89735C18.4702 5.35295 17.5268 4.58075 16.7642 4.942C16.0018 5.30324 16.0018 6.52251 16.0018 8.96103V15.6838M16.3333 15.9994L16.0018 16.3152M16.3333 15.9994L21.1468 20.5838C22.166 21.5544 22.6754 22.0397 22.6529 22.6406C22.6304 23.2414 22.0857 23.6872 20.9966 24.5785L20.3574 25.1016C18.4702 26.646 17.5268 27.4182 16.7642 27.057C16.0018 26.6957 16.0018 25.4765 16.0018 23.038V16.3152M16.3333 15.9994L16.0018 15.6838M16.0018 15.6838V16.3152M16.0018 15.6838L9.33331 9.33279M16.0018 16.3152L9.33331 22.6661" stroke="#141B34" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22.6667 16H24" stroke="#141B34" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.33333 16H8" stroke="#141B34" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
15
medmap/Assets.xcassets/timerIcon.imageset/Contents.json
vendored
Normal file
15
medmap/Assets.xcassets/timerIcon.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "timerIcon.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
3
medmap/Assets.xcassets/timerIcon.imageset/timerIcon.svg
vendored
Normal file
3
medmap/Assets.xcassets/timerIcon.imageset/timerIcon.svg
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.8242 0C45.352 0 55.5078 10.1558 55.5078 22.6836V32.8242C55.5078 45.3521 45.3521 55.5078 32.8242 55.5078H22.6836C10.1558 55.5078 0 45.352 0 32.8242V22.6836C3.67278e-05 10.1558 10.1558 3.49374e-05 22.6836 0H32.8242ZM23.2168 35.5303C20.567 35.5305 18.419 37.6793 18.4189 40.3291C18.4193 42.9787 20.5672 45.1267 23.2168 45.127H33.75C36.3997 45.1269 38.5475 42.9787 38.5479 40.3291C38.5478 37.6792 36.3999 35.5304 33.75 35.5303H23.2168ZM26.6748 38.2754V42.3818H23.2168C22.0832 42.3816 21.1644 41.4627 21.1641 40.3291C21.1641 39.1953 22.083 38.2756 23.2168 38.2754H26.6748ZM33.75 38.2754C34.8839 38.2755 35.8036 39.1952 35.8037 40.3291C35.8033 41.4628 34.8837 42.3817 33.75 42.3818H30.292V38.2754H33.75ZM28.3311 10.0391C27.5732 10.0391 26.9592 10.6533 26.959 11.4111V12.9297C22.1238 13.6644 18.4189 17.8405 18.4189 22.8809C18.4193 28.439 22.9252 32.9452 28.4834 32.9453C34.0417 32.9452 38.5475 28.4391 38.5479 22.8809C38.5479 17.7353 34.6863 13.4938 29.7031 12.8916V11.4111C29.7029 10.6534 29.0888 10.0392 28.3311 10.0391ZM28.4834 15.5605C32.5259 15.5606 35.8037 18.8383 35.8037 22.8809C35.8033 26.9231 32.5257 30.2001 28.4834 30.2002C24.4411 30.2001 21.1644 26.9231 21.1641 22.8809C21.1641 18.8383 24.4409 15.5607 28.4834 15.5605ZM28.4834 18.2734C27.7255 18.2735 27.1113 18.8886 27.1113 19.6465V23.001L27.1182 23.1416C27.1887 23.8334 27.773 24.3739 28.4834 24.374H31.5332C32.291 24.3739 32.906 23.7587 32.9062 23.001C32.9062 22.2431 32.2911 21.629 31.5332 21.6289H29.8564V19.6465C29.8564 18.8885 29.2413 18.2735 28.4834 18.2734Z" fill="#4078BC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@ -47,7 +47,7 @@ extension Color {
|
||||
static let appBackground = Color(hex: "#4078BC")
|
||||
static let appBackgroundDark = Color(hex: "##4078BC")
|
||||
|
||||
|
||||
static let appBackgroundLight = Color(hex: "#4078BC")
|
||||
|
||||
|
||||
|
||||
|
||||
@ -11,10 +11,20 @@ extension Font {
|
||||
|
||||
static let interRegular = "Inter Regular"
|
||||
static let interBold = "Inter Bold"
|
||||
|
||||
|
||||
static let lineSeedBold = "LINE Seed JP Bold"
|
||||
|
||||
static let lineSeedRegular = " LINE Seed JP Regular"
|
||||
|
||||
|
||||
static let redHatSemibold = "Red Hat Display SemiBold"
|
||||
static let redHat = "Red Hat Display Regular"
|
||||
|
||||
|
||||
|
||||
static let lineSeedBold22 = Font.custom(lineSeedBold, size: 22)
|
||||
|
||||
static let lineSeedBold20 = Font.custom(lineSeedBold, size: 20)
|
||||
static let jakartaRegular24 = Font.custom(jakartaRegularName, size: 24)
|
||||
static let jakartaRegular64 = Font.custom(jakartaRegularName, size: 64)
|
||||
static let jakartaRegular60 = Font.custom(jakartaRegularName, size: 60)
|
||||
|
||||
13
medmap/Info.plist
Normal file
13
medmap/Info.plist
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>LINESeedJP-Bold.ttf</string>
|
||||
<string>LINESeedJP-ExtraBold.ttf</string>
|
||||
<string>LINESeedJP-Regular.ttf</string>
|
||||
<string>LINESeedJP-Thin.ttf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
medmap/Resources/Fonts/LINESeedJP-Bold.ttf
Normal file
BIN
medmap/Resources/Fonts/LINESeedJP-Bold.ttf
Normal file
Binary file not shown.
BIN
medmap/Resources/Fonts/LINESeedJP-ExtraBold.ttf
Normal file
BIN
medmap/Resources/Fonts/LINESeedJP-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
medmap/Resources/Fonts/LINESeedJP-Regular.ttf
Normal file
BIN
medmap/Resources/Fonts/LINESeedJP-Regular.ttf
Normal file
Binary file not shown.
BIN
medmap/Resources/Fonts/LINESeedJP-Thin.ttf
Normal file
BIN
medmap/Resources/Fonts/LINESeedJP-Thin.ttf
Normal file
Binary file not shown.
@ -9,8 +9,8 @@ import Combine
|
||||
@MainActor
|
||||
final class LoginViewModel: ViewModel {
|
||||
|
||||
@Published var email = ""
|
||||
@Published var password = ""
|
||||
@Published var email = "katia.carvalho564@mail.pt"
|
||||
@Published var password = "2fuckpprod"
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var authenticatedPatient: LoginResponse?
|
||||
|
||||
|
||||
64
medmap/Views/Components/RadialSegmentedRingView.swift
Normal file
64
medmap/Views/Components/RadialSegmentedRingView.swift
Normal file
@ -0,0 +1,64 @@
|
||||
//
|
||||
// RadialSegmentedRingView.swift
|
||||
// medmap
|
||||
//
|
||||
// Created by RSA on 15/09/2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A single colored arc segment of a `RadialSegmentedRingView`.
|
||||
struct RadialRingSegment: Identifiable {
|
||||
let id = UUID()
|
||||
let percentage: Double
|
||||
let color: Color
|
||||
}
|
||||
|
||||
/// Renders a ring made of proportional colored arc segments, with a small gap between each segment.
|
||||
struct RadialSegmentedRingView: View {
|
||||
|
||||
let segments: [RadialRingSegment]
|
||||
var lineWidth: CGFloat = 6
|
||||
var gapDegrees: Double = 6
|
||||
|
||||
private var total: Double {
|
||||
max(segments.reduce(0) { $0 + $1.percentage }, 0.0001)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(Array(segments.enumerated()), id: \.element.id) { index, segment in
|
||||
let (start, end) = arcRange(for: index)
|
||||
Circle()
|
||||
.trim(from: start, to: end)
|
||||
.stroke(segment.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the normalized (0...1) start/end trim values for the segment at `index`, leaving a small gap between segments.
|
||||
private func arcRange(for index: Int) -> (CGFloat, CGFloat) {
|
||||
let gapFraction = gapDegrees / 360
|
||||
var start = 0.0
|
||||
for i in 0..<index {
|
||||
start += segments[i].percentage / total
|
||||
}
|
||||
let length = segments[index].percentage / total
|
||||
let clampedGap = min(gapFraction, length / 2)
|
||||
return (CGFloat(start + clampedGap), CGFloat(start + length - clampedGap))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RadialSegmentedRingView(
|
||||
segments: [
|
||||
RadialRingSegment(percentage: 60, color: .green),
|
||||
RadialRingSegment(percentage: 25, color: .orange),
|
||||
RadialRingSegment(percentage: 15, color: .red)
|
||||
],
|
||||
lineWidth: 6
|
||||
)
|
||||
.frame(width: 92, height: 92)
|
||||
.padding(40)
|
||||
}
|
||||
@ -2,259 +2,273 @@ import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
|
||||
@StateObject private var viewModel = DashboardViewModel()
|
||||
@StateObject private var viewModel = DashboardViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appSecondary.ignoresSafeArea()
|
||||
var body: some View {
|
||||
ZStack {
|
||||
BouncingCirclesBackground(numberOfCircles: 6, blurStrength: 60)
|
||||
.opacity(0.3)
|
||||
.ignoresSafeArea()
|
||||
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
topBar
|
||||
patientHeader
|
||||
reminderBanner
|
||||
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
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.task {
|
||||
await viewModel.loadDashboard()
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.loadDashboard()
|
||||
}
|
||||
}
|
||||
// MARK: - Top bar
|
||||
|
||||
// MARK: - Top bar
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Image("timerIcon")
|
||||
.foregroundStyle(Color.appBackground)
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.appBackground)
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
Image(systemName: "person.fill")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
Image("bluetoothIcon")
|
||||
.foregroundStyle(Color.appTextPrimary.opacity(0.55))
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.foregroundStyle(Color.appTextPrimary.opacity(0.55))
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
// MARK: - Patient header
|
||||
|
||||
// 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) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.green, lineWidth: 3)
|
||||
.frame(width: 92, height: 92)
|
||||
private var patientHeader: some View {
|
||||
VStack(spacing: 6) {
|
||||
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())
|
||||
}
|
||||
|
||||
Text(viewModel.patient?.name ?? "Doente")
|
||||
.font(.jakartaBold18)
|
||||
.foregroundStyle(Color.appAccent)
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
.foregroundStyle(Color.appTextPrimary.opacity(0.4))
|
||||
}
|
||||
}
|
||||
}
|
||||
@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
|
||||
// MARK: - Reminder banner
|
||||
|
||||
private var reminderBanner: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text("Não se esqueça!")
|
||||
.font(.jakartaBold16)
|
||||
.foregroundStyle(Color.appAccent)
|
||||
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)
|
||||
}
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
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 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 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
|
||||
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)
|
||||
}
|
||||
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(.jakartaBold16)
|
||||
.foregroundStyle(Color.appAccent)
|
||||
.multilineTextAlignment(.center)
|
||||
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())
|
||||
Text(card.tag)
|
||||
.font(.jakartaMedium14)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.appBackground)
|
||||
.clipShape(Capsule())
|
||||
|
||||
if let nextDoseTime = card.nextDoseTime {
|
||||
Group {
|
||||
Text("Próxima toma às ") +
|
||||
Text("\(nextDoseTime)h").fontWeight(.bold)
|
||||
}
|
||||
.font(.jakartaRegular14)
|
||||
.foregroundStyle(Color.appBackground)
|
||||
}
|
||||
if let nextDoseTime = card.nextDoseTime {
|
||||
Group {
|
||||
Text("Próxima toma às ") +
|
||||
Text("\(nextDoseTime)h").fontWeight(.bold)
|
||||
}
|
||||
.font(.jakartaRegular14)
|
||||
.foregroundStyle(Color.appBackground)
|
||||
}
|
||||
|
||||
Divider()
|
||||
Divider()
|
||||
|
||||
doseTimesGrid
|
||||
}
|
||||
.padding(16)
|
||||
.background(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 4)
|
||||
}
|
||||
doseTimesGrid
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@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()
|
||||
DashboardView()
|
||||
}
|
||||
|
||||
154
medmap/Views/FloatingCircle.swift
Normal file
154
medmap/Views/FloatingCircle.swift
Normal file
@ -0,0 +1,154 @@
|
||||
//
|
||||
// FloatingCircle.swift
|
||||
// medmap
|
||||
//
|
||||
// Created by RSA on 15/09/2026.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
private struct FloatingCircle: Identifiable {
|
||||
let id = UUID()
|
||||
var position: CGPoint
|
||||
var velocity: CGVector
|
||||
let radius: CGFloat
|
||||
let color: Color
|
||||
}
|
||||
|
||||
// MARK: - Background View
|
||||
|
||||
/// A background of colored, blurred circles that bounce around the screen
|
||||
/// with simple physics (constant velocity + reflection off the edges).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// ZStack {
|
||||
/// BouncingCirclesBackground(numberOfCircles: 8, blurStrength: 60)
|
||||
/// YourContent()
|
||||
/// }
|
||||
/// ```
|
||||
struct BouncingCirclesBackground: View {
|
||||
|
||||
/// How many circles to animate.
|
||||
var numberOfCircles: Int = 6
|
||||
|
||||
/// Gaussian blur radius applied to the whole layer of circles.
|
||||
/// Higher = softer, more "ambient glow" look.
|
||||
var blurStrength: CGFloat = 40
|
||||
|
||||
/// Palette the circles are drawn from (cycled if there are more circles than colors).
|
||||
var colors: [Color] = [.pink, .purple, .blue, .orange, .mint, .yellow, .red, .teal]
|
||||
|
||||
/// Range for circle radii (before scaling).
|
||||
var radiusRange: ClosedRange<CGFloat> = 60...140
|
||||
|
||||
/// Range for movement speed, in points per second.
|
||||
var speedRange: ClosedRange<CGFloat> = 25...65
|
||||
|
||||
@State private var circles: [FloatingCircle] = []
|
||||
@State private var lastTimestamp: Date = Date()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
TimelineView(.animation) { timeline in
|
||||
Canvas { context, size in
|
||||
for circle in circles {
|
||||
let rect = CGRect(
|
||||
x: circle.position.x - circle.radius,
|
||||
y: circle.position.y - circle.radius,
|
||||
width: circle.radius * 2,
|
||||
height: circle.radius * 2
|
||||
)
|
||||
context.fill(Path(ellipseIn: rect), with: .color(circle.color))
|
||||
}
|
||||
}
|
||||
.blur(radius: blurStrength)
|
||||
.onChange(of: timeline.date) { newDate in
|
||||
let dt = min(newDate.timeIntervalSince(lastTimestamp), 1 / 30) // clamp to avoid big jumps
|
||||
lastTimestamp = newDate
|
||||
step(dt: CGFloat(dt), in: geo.size)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if circles.isEmpty {
|
||||
circles = makeInitialCircles(in: geo.size)
|
||||
}
|
||||
lastTimestamp = Date()
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.drawingGroup() // renders via Metal, much smoother with blur + many shapes
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func makeInitialCircles(in size: CGSize) -> [FloatingCircle] {
|
||||
(0..<numberOfCircles).map { i in
|
||||
let radius = CGFloat.random(in: radiusRange)
|
||||
let x = CGFloat.random(in: radius...max(radius, size.width - radius))
|
||||
let y = CGFloat.random(in: radius...max(radius, size.height - radius))
|
||||
let speed = CGFloat.random(in: speedRange)
|
||||
let angle = CGFloat.random(in: 0..<(2 * .pi))
|
||||
|
||||
return FloatingCircle(
|
||||
position: CGPoint(x: x, y: y),
|
||||
velocity: CGVector(dx: cos(angle) * speed, dy: sin(angle) * speed),
|
||||
radius: radius,
|
||||
color: colors[i % colors.count]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Physics step
|
||||
|
||||
private func step(dt: CGFloat, in size: CGSize) {
|
||||
guard dt > 0, size.width > 0, size.height > 0 else { return }
|
||||
|
||||
for i in circles.indices {
|
||||
var c = circles[i]
|
||||
|
||||
var newX = c.position.x + c.velocity.dx * dt
|
||||
var newY = c.position.y + c.velocity.dy * dt
|
||||
|
||||
// Bounce off left/right edges
|
||||
if newX - c.radius < 0 {
|
||||
newX = c.radius
|
||||
c.velocity.dx = abs(c.velocity.dx)
|
||||
} else if newX + c.radius > size.width {
|
||||
newX = size.width - c.radius
|
||||
c.velocity.dx = -abs(c.velocity.dx)
|
||||
}
|
||||
|
||||
// Bounce off top/bottom edges
|
||||
if newY - c.radius < 0 {
|
||||
newY = c.radius
|
||||
c.velocity.dy = abs(c.velocity.dy)
|
||||
} else if newY + c.radius > size.height {
|
||||
newY = size.height - c.radius
|
||||
c.velocity.dy = -abs(c.velocity.dy)
|
||||
}
|
||||
|
||||
c.position = CGPoint(x: newX, y: newY)
|
||||
circles[i] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
BouncingCirclesBackground(numberOfCircles: 8, blurStrength: 60)
|
||||
|
||||
VStack {
|
||||
Text("Your content goes here")
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,8 @@ struct LoginView: View {
|
||||
Image("splashLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 76)
|
||||
|
||||
.frame(width: 150, height: 180)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Spacer()
|
||||
@ -38,9 +39,9 @@ struct LoginView: View {
|
||||
.padding(.top, 32)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Aceda aqui à sua conta")
|
||||
.font(.jakartaBold24)
|
||||
.foregroundStyle(.white)
|
||||
@ -96,7 +97,7 @@ struct LoginView: View {
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Button("Não tem conta? Criar conta") {
|
||||
viewModel.showSignup()
|
||||
@ -107,8 +108,13 @@ struct LoginView: View {
|
||||
Text("MedMap")
|
||||
.font(.jakartaRegular12)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
|
||||
|
||||
Image("corporateLogos")
|
||||
|
||||
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ struct SplashScreenView: View {
|
||||
.ignoresSafeArea()
|
||||
.task {
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user