improvements on design

This commit is contained in:
RSA 2026-09-15 23:23:01 +01:00
parent 6c362e0d95
commit c49034bd93
19 changed files with 542 additions and 228 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -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;

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "bluetoothIcon.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View 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

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "timerIcon.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View 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

View File

@ -47,7 +47,7 @@ extension Color {
static let appBackground = Color(hex: "#4078BC")
static let appBackgroundDark = Color(hex: "##4078BC")
static let appBackgroundLight = Color(hex: "#4078BC")

View File

@ -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
View 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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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?

View 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)
}

View File

@ -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()
}

View 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))
}
}
}

View File

@ -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)
}
}

View File

@ -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
}