Implement bank transfer app flows

This commit is contained in:
2026-06-08 00:03:57 +05:00
parent ef877217ad
commit 9b281d48a7
127 changed files with 8008 additions and 90 deletions
@@ -0,0 +1,249 @@
import SwiftUI
struct CredentialsView: View {
let bank: String
@Environment(AppViewModel.self) private var app
@Environment(\.dismiss) private var dismiss
@State private var vm = LoginViewModel()
@State private var username = ""
@State private var password = ""
@State private var otpSeed = ""
@State private var totpCode = "" // Fahipay 2-step verification code
@State private var isLoading = false
@State private var errorText = ""
@State private var showOtpPreview = false
private var isFahipay: Bool { bank == "FAHIPAY" }
private var awaitingFahipayTotp: Bool {
if case .fahipayNeedTotp = vm.state { return true }
return false
}
private var resolvedSeed: String { LoginViewModel.resolveOtpSeed(otpSeed) }
private var canSubmit: Bool {
let u = username.trimmingCharacters(in: .whitespaces)
let p = password
if awaitingFahipayTotp { return totpCode.count == 6 }
if isFahipay { return !u.isEmpty && !p.isEmpty }
return !u.isEmpty && !p.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed)
}
// MARK: - Body
var body: some View {
ScrollView {
VStack(spacing: 24) {
bankHeader
credentialFields
if !isFahipay && !resolvedSeed.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed) {
OtpPreviewView(seed: resolvedSeed)
.padding(.horizontal, 4)
.transition(.move(edge: .top).combined(with: .opacity))
}
if !errorText.isEmpty {
Text(errorText)
.font(.callout)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.transition(.opacity)
}
Button(submitLabel) { attemptLogin() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!canSubmit || isLoading)
.padding(.horizontal, 4)
if isLoading {
ProgressView()
}
}
.padding(24)
}
.navigationTitle(bankTitle)
.navigationBarTitleDisplayMode(.large)
.animation(.easeInOut(duration: 0.2), value: resolvedSeed)
.animation(.easeInOut(duration: 0.2), value: errorText)
}
// MARK: - Bank header
@ViewBuilder
private var bankHeader: some View {
VStack(spacing: 6) {
Text(bankDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
// MARK: - Fields
@ViewBuilder
private var credentialFields: some View {
VStack(spacing: 14) {
if awaitingFahipayTotp {
// Fahipay 2-step: only show the TOTP field
VStack(alignment: .leading, spacing: 6) {
Text("Authenticator Code")
.font(.caption)
.foregroundStyle(.secondary)
TextField("6-digit code", text: $totpCode)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.padding(12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
}
Text("Open your authenticator app and enter the 6-digit code for Fahipay.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
// Username
VStack(alignment: .leading, spacing: 6) {
Text(isFahipay ? "ID Card Number" : "Username")
.font(.caption).foregroundStyle(.secondary)
TextField(isFahipay ? "A000000" : "Enter username", text: $username)
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.padding(12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
.disabled(awaitingFahipayTotp)
}
// Password
VStack(alignment: .leading, spacing: 6) {
Text("Password")
.font(.caption).foregroundStyle(.secondary)
SecureField("Enter password", text: $password)
.textContentType(.password)
.padding(12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
.disabled(awaitingFahipayTotp)
}
// OTP seed (MIB + BML only)
if !isFahipay {
VStack(alignment: .leading, spacing: 6) {
Text("Authenticator Secret")
.font(.caption).foregroundStyle(.secondary)
TextField("Paste TOTP secret or otpauth:// URI", text: $otpSeed)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.none)
.padding(12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
}
Text("This is the secret from your authenticator app — not the 6-digit code.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
}
}
// MARK: - Login logic
private func attemptLogin() {
withAnimation { errorText = "" }
isLoading = true
Task {
do {
switch bank {
case "MIB":
let accounts = try await vm.loginMib(
username: username.trimmingCharacters(in: .whitespaces),
password: password,
otpSeed: resolvedSeed
)
await MainActor.run { finish(accounts: accounts) }
case "BML":
let accounts = try await vm.loginBml(
username: username.trimmingCharacters(in: .whitespaces),
password: password,
otpSeed: resolvedSeed
)
await MainActor.run { finish(accounts: accounts) }
case "FAHIPAY":
if awaitingFahipayTotp {
try await vm.verifyFahipayTotp(totpCode)
await MainActor.run { finish(accounts: []) }
} else {
let step = try await vm.loginFahipay(
idCard: username.trimmingCharacters(in: .whitespaces),
password: password
)
await MainActor.run {
if step.twoFactorRequired {
withAnimation { vm.state = .fahipayNeedTotp }
} else if let authId = step.authId {
vm.completeFahipayLogin(authId: authId)
finish(accounts: [])
}
}
}
default: break
}
} catch {
await MainActor.run {
withAnimation { errorText = error.localizedDescription }
}
}
await MainActor.run { isLoading = false }
}
}
private func finish(accounts: [BankAccount]) {
app.loginSucceeded()
}
// MARK: - Computed strings
private var bankTitle: String {
switch bank {
case "MIB": return "MIB Faisanet"
case "BML": return "Bank of Maldives"
case "FAHIPAY": return "Fahipay Wallet"
default: return bank
}
}
private var bankDescription: String {
switch bank {
case "MIB": return "Sign in with your Faisanet username and password.\nPaste your TOTP secret from your authenticator app."
case "BML": return "Sign in with your BML Internet Banking credentials.\nAll personal profiles will be activated automatically."
case "FAHIPAY": return "Sign in with your Fahipay ID card number and password."
default: return ""
}
}
private var submitLabel: String {
if awaitingFahipayTotp { return "Verify Code" }
return "Sign In"
}
}
#Preview {
NavigationStack {
CredentialsView(bank: "MIB")
.environment(AppViewModel())
}
}