Files
Thijooree-iOS/Thijooree iOS/Views/Home/DashboardView.swift
T

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