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