Last active
April 7, 2025 07:39
-
-
Save mireabot/075a3d44f79ea6d41669885892975e4e to your computer and use it in GitHub Desktop.
FinancialHealthSheet
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct FinancialHealthSheetPreview: View { | |
@State private var showFinancialHealthSheet = false | |
var body: some View { | |
VStack { | |
Button(action: { | |
showFinancialHealthSheet.toggle() | |
}, label: { | |
HStack { | |
Text("Show Financial Health Sheet") | |
Spacer() | |
Image(systemName: "arrow.up.right") | |
} | |
}) | |
.buttonStyle(BorderedCapsuledButtonStyle()) | |
} | |
.padding(.horizontal, 16) | |
.floatingSheet(isPresented: $showFinancialHealthSheet, content: { | |
FinancialHealthSheet(score: 79) | |
}) | |
} | |
} | |
// MARK: - Extensions | |
extension FinancialHealthSheetPreview { | |
struct BorderedCapsuledButtonStyle: ButtonStyle { | |
func makeBody(configuration: Configuration) -> some View { | |
configuration.label | |
.padding(16) | |
.background( | |
Capsule() | |
.stroke(Color(uiColor: .systemGray5), lineWidth: 1) | |
) | |
} | |
} | |
} | |
// MARK: - Previews | |
#Preview { | |
FinancialHealthSheetPreview() | |
} | |
// MARK: - Financial Health Sheet Base | |
struct FinancialHealthSheetBase { | |
var maxDetent: PresentationDetent | |
var cornerRadius: CGFloat = 20 | |
var interactiveDimiss: Bool = false | |
var hPadding: CGFloat = 20 | |
var bPadding: CGFloat = 20 | |
} | |
extension View { | |
@ViewBuilder | |
func floatingSheet<Content: View>(isPresented: Binding<Bool>, config: FinancialHealthSheetBase = .init(maxDetent: .fraction(0.99)), @ViewBuilder content: @escaping () -> Content) -> some View { | |
self | |
.sheet(isPresented: isPresented) { | |
content() | |
.background(Color(uiColor: .systemBackground)) | |
.clipShape(.rect(cornerRadius: config.cornerRadius)) | |
.padding(.horizontal, config.hPadding) | |
.padding(.bottom, config.bPadding) | |
.frame(maxHeight: .infinity, alignment: .bottom) | |
.presentationDetents([config.maxDetent]) | |
.presentationCornerRadius(0) | |
.presentationBackground(.clear.blendMode(.darken)) | |
.presentationDragIndicator(.hidden) | |
.interactiveDismissDisabled(config.interactiveDimiss) | |
} | |
} | |
} | |
// MARK: - Financial Health Sheet View | |
struct FinancialHealthSheet: View { | |
let score: Double | |
@State private var currentScore: Double = 0 | |
let scoreRanges: [ClosedRange<Double>: Color] = [ | |
0...30: .red, // Bad | |
31...50: .yellow, // Fair | |
51...80: .blue, // Good | |
81...100: .green // Great | |
] | |
let labels = [ | |
(text: "Bad", color: Color.red), | |
(text: "Fair", color: Color.yellow), | |
(text: "Good", color: Color.blue), | |
(text: "Great", color: Color.green) | |
] | |
// Background icons | |
let icons = ["chart.pie.fill", "person.fill", "plus", "heart.fill", "star.fill", "line.3.horizontal"] | |
var body: some View { | |
ZStack(alignment: .top) { | |
LazyHGrid(rows: Array(repeating: GridItem(.fixed(50)), count: 2), spacing: 20) { | |
ForEach(0..<12) { index in | |
Image(systemName: icons[index % icons.count]) | |
.font(.system(size: 24)) | |
.foregroundColor(.gray.opacity(0.7)) | |
.frame(height: 25) | |
.padding(8) | |
.background(.gray.opacity(0.15)) | |
.cornerRadius(10) | |
} | |
} | |
.fixedSize(horizontal: true, vertical: true) | |
.blur(radius: 1.7) | |
VStack(spacing: 32) { | |
VStack(spacing: 6) { | |
Text("Financial health:") | |
.font(.system(.subheadline, weight: .medium)) | |
VStack(spacing: 3) { | |
Text("\(Int(score))") | |
.font(.system(size: 72, weight: .bold)) | |
Text("out of 100") | |
.font(.title3) | |
.foregroundColor(.secondary) | |
} | |
} | |
VStack(alignment: .leading, spacing: 10) { | |
SegmentedProgressBar(score: currentScore, scoreRanges: scoreRanges) | |
.frame(height: 16) | |
HStack(spacing: 0) { | |
ForEach(labels, id: \.text) { label in | |
Text(label.text) | |
.font(.caption) | |
.foregroundColor(.secondary) | |
.frame(maxWidth: .infinity) | |
} | |
} | |
} | |
.padding(.top, 10) | |
infoView() | |
.padding(.bottom, 16) | |
} | |
.padding(.top, 20) | |
.padding(.horizontal, 16) | |
} | |
.onAppear { | |
withAnimation(.spring(duration: 2)) { | |
currentScore = score | |
} | |
} | |
} | |
@ViewBuilder | |
func infoView() -> some View { | |
VStack(alignment: .leading) { | |
VStack(alignment: .leading, spacing: 6) { | |
Text("Why?") | |
.font(.system(.subheadline, weight: .medium)) | |
.foregroundStyle(.primary) | |
Text("Savings exceed goals and spending is intentional") | |
.font(.system(.subheadline, weight: .regular)) | |
.foregroundStyle(.secondary) | |
.multilineTextAlignment(.leading) | |
} | |
.frame(maxWidth: .infinity, alignment: .leading) | |
Divider().foregroundColor(Color.gray.opacity(0.2)).padding(.vertical, 8) | |
VStack(alignment: .leading, spacing: 6) { | |
Text("Recommendation:") | |
.font(.system(.subheadline, weight: .medium)) | |
.foregroundStyle(.primary) | |
Text("Stay consistent with your budget, spending and saving habits") | |
.font(.system(.subheadline, weight: .regular)) | |
.foregroundStyle(.secondary) | |
} | |
.frame(maxWidth: .infinity, alignment: .leading) | |
} | |
.padding(.horizontal, 12) | |
.padding(.vertical, 14) | |
.overlay { | |
RoundedRectangle(cornerRadius: 10) | |
.stroke(Color.gray.opacity(0.2), lineWidth: 1) | |
} | |
} | |
} | |
// MARK: - FinancialHealthSheet Extenstions | |
extension FinancialHealthSheet { | |
// Custom segmented progress bar | |
struct SegmentedProgressBar: View { | |
let score: Double | |
let scoreRanges: [ClosedRange<Double>: Color] | |
private var indicatorColor: Color { | |
scoreRanges.first { $0.key.contains(score) }?.value ?? .gray | |
} | |
private var segmentColors: [Color] { | |
scoreRanges.sorted { $0.key.lowerBound < $1.key.lowerBound }.flatMap { range, color in | |
let count = Int((range.upperBound - range.lowerBound) / 10) | |
return Array(repeating: color, count: max(1, count)) | |
} | |
} | |
var body: some View { | |
GeometryReader { geometry in | |
HStack(spacing: 4) { | |
ForEach(0..<segmentColors.count, id: \.self) { index in | |
Rectangle() | |
.fill(segmentColors[index]) | |
.cornerRadius(4, corners: getCornerRadius(for: index)) | |
} | |
} | |
.overlay { | |
// Score indicator | |
GeometryReader { geo in | |
Circle() | |
.fill(indicatorColor) | |
.stroke(.white, lineWidth: 2) | |
.frame(width: 32, height: 32) | |
.position(x: (geo.size.width * score / 100), y: geo.size.height / 2) | |
} | |
} | |
} | |
} | |
private func getCornerRadius(for index: Int) -> UIRectCorner { | |
if index == 0 { | |
return [.topLeft, .bottomLeft] | |
} else if index == segmentColors.count - 1 { | |
return [.topRight, .bottomRight] | |
} | |
return [] | |
} | |
} | |
} | |
// MARK: - View Extensions | |
extension View { | |
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { | |
clipShape(RoundedCorner(radius: radius, corners: corners)) | |
} | |
} | |
struct RoundedCorner: Shape { | |
var radius: CGFloat = .infinity | |
var corners: UIRectCorner = .allCorners | |
func path(in rect: CGRect) -> Path { | |
let path = UIBezierPath( | |
roundedRect: rect, | |
byRoundingCorners: corners, | |
cornerRadii: CGSize(width: radius, height: radius) | |
) | |
return Path(path.cgPath) | |
} | |
var animatableData: CGFloat { | |
get { radius } | |
set { radius = newValue } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment