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

820 lines
31 KiB
Swift

import SwiftUI
struct TransferView: View {
private enum FocusedField {
case toAccount
case amount
case remarks
}
@Environment(HomeViewModel.self) private var homeVM
@State private var vm = TransferViewModel()
@State private var showAccountPicker = false
@State private var showContactPicker = false
@State private var showBmlOtp = false
@State private var showQRScanner = false
@State private var accountSearchText = ""
@State private var contactSearchText = ""
@State private var toInputText = ""
@State private var amountInputText = ""
@State private var purposeInputText = ""
@State private var completedReceipt: ReceiptPresentation? = nil
@State private var pendingReceiptAfterOtp: TransferReceiptData? = nil
@State private var isProgrammaticToInputChange = false
@FocusState private var focusedField: FocusedField?
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
private struct ReceiptPresentation: Identifiable {
let id = UUID()
let receipt: TransferReceiptData
}
// Accounts eligible to send from
private var sendableAccounts: [BankAccount] {
homeVM.accounts.filter {
($0.bank == "MIB" || $0.bank == "BML") &&
!["BML_CREDIT", "BML_DEBIT", "BML_PREPAID", "BML_LOAN", "MIB_CARD"].contains($0.profileType) &&
$0.isActive
}
}
private var filteredSendableAccounts: [BankAccount] {
guard !accountSearchText.isEmpty else { return sendableAccounts }
let query = accountSearchText.lowercased()
return sendableAccounts.filter {
$0.bank.lowercased().contains(query) ||
$0.accountNumber.contains(query) ||
$0.accountBriefName.lowercased().contains(query) ||
$0.accountTypeName.lowercased().contains(query)
}
}
private var destinationAccounts: [BankAccount] {
homeVM.accounts.filter {
($0.bank == "MIB" || $0.bank == "BML") &&
$0.profileType != "BML_LOAN" &&
$0.id != vm.fromAccount?.id
}
}
private var filteredDestinationAccounts: [BankAccount] {
guard !contactSearchText.isEmpty else { return destinationAccounts }
let query = contactSearchText.lowercased()
return destinationAccounts.filter {
$0.bank.lowercased().contains(query) ||
$0.accountNumber.contains(query) ||
$0.accountBriefName.lowercased().contains(query) ||
$0.accountTypeName.lowercased().contains(query)
}
}
private var filteredContacts: [BankContact] {
guard !contactSearchText.isEmpty else { return homeVM.contacts }
let query = contactSearchText.lowercased()
return homeVM.contacts.filter {
$0.displayName.lowercased().contains(query) ||
$0.benefAccount.contains(query) ||
($0.benefBankName?.lowercased().contains(query) ?? false) ||
($0.benefCategoryId?.lowercased().contains(query) ?? false)
}
}
private var canSubmitTransfer: Bool {
guard let from = vm.fromAccount else { return false }
let amount = Double(amountInputText.replacingOccurrences(of: ",", with: "")) ?? 0
guard amount > 0 else { return false }
switch vm.lookupState {
case .found, .bmlReady:
return true
default:
return from.bank == "BML" && !toInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
private var groupedContacts: [(String, [BankContact])] {
let groups = Dictionary(grouping: filteredContacts) { contactCategoryTitle($0) }
return groups.keys.sorted().compactMap { key in
guard let contacts = groups[key], !contacts.isEmpty else { return nil }
return (key, contacts.sorted { $0.displayName < $1.displayName })
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
fromAccountCard
toAccountCard
amountCard
remarksCard
transferButton
}
.padding(16)
}
.scrollDismissesKeyboard(.interactively)
.background(Color(.systemGroupedBackground))
.contentShape(Rectangle())
.onTapGesture { focusedField = nil }
.navigationTitle("Transfer")
.navigationBarTitleDisplayMode(.inline)
}
.sheet(isPresented: $showAccountPicker) {
accountPickerSheet
.onDisappear { accountSearchText = "" }
}
.sheet(isPresented: $showBmlOtp, onDismiss: presentPendingReceiptAfterOtp) {
bmlOtpSheet
}
.sheet(isPresented: $showContactPicker) {
contactPickerSheet
.onDisappear { contactSearchText = "" }
}
.sheet(item: $completedReceipt, onDismiss: finishCompletedTransfer) { presentation in
TransferReceiptView(
receipt: presentation.receipt,
onSaveContact: { contact in
homeVM.saveContact(contact)
}
) {
completedReceipt = nil
finishCompletedTransfer()
}
}
.sheet(isPresented: $showQRScanner) {
QRScannerSheet { qrContent in
handleQR(qrContent)
}
}
.onChange(of: vm.transferState) { _, state in
switch state {
case .success(let receipt):
focusedField = nil
if showBmlOtp {
pendingReceiptAfterOtp = receipt
showBmlOtp = false
} else {
completedReceipt = ReceiptPresentation(receipt: receipt)
}
case .bmlStep2:
focusedField = nil
showBmlOtp = true
default:
break
}
}
.onChange(of: homeVM.pendingTransferContact) { _, contact in
guard let contact else { return }
selectContact(contact)
homeVM.pendingTransferContact = nil
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { focusedField = nil }
}
}
}
// MARK: - From account card
private var fromAccountCard: some View {
Button { showAccountPicker = true } label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("From Account")
.font(.caption)
.foregroundStyle(.secondary)
if let acc = vm.fromAccount {
Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
.font(.subheadline.bold())
Text(acc.accountNumber)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Text(acc.formattedAvailableBalance)
.font(.caption)
.foregroundStyle(primary)
} else {
Text("Select account")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.down")
.foregroundStyle(.secondary)
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
// MARK: - To account card
private var toAccountCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("To Account")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
// Text field with lookup icon inside its border
HStack(spacing: 8) {
TextField("Account Number or Favara ID", text: $toInputText)
.focused($focusedField, equals: .toAccount)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.next)
.onSubmit { focusedField = .amount }
.font(.subheadline)
.onChange(of: toInputText) { _, _ in
if isProgrammaticToInputChange {
isProgrammaticToInputChange = false
return
}
vm.lookupState = .idle
vm.bmlTrnType = "IAT"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = nil
}
if vm.fromAccount?.bank == "MIB" || vm.fromAccount?.bank == "BML" {
lookupButton
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.separator), lineWidth: 0.5)
)
// Contact picker
Button { showContactPicker = true } label: {
Image(systemName: "person.fill")
.font(.subheadline)
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(Color.green)
.clipShape(Circle())
}
.buttonStyle(.plain)
// QR scanner
Button { showQRScanner = true } label: {
Image(systemName: "qrcode.viewfinder")
.font(.subheadline)
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(Color.green)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
lookupResultRow
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private var lookupButton: some View {
switch vm.lookupState {
case .loading:
ProgressView()
.controlSize(.small)
default:
Button {
performBeneficiaryLookup()
} label: {
Image(systemName: "magnifyingglass")
.font(.subheadline)
.foregroundStyle(toInputText.isEmpty ? Color.secondary : primary)
}
.disabled(toInputText.isEmpty)
}
}
@ViewBuilder
private var lookupResultRow: some View {
switch vm.lookupState {
case .found(let r):
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text(r.accountName).font(.caption.bold())
Text(r.bankName).font(.caption2).foregroundStyle(.secondary)
}
}
case .bmlReady(let name) where !name.isEmpty:
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Text(name).font(.caption.bold())
}
case .error(let msg):
HStack(spacing: 6) {
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
Text(msg).font(.caption).foregroundStyle(.red)
}
default:
EmptyView()
}
}
// MARK: - Amount card
private var amountCard: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Amount")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.top, 12)
HStack {
Text(vm.fromAccount?.currencyName ?? "MVR")
.font(.subheadline.bold())
.foregroundStyle(primary)
.frame(width: 44)
TextField("0.00", text: $amountInputText)
.focused($focusedField, equals: .amount)
.keyboardType(.decimalPad)
.submitLabel(.next)
.onSubmit { focusedField = .remarks }
.font(.title3.bold())
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Remarks card
private var remarksCard: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Remarks")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.top, 12)
TextField("Fund Transfer", text: $purposeInputText)
.focused($focusedField, equals: .remarks)
.submitLabel(.done)
.onSubmit { focusedField = nil }
.font(.subheadline)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Transfer button
@ViewBuilder
private var transferButton: some View {
if case .processing = vm.transferState {
ProgressView("Processing transfer…")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
} else {
VStack(spacing: 8) {
if case .failure(let msg) = vm.transferState {
Text(msg)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
Button {
syncFormToViewModel()
focusedField = nil
Task { await vm.submitTransfer() }
} label: {
Text("Transfer")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(canSubmitTransfer ? primary : Color.gray.opacity(0.4))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!canSubmitTransfer)
}
}
}
// MARK: - Account picker sheet
private var accountPickerSheet: some View {
NavigationStack {
List(filteredSendableAccounts) { acc in
Button {
vm.fromAccount = acc
vm.lookupState = .idle
vm.bmlTrnType = "IAT"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = nil
showAccountPicker = false
} label: {
HStack {
bankDot(acc.bank)
VStack(alignment: .leading, spacing: 3) {
Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text(acc.accountNumber)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Text(acc.formattedAvailableBalance)
.font(.caption.bold())
.foregroundStyle(primary)
}
}
}
.navigationTitle("From Account")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $accountSearchText, prompt: "Search account")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") { showAccountPicker = false }
}
}
}
}
// MARK: - BML OTP sheet
private var bmlOtpSheet: some View {
NavigationStack {
VStack(spacing: 24) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 48))
.foregroundStyle(primary)
Text("Confirm Transfer")
.font(.title2.bold())
Text("The OTP will be generated automatically from your stored authenticator seed.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if case .processing = vm.transferState {
ProgressView("Confirming…")
} else {
Button {
guard let pid = vm.fromAccount?.profileId
?? vm.fromAccount.flatMap({ CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles($0.loginTag.replacingOccurrences(of: "bml_", with: ""))).first }),
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(
vm.fromAccount?.loginTag.replacingOccurrences(of: "bml_", with: "") ?? ""
)) else { return }
let _ = pid // suppress unused warning
Task { await vm.confirmBmlOtp(otpSeed: seed) }
} label: {
Text("Confirm with Authenticator")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(primary)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, 24)
if case .failure(let msg) = vm.transferState {
Text(msg)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
.navigationTitle("OTP Confirmation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
vm.transferState = .idle
showBmlOtp = false
}
}
}
}
.presentationDetents([.medium])
.onChange(of: vm.transferState) { _, state in
if case .success = state {
showBmlOtp = false
}
}
}
// MARK: - Contact picker sheet
private var contactPickerSheet: some View {
NavigationStack {
List {
if !filteredDestinationAccounts.isEmpty {
Section("My Accounts") {
ForEach(filteredDestinationAccounts) { account in
Button {
selectDestinationAccount(account)
showContactPicker = false
} label: {
destinationAccountRow(account)
}
.buttonStyle(.plain)
}
}
}
ForEach(groupedContacts, id: \.0) { title, contacts in
Section(title) {
ForEach(contacts) { contact in
Button {
selectContact(contact)
showContactPicker = false
} label: {
contactPickerRow(contact)
}
.buttonStyle(.plain)
}
}
}
}
.navigationTitle("Select Contact")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $contactSearchText, prompt: "Search contacts")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") { showContactPicker = false }
}
}
}
}
// MARK: - Transfer completion
private func finishCompletedTransfer() {
completedReceipt = nil
pendingReceiptAfterOtp = nil
focusedField = nil
showBmlOtp = false
vm.reset()
toInputText = ""
amountInputText = ""
purposeInputText = ""
}
private func presentPendingReceiptAfterOtp() {
guard let receipt = pendingReceiptAfterOtp else { return }
pendingReceiptAfterOtp = nil
completedReceipt = ReceiptPresentation(receipt: receipt)
}
private func syncFormToViewModel() {
vm.toInput = toInputText
vm.amountInput = amountInputText
vm.purposeInput = purposeInputText
}
private func setToInput(_ value: String) {
guard toInputText != value else {
vm.toInput = value
isProgrammaticToInputChange = false
return
}
isProgrammaticToInputChange = true
toInputText = value
vm.toInput = value
}
// MARK: - QR handler
private func performBeneficiaryLookup() {
syncFormToViewModel()
if resolveCachedRecipient() { return }
Task { await vm.lookupBeneficiary() }
}
private func resolveCachedRecipient() -> Bool {
let query = toInputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return false }
if let account = destinationAccounts.first(where: { $0.accountNumber == query }) {
selectDestinationAccount(account)
return true
}
if let contact = homeVM.contacts.first(where: {
$0.benefAccount == query ||
$0.benefNo == query ||
$0.displayName.caseInsensitiveCompare(query) == .orderedSame
}) {
selectContact(contact)
return true
}
return false
}
private func handleQR(_ content: String) {
if let result = PaymvQrParser.parse(content) {
setToInput(result.account)
vm.lookupState = .idle
if let amount = result.amount, !amount.isEmpty {
amountInputText = amount
vm.amountInput = amount
}
if let purpose = result.purpose, !purpose.isEmpty {
purposeInputText = purpose
vm.purposeInput = purpose
} else if let name = result.merchantName, !name.isEmpty {
purposeInputText = "Pay \(name)"
vm.purposeInput = purposeInputText
}
// Trigger lookup for the filled account
if vm.fromAccount?.bank == "MIB" {
Task { await vm.lookupBeneficiary() }
} else if vm.fromAccount?.bank == "BML" {
Task { await vm.lookupBeneficiary() }
}
} else {
// Fallback: treat raw content as an account number
setToInput(content)
vm.lookupState = .idle
}
}
private func destinationAccountRow(_ account: BankAccount) -> some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(contactColor(account.bank).opacity(0.15))
.frame(width: 36, height: 36)
Text(accountInitials(account))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(contactColor(account.bank))
}
VStack(alignment: .leading, spacing: 3) {
Text(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text("\(account.bank) · \(account.accountNumber)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(account.formattedAvailableBalance)
.font(.caption.bold())
.foregroundStyle(primary)
}
}
private func contactPickerRow(_ contact: BankContact) -> some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(contactColor(contact.source).opacity(0.15))
.frame(width: 36, height: 36)
Text(contactInitials(contact.displayName))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(contactColor(contact.source))
}
VStack(alignment: .leading, spacing: 3) {
Text(contact.displayName)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text(contactSubtitle(contact))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(contact.source)
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(contactColor(contact.source).opacity(0.12), in: Capsule())
.foregroundStyle(contactColor(contact.source))
}
}
private func selectDestinationAccount(_ account: BankAccount) {
setToInput(account.accountNumber)
guard let from = vm.fromAccount else { return }
if from.bank == "MIB" {
let isMibInternal = account.bank == "MIB"
vm.lookupState = .found(MibAccountLookupResult(
accountNumber: account.accountNumber,
accountName: account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName,
bankName: isMibInternal ? "Maldives Islamic Bank" : "Bank of Maldives",
bankCode: isMibInternal ? "2" : "3",
aliasId: nil,
network: isMibInternal ? .mibInternal : .local
))
} else if from.bank == "BML" {
if account.bank == "MIB" {
vm.bmlTrnType = "DOT"
vm.bmlBank = "MIB"
vm.bmlCreditAccountOverride = nil
} else if isBmlCard(account) {
vm.bmlTrnType = "CPA"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = (account.internalId ?? "").isEmpty ? account.accountNumber : account.internalId
} else {
vm.bmlTrnType = "IAT"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = nil
}
vm.lookupState = .bmlReady(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
}
}
private func selectContact(_ contact: BankContact) {
setToInput(contact.benefAccount)
guard let fromBank = vm.fromAccount?.bank else { return }
if fromBank == "MIB" {
let isMibInternal = contact.source == "MIB" && contact.benefType != "LOCAL"
vm.lookupState = .found(MibAccountLookupResult(
accountNumber: contact.benefAccount,
accountName: contact.displayName,
bankName: contact.benefBankName ?? (isMibInternal ? "Maldives Islamic Bank" : "Local Bank"),
bankCode: contact.bankCode ?? (isMibInternal ? "2" : "3"),
aliasId: contact.benefNo,
network: isMibInternal ? .mibInternal : .local
))
} else if fromBank == "BML" {
vm.bmlTrnType = contact.source == "MIB" ? "DOT" : "IAT"
vm.bmlBank = contact.source == "MIB" ? "MIB" : nil
vm.bmlCreditAccountOverride = nil
vm.lookupState = .bmlReady(contact.displayName)
}
}
private func contactCategoryTitle(_ contact: BankContact) -> String {
if let category = contact.benefCategoryId, !category.isEmpty, category != "BML" {
return category
}
switch contact.source {
case "MIB": return "MIB Contacts"
case "BML": return "BML Contacts"
default: return "Other Contacts"
}
}
private func contactSubtitle(_ contact: BankContact) -> String {
let bank = contact.benefBankName ?? contact.source
return "\(bank) · \(contact.benefAccount)"
}
private func accountInitials(_ account: BankAccount) -> String {
let label = account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName
let words = label.split(separator: " ").prefix(2)
let initials = words.compactMap { $0.first }.map(String.init).joined()
return initials.isEmpty ? account.bank : initials
}
private func isBmlCard(_ account: BankAccount) -> Bool {
account.profileType == "BML_CREDIT" ||
account.profileType == "BML_DEBIT" ||
account.profileType == "BML_PREPAID"
}
private func contactInitials(_ name: String) -> String {
let words = name.split(separator: " ").prefix(2)
return words.compactMap { $0.first }.map(String.init).joined()
}
private func contactColor(_ source: String) -> Color {
switch source {
case "MIB": return primary
case "BML": return .blue
default: return .purple
}
}
private func bankDot(_ bank: String) -> some View {
Circle()
.fill(bank == "MIB" ? Color(red: 0.247, green: 0.396, blue: 0.678) : Color.blue)
.frame(width: 10, height: 10)
}
}
#Preview {
TransferView()
.environment(HomeViewModel())
}