Implement bank transfer app flows
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user