Files
modeazimandClaude Sonnet 4.6 2bbb280380 Add Tap to Pay feature and misc fixes
Adds BML contactless token fetching, APDU emulation utility, TapToPayViewModel, and TapToPayView with NFC animation. Also updates QR scanner, transfer flow, dashboard, and home views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:27:27 +05:00

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