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 @State private var showQRScanner = false @State private var scannedCardAccountNumber: String? = nil @State private var tapToPayAccount: BankAccount? = nil 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") { showQRScanner = true } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, max(16, 0)) } .background(Color(.systemBackground)) .navigationTitle("Thijooree") .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarItems } .sheet(isPresented: $showQRScanner) { QRScannerSheet { qrContent in handleQR(qrContent) } } .sheet(item: $tapToPayAccount) { account in TapToPayView(account: account) { tapToPayAccount = nil } } } } // 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, onScanToPay: { scannedCardAccountNumber = card.accountNumber showQRScanner = true }, onTapToPay: { tapToPayAccount = card }) .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: - QR handler private func handleQR(_ content: String) { let bmlUrl = PaymvQrParser.extractBmlGatewayUrl(content) if content.hasPrefix("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != nil { vm.pendingBmlQrUrl = bmlUrl ?? content vm.pendingQrFromCard = scannedCardAccountNumber return } if let result = PaymvQrParser.parse(content) { vm.pendingTransferQRData = result vm.pendingQrFromCard = scannedCardAccountNumber } else { let fallback = PaymvQrResult(account: content, amount: nil, merchantName: nil, purpose: nil) vm.pendingTransferQRData = fallback vm.pendingQrFromCard = scannedCardAccountNumber } } // 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 let onScanToPay: () -> Void let onTapToPay: () -> Void 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") { onScanToPay() } CardActionButton(icon: "wave.3.right", label: "Tap to Pay") { onTapToPay() } } .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 = [] 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()) }