596 lines
25 KiB
Swift
596 lines
25 KiB
Swift
import SwiftUI
|
|
|
|
// Mirrors fragment_dashboard.xml:
|
|
// • 2-column balance cards (MVR + USD) — always shown
|
|
// • Credit row — visible only when credit/prepaid cards exist
|
|
// • Blocked funds row — visible only when blockedAmount > 0
|
|
// • Card carousel — visible only when card accounts exist
|
|
// • Error banners
|
|
// • Fixed bottom quick-action bar (2 outlined buttons, outside scroll)
|
|
struct DashboardView: View {
|
|
@Environment(HomeViewModel.self) private var vm
|
|
@Environment(AppViewModel.self) private var app
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Indeterminate progress bar while loading (mirrors LinearProgressIndicator)
|
|
if vm.isRefreshing {
|
|
ProgressView(value: nil as Double?)
|
|
.progressViewStyle(.linear)
|
|
.tint(.accentColor)
|
|
.frame(height: 3)
|
|
}
|
|
|
|
// Scrollable content area
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
|
|
// ── 1. Balance summary row (MVR + USD) ───────────────────
|
|
HStack(spacing: 8) {
|
|
BalanceCard(label: "MVR Balance",
|
|
value: vm.fmt(vm.totalMvrBalance, currency: "MVR"),
|
|
hidden: vm.hideAmounts,
|
|
style: .normal)
|
|
BalanceCard(label: "USD Balance",
|
|
value: vm.fmt(vm.totalUsdBalance, currency: "USD"),
|
|
hidden: vm.hideAmounts,
|
|
style: .normal)
|
|
}
|
|
|
|
// ── 2. Available credit row (conditional) ────────────────
|
|
if vm.hasCreditAccounts {
|
|
HStack(spacing: 8) {
|
|
BalanceCard(label: "MVR Available Credit",
|
|
value: vm.fmt(vm.totalMvrCredit, currency: "MVR"),
|
|
hidden: vm.hideAmounts,
|
|
style: .normal)
|
|
BalanceCard(label: "USD Available Credit",
|
|
value: vm.fmt(vm.totalUsdCredit, currency: "USD"),
|
|
hidden: vm.hideAmounts,
|
|
style: .normal)
|
|
}
|
|
}
|
|
|
|
// ── 3. Blocked funds row (conditional) ───────────────────
|
|
if vm.totalBlockedMvr > 0 || vm.totalBlockedUsd > 0 {
|
|
HStack(spacing: 8) {
|
|
if vm.totalBlockedMvr > 0 {
|
|
BalanceCard(label: "Blocked MVR",
|
|
value: vm.fmt(vm.totalBlockedMvr, currency: "MVR"),
|
|
hidden: vm.hideAmounts,
|
|
style: .error)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
if vm.totalBlockedUsd > 0 {
|
|
BalanceCard(label: "Blocked USD",
|
|
value: vm.fmt(vm.totalBlockedUsd, currency: "USD"),
|
|
hidden: vm.hideAmounts,
|
|
style: .error)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
// Pad when only one side has a value
|
|
if vm.totalBlockedMvr == 0 || vm.totalBlockedUsd == 0 {
|
|
Spacer().frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 4. Pending finances (MIB) ────────────────────────────
|
|
if vm.totalPendingFinances > 0 {
|
|
BalanceCard(label: "Pending Finances",
|
|
value: vm.fmt(vm.totalPendingFinances, currency: "MVR"),
|
|
hidden: vm.hideAmounts,
|
|
style: .normal)
|
|
}
|
|
|
|
// ── 5. Foreign transaction limits (BML) ──────────────────
|
|
ForEach(vm.foreignLimits, id: \.0) { userName, limits in
|
|
ForeignLimitsCard(userName: userName, limits: limits, hidden: vm.hideAmounts)
|
|
}
|
|
|
|
// ── 6. Card carousel ─────────────────────────────────────
|
|
if !vm.cardAccounts.isEmpty {
|
|
cardCarousel
|
|
}
|
|
|
|
// ── 7. Error banners ─────────────────────────────────────
|
|
ForEach(vm.bankErrors.sorted(by: { $0.key < $1.key }), id: \.key) { bank, msg in
|
|
ErrorBanner(bank: bank, message: msg)
|
|
}
|
|
|
|
// ── 8. Loading skeletons (no data yet) ───────────────────
|
|
if vm.isRefreshing && vm.accounts.isEmpty {
|
|
ForEach(0..<3, id: \.self) { _ in SkeletonCard() }
|
|
}
|
|
|
|
// ── 9. Empty state ───────────────────────────────────────
|
|
if !vm.isRefreshing && vm.accounts.isEmpty && vm.bankErrors.isEmpty {
|
|
emptyState
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.refreshable { await vm.refresh() }
|
|
|
|
Divider()
|
|
|
|
// ── Fixed bottom quick-action bar ────────────────────────────────
|
|
HStack(spacing: 8) {
|
|
QuickActionButton(icon: "arrow.up.right", label: "Transfer") {}
|
|
QuickActionButton(icon: "qrcode", label: "PayMV QR") {}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, max(16, 0))
|
|
}
|
|
.background(Color(.systemBackground))
|
|
.navigationTitle("Thijooree")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar { toolbarItems }
|
|
}
|
|
}
|
|
|
|
// MARK: - Card Carousel
|
|
|
|
private var cardCarousel: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 0) {
|
|
ForEach(vm.cardAccounts) { card in
|
|
CardCarouselItem(account: card, hide: vm.hideAmounts)
|
|
.padding(.trailing, 12)
|
|
}
|
|
}
|
|
}
|
|
// Extend carousel to bleed past the 16dp parent padding
|
|
.padding(.horizontal, -16)
|
|
.padding(.leading, 16)
|
|
}
|
|
|
|
// MARK: - Toolbar (matches toolbar_menu.xml: bell + visibility + lock)
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbarItems: some ToolbarContent {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
HStack(spacing: 4) {
|
|
Button { /* notifications — Phase 12 */ } label: {
|
|
Image(systemName: "bell")
|
|
}
|
|
Button {
|
|
withAnimation { vm.hideAmounts.toggle() }
|
|
} label: {
|
|
Image(systemName: vm.hideAmounts ? "eye.slash" : "eye")
|
|
}
|
|
Button { app.lock() } label: {
|
|
Image(systemName: "lock")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty state
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "building.columns")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(.tertiary)
|
|
Text("No accounts")
|
|
.font(.headline)
|
|
.foregroundStyle(.secondary)
|
|
Text("Pull to refresh or log in to a bank.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.tertiary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 40)
|
|
}
|
|
}
|
|
|
|
// MARK: - Balance card (mirrors MaterialCardView with 12dp radius, 1dp elevation, 16dp padding)
|
|
|
|
private struct BalanceCard: View {
|
|
enum Style { case normal, error }
|
|
|
|
let label: String
|
|
let value: String
|
|
let hidden: Bool
|
|
let style: Style
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(style == .error ? errorLabelColor : Color.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
Text(hidden ? hiddenValue : value)
|
|
.font(.headline)
|
|
.foregroundStyle(style == .error ? errorValueColor : Color.primary)
|
|
.monospacedDigit()
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(style == .error ? Color(.systemRed).opacity(0.12) : Color(.secondarySystemGroupedBackground),
|
|
in: RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: .black.opacity(0.06), radius: 1, x: 0, y: 1)
|
|
}
|
|
|
|
private var hiddenValue: String {
|
|
String(value.prefix(4)) + " ••••••"
|
|
}
|
|
|
|
private var errorLabelColor: Color { Color(.systemRed).opacity(0.8) }
|
|
private var errorValueColor: Color { Color(.systemRed) }
|
|
}
|
|
|
|
// MARK: - Card carousel item
|
|
|
|
struct CardCarouselItem: View {
|
|
let account: BankAccount
|
|
let hide: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ZStack(alignment: .bottomLeading) {
|
|
// Real card art — fall back to colour gradient if no asset
|
|
if let name = cardImageName, UIImage(named: name) != nil {
|
|
Image(name)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 300, height: 180)
|
|
.clipped()
|
|
} else {
|
|
Rectangle()
|
|
.fill(cardGradient)
|
|
.frame(width: 300, height: 180)
|
|
}
|
|
|
|
// Scrim for text legibility
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .clear, location: 0.35),
|
|
.init(color: .black.opacity(0.65), location: 1.0)
|
|
],
|
|
startPoint: .top, endPoint: .bottom
|
|
)
|
|
|
|
// Name + masked number
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(account.profileName.uppercased())
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.shadow(color: .black.opacity(0.5), radius: 3, x: 1, y: 1)
|
|
Text(maskedNumber)
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
.fontDesign(.monospaced)
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
// Action buttons
|
|
HStack(spacing: 4) {
|
|
CardActionButton(icon: "qrcode", label: "Scan to Pay") {}
|
|
CardActionButton(icon: "wave.3.right", label: "Tap to Pay") {}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 10)
|
|
}
|
|
.frame(width: 300)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
.shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 2)
|
|
}
|
|
|
|
// MARK: - Image name resolution
|
|
|
|
private var cardImageName: String? {
|
|
switch account.bank {
|
|
case "BML": return "card_bml_\(bmlAsset(account.productCode))"
|
|
case "MIB": return mibCardImageName(account.productCode)
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
// Mirrors BmlCardParser.productCodeToAsset() exactly
|
|
private func bmlAsset(_ code: String) -> String {
|
|
switch code {
|
|
case "C8201","C8001","C8009": return "master_prepaid"
|
|
case "C8205","C8005","C8008": return "master_prepaid_travel"
|
|
case "C3007","C3017","C3097","C3095","C3077","C3177": return "amex_debit_green"
|
|
case "C3003","C3013","C3053","C3023","C3033","C3052": return "amex_debit_gold"
|
|
case "C3009","C3019","C3029","C3099","C3088","C3188": return "amex_credit_gold"
|
|
case "C3001","C3011","C3050","C3051","C3031": return "amex_credit_green"
|
|
case "C3005","C3015","C3055","C3054": return "amex_platinum"
|
|
case "C1003","C1013","C1083","C1084","C1103","C1113","C1183","C1184": return "visa_gold"
|
|
case "C1007","C1027","C1097","C1107","C1197","C1077","C1177": return "visa_debit"
|
|
case "C1020","C1021": return "visa_debit_platinum"
|
|
case "C8020","C8022": return "master_gold"
|
|
case "C8902","C8907","C8909","C8912","C8992","C8996","C8997","C8982","C8983": return "master_islamic"
|
|
case "C8101": return "master_masveriyaa"
|
|
case "C8102": return "master_odiveriyaa"
|
|
case "C8010","C8011": return "master_platinum"
|
|
case "C8040","C8044": return "master_world"
|
|
case "C8030","C8033": return "master_business_debit"
|
|
case "C8901","C8991","C8980","C8981": return "master_passport"
|
|
case "C1090","C1130","C1033","C1133": return "visa_corporate"
|
|
case "C8905","C8995": return "visa_credit"
|
|
case "C1001","C1011","C1082","C1081","C1101","C1111","C1181","C1182": return "visa_debit_generic"
|
|
case "C1005","C1006","C1030","C1089": return "visa_debit_islamic"
|
|
case "C1017": return "visa_infinite"
|
|
case "C1009","C1019","C1085","C1086","C1109","C1119","C1185","C1186": return "visa_platinum"
|
|
case "C1050","C1051","C1087","C1088","C1150","C1151","C1187","C1188",
|
|
"C1040","C1041","C1047","C1048","C1140","C1141","C1147","C1148": return "visa_student_black"
|
|
case "C8925","C8926": return "visa_student_blue"
|
|
case "C1071","C1073","C1061","C1063","C1161","C1163": return "master"
|
|
case "C1070","C1072","C1059","C1062","C1159","C1162": return "master_prepaid_business"
|
|
default: return "defaultcard"
|
|
}
|
|
}
|
|
|
|
// Mirrors CardsFragment.cardImageAsset(MibCard)
|
|
private func mibCardImageName(_ cardType: String) -> String? {
|
|
switch cardType {
|
|
case "51": return "card_mib_faisa_card"
|
|
case "53": return "card_mib_visa_black_platinum"
|
|
case "57": return "card_mib_visa_blue_everyday"
|
|
case "70": return "card_mib_visa_business"
|
|
case "701": return "card_mib_visa_bingaa_mvr"
|
|
case "702": return "card_mib_visa_bingaa_usd"
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
private var maskedNumber: String {
|
|
let raw = account.accountNumber
|
|
guard raw.count >= 4 else { return raw }
|
|
if hide { return "•••• •••• •••• ••••" }
|
|
return "•••• \(String(raw.suffix(4)))"
|
|
}
|
|
|
|
private var cardGradient: LinearGradient {
|
|
switch account.bank {
|
|
case "BML": return LinearGradient(colors: [Color(red: 0.1, green: 0.3, blue: 0.7),
|
|
Color(red: 0.05, green: 0.15, blue: 0.4)],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
default: return LinearGradient(colors: [Color(red: 0.15, green: 0.5, blue: 0.25),
|
|
Color(red: 0.05, green: 0.3, blue: 0.15)],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Card action button (matches TonalButton style, 11sp text, 16dp icon)
|
|
|
|
private struct CardActionButton: View {
|
|
let icon: String
|
|
let label: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Label(label, systemImage: icon)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.padding(.vertical, 6)
|
|
.frame(maxWidth: .infinity)
|
|
.background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
|
|
.foregroundStyle(.tint)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Quick action button (matches Widget.Material3.Button.OutlinedButton)
|
|
|
|
private struct QuickActionButton: View {
|
|
let icon: String
|
|
let label: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Label(label, systemImage: icon)
|
|
.font(.subheadline.weight(.medium))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(.tint, lineWidth: 1))
|
|
.foregroundStyle(.tint)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Error banner
|
|
|
|
private struct ErrorBanner: View {
|
|
let bank: String
|
|
let message: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("\(bank) — could not refresh")
|
|
.font(.caption.bold())
|
|
Text(message)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(12)
|
|
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - Skeleton placeholder card
|
|
|
|
private struct SkeletonCard: View {
|
|
@State private var shimmer = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
SkeletonRect(width: nil, height: 60).frame(maxWidth: .infinity)
|
|
SkeletonRect(width: nil, height: 60).frame(maxWidth: .infinity)
|
|
}
|
|
.opacity(shimmer ? 0.4 : 0.9)
|
|
.animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: shimmer)
|
|
.onAppear { shimmer = true }
|
|
}
|
|
}
|
|
|
|
private struct SkeletonRect: View {
|
|
let width: CGFloat?
|
|
let height: CGFloat
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.systemFill))
|
|
.frame(width: width, height: height)
|
|
}
|
|
}
|
|
|
|
// MARK: - Foreign Limits Card
|
|
|
|
private struct ForeignLimitsCard: View {
|
|
let userName: String
|
|
let limits: [BmlForeignLimit]
|
|
let hidden: Bool
|
|
|
|
@State private var expandedIndices: Set<Int> = []
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Header: name + card-type badge
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(userName.isEmpty ? "BML" : userName)
|
|
.font(.subheadline.bold())
|
|
Text("USD Foreign Transaction Limits")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(limits.first?.type ?? "Debit")
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(.green.opacity(0.2))
|
|
.foregroundStyle(.green)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
ForEach(Array(limits.enumerated()), id: \.offset) { idx, limit in
|
|
Divider()
|
|
LimitCardSection(
|
|
limit: limit,
|
|
hidden: hidden,
|
|
isExpanded: expandedIndices.contains(idx)
|
|
) {
|
|
withAnimation(.easeInOut(duration: 0.22)) {
|
|
if expandedIndices.contains(idx) { expandedIndices.remove(idx) }
|
|
else { expandedIndices.insert(idx) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: .black.opacity(0.06), radius: 1, x: 0, y: 1)
|
|
}
|
|
}
|
|
|
|
// One limit entry (ECOM + General always; ATM/POS/Medical when expanded)
|
|
private struct LimitCardSection: View {
|
|
let limit: BmlForeignLimit
|
|
let hidden: Bool
|
|
let isExpanded: Bool
|
|
let onToggle: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
// Always visible
|
|
limitRow("Online (ECOM)", remaining: limit.ecomRemaining, total: limit.ecomLimit)
|
|
limitRow("General", remaining: limit.generalRemaining, total: limit.generalCap)
|
|
|
|
// Expand / collapse toggle
|
|
Button(action: onToggle) {
|
|
HStack(spacing: 4) {
|
|
Text(isExpanded ? "Show less" : "Show more")
|
|
.font(.caption)
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Expanded section: ATM, POS, Medical
|
|
if isExpanded {
|
|
Divider()
|
|
limitRow(
|
|
limit.isAtmEnabled ? "ATM" : "ATM (Disabled)",
|
|
remaining: limit.atmRemaining, total: limit.atmLimit
|
|
)
|
|
limitRow(
|
|
limit.isPosEnabled ? "POS" : "POS (Disabled)",
|
|
remaining: limit.posRemaining, total: limit.posLimit
|
|
)
|
|
limitRow("Medical", remaining: limit.medicalRemaining, total: limit.totalLimit)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func limitRow(_ label: String, remaining: Double, total: Double) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(label)
|
|
.font(.subheadline)
|
|
.foregroundStyle(label.contains("Disabled") ? Color.secondary.opacity(0.5) : Color.secondary)
|
|
Spacer()
|
|
Text(hidden ? "USD ••••••" : fmtLimit(remaining, total))
|
|
.font(.subheadline.bold())
|
|
.monospacedDigit()
|
|
.foregroundStyle(label.contains("Disabled") ? .secondary : .primary)
|
|
}
|
|
ProgressView(value: (hidden || total <= 0) ? 0.0 : min(remaining / total, 1.0))
|
|
.progressViewStyle(.linear)
|
|
.tint(progressTint(remaining, total))
|
|
}
|
|
}
|
|
|
|
// "USD 1,234.56 / 5,000" — mirrors Kotlin format
|
|
private func fmtLimit(_ remaining: Double, _ total: Double) -> String {
|
|
let rf = NumberFormatter(); rf.numberStyle = .decimal
|
|
rf.minimumFractionDigits = 2; rf.maximumFractionDigits = 2; rf.usesGroupingSeparator = true
|
|
let tf = NumberFormatter(); tf.numberStyle = .decimal
|
|
tf.minimumFractionDigits = 0; tf.maximumFractionDigits = 0; tf.usesGroupingSeparator = true
|
|
let r = rf.string(from: NSNumber(value: remaining)) ?? "0.00"
|
|
let t = tf.string(from: NSNumber(value: total)) ?? "0"
|
|
return "USD \(r) / \(t)"
|
|
}
|
|
|
|
private func progressTint(_ remaining: Double, _ total: Double) -> Color {
|
|
guard total > 0 else { return .secondary }
|
|
let ratio = remaining / total
|
|
if ratio > 0.5 { return .green }
|
|
if ratio > 0.25 { return .orange }
|
|
return .red
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DashboardView()
|
|
.environment(HomeViewModel())
|
|
.environment(AppViewModel())
|
|
}
|