Compare commits
1
Commits
v1.0.9
..
cc072484a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc072484a8
|
@@ -1,9 +1,11 @@
|
||||
services:
|
||||
release:
|
||||
# image: git.shihaam.dev/dockerfiles/android-builder
|
||||
image: git.shihaam.dev/dockerfiles/runners/gradle
|
||||
hostname: isodroid
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./release:/release
|
||||
- ../../:/source
|
||||
- /root/.cache/cache-runners/gradle:/root/.gradle
|
||||
# - /root/.cache/cache-runners/gradle:/root/.gradle
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<option name="selectionMode" value="DIALOG" />
|
||||
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Thijooree
|
||||
# BasedBank
|
||||
|
||||
A native Android client for Maldivian banking services. It is a pure client: requests go directly from your device to the banks' own servers using the same protocols as their official apps. No proxy, no backend, no middleman.
|
||||
A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (Bank of Maldives), and Fahipay into a single interface — with no analytics, no tracking, and no phone-home behaviour outside the banks themselves.
|
||||
|
||||
[](https://sladge.net)
|
||||
[](LICENSE)
|
||||
@@ -8,14 +8,60 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
||||

|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
|
||||
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
|
||||
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
|
||||
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
|
||||
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
|
||||
- **QR payments** — scan PayMV QR codes to pre-fill transfers
|
||||
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
|
||||
- **MIB financing** — view active financing deals
|
||||
|
||||
## Authentication
|
||||
|
||||
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
|
||||
|
||||
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
|
||||
|
||||
## Security
|
||||
|
||||
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
|
||||
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
|
||||
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
|
||||
- All sensitive data excluded from Android cloud backup
|
||||
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
|
||||
|
||||
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
|
||||
|
||||
## Supported banks
|
||||
|
||||
| Bank | Login | Accounts | History | Transfers | Contacts |
|
||||
|---|---|---|---|---|---|
|
||||
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- Existing accounts with MIB, BML, or Fahipay
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||
|
||||
## Download
|
||||
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
|
||||
## Building
|
||||
|
||||
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
|
||||
|
||||
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
|
||||
|
||||
## How it works
|
||||
|
||||
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
|
||||
|
||||
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
|
||||
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
|
||||
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
|
||||
|
||||
## Privacy
|
||||
|
||||
@@ -24,8 +70,3 @@ No data ever leaves your device except the API calls to the banking services the
|
||||
## Disclaimer
|
||||
|
||||
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 8
|
||||
versionName = "1.0.9"
|
||||
versionCode = 7
|
||||
versionName = "1.0.8"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@@ -31,7 +30,6 @@ class BmlHistoryClient {
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
@@ -84,7 +82,6 @@ class BmlHistoryClient {
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
|
||||
@@ -66,22 +66,6 @@ data class BmlLoanDetail(
|
||||
val overdueAmount: Double
|
||||
)
|
||||
|
||||
data class BmlQrPayInfo(
|
||||
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
||||
val merchantName: String, // narrative1
|
||||
val merchantAddress: String, // narrative2 + narrative3
|
||||
val amount: Double, // 0.0 for static QR
|
||||
val currency: String
|
||||
)
|
||||
|
||||
data class BmlQrPayResult(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
val amount: String = "",
|
||||
val currency: String = "",
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class BmlForeignLimit(
|
||||
val type: String,
|
||||
val used: Double,
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlQrPayClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Resolves a BML QR URL to merchant details.
|
||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
||||
*/
|
||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||
val request = bmlApiRequest(session,
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw Exception("No response")
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success"))
|
||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||
val payload = json.getJSONObject("payload")
|
||||
val addr2 = payload.optString("narrative2").trim()
|
||||
val addr3 = payload.optString("narrative3").trim()
|
||||
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
|
||||
BmlQrPayInfo(
|
||||
requestId = payload.optString("trxn_hash"),
|
||||
merchantName = payload.optString("narrative1").trim(),
|
||||
merchantAddress = address,
|
||||
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
|
||||
currency = payload.optString("currency").ifBlank { "MVR" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-initiate step required for gateway QR (pay.bml.com.mv).
|
||||
* POST without channel — expects code 99 (OTP channel selection required).
|
||||
*/
|
||||
fun preInitiatePayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: return@use false
|
||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||
json.optBoolean("success") && json.optInt("code") == 99
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 — initiate: POST with channel but no OTP.
|
||||
* Returns true when server responds with code 22 (OTP generated).
|
||||
*/
|
||||
fun initiatePayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: return@use false
|
||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — confirm: POST with channel + OTP.
|
||||
* Returns [BmlQrPayResult] with success/error.
|
||||
*/
|
||||
fun confirmPayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String,
|
||||
otp: String
|
||||
): BmlQrPayResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("otp", otp)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string()
|
||||
?: return@use BmlQrPayResult(false, errorMessage = "No response")
|
||||
val json = try { JSONObject(body) } catch (_: Exception) {
|
||||
return@use BmlQrPayResult(false, errorMessage = "Parse error")
|
||||
}
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlQrPayResult(
|
||||
success = true,
|
||||
merchant = payload?.optString("merchant") ?: "",
|
||||
amount = payload?.optString("amount") ?: "",
|
||||
currency = payload?.optString("currency") ?: currency
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package sh.sar.basedbank.api.fahipay
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -33,10 +32,8 @@ class FahipayHistoryClient {
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
|
||||
@@ -5,7 +5,6 @@ import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MibHistoryClient {
|
||||
@@ -61,7 +60,6 @@ class MibHistoryClient {
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code in 500..599) throw BankServerException("MIB")
|
||||
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
|
||||
@@ -39,7 +39,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addNetworkInterceptor { chain ->
|
||||
.addInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
.header("User-Agent", "android/1.0")
|
||||
.header("Accept", "application/json")
|
||||
@@ -366,34 +366,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile image via P40.
|
||||
* [imageBase64] is a base64-encoded JPEG string.
|
||||
* Returns the new imageHash on success, or null on failure.
|
||||
*/
|
||||
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
|
||||
val payload = baseData(session, "P40").apply {
|
||||
put("profileId", profile.profileId)
|
||||
put("profileImage", imageBase64)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
if (!resp.optBoolean("success", false)) return null
|
||||
return resp.optString("imageHash").takeIf { it.isNotBlank() }
|
||||
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the profile image via P42.
|
||||
* Returns true on success.
|
||||
*/
|
||||
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
|
||||
val payload = baseData(session, "P42").apply {
|
||||
put("profileId", profile.profileId)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
return resp.optBoolean("success", false)
|
||||
}
|
||||
|
||||
private fun post(body: FormBody): String {
|
||||
val request = Request.Builder()
|
||||
.url(BASE_URL)
|
||||
|
||||
@@ -23,7 +23,6 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
@@ -150,15 +149,8 @@ class AccountHistoryFragment : Fragment() {
|
||||
pendingIconUrls.clear()
|
||||
firstPageDone = false
|
||||
fetcher = HistoryFetcher(account)
|
||||
// Restore cache immediately so data stays visible while refreshing
|
||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
loadNextPage()
|
||||
}
|
||||
|
||||
@@ -173,20 +165,9 @@ class AccountHistoryFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val transactions = try {
|
||||
fetcher.fetchNextPage(app, pageSize)
|
||||
} catch (e: java.io.IOException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
null
|
||||
} catch (e: BankServerException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
|
||||
null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val transactions = fetcher.fetchNextPage(app, pageSize)
|
||||
|
||||
isLoading = false
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (!firstPageDone) {
|
||||
firstPageDone = true
|
||||
@@ -194,13 +175,6 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
if (transactions == null) {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
return@launch
|
||||
}
|
||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = transactions.filter { it.id !in existingIds }
|
||||
|
||||
@@ -3,13 +3,11 @@ package sh.sar.basedbank.ui.home
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
@@ -19,11 +17,7 @@ import sh.sar.basedbank.util.AccountListParser
|
||||
|
||||
class AccountsAdapter(
|
||||
accounts: List<BankAccount>,
|
||||
private val onAccountClick: (BankAccount) -> Unit = {},
|
||||
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
||||
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
||||
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
||||
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
||||
private val onAccountClick: (BankAccount) -> Unit = {}
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
@@ -118,8 +112,6 @@ class AccountsAdapter(
|
||||
|
||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
private var boundHash: String? = null
|
||||
|
||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||
binding.tvAccountName.text = display.name
|
||||
binding.tvAccountNumber.text = display.number
|
||||
@@ -131,30 +123,6 @@ class AccountsAdapter(
|
||||
copyToClipboard(it.context, display.number)
|
||||
true
|
||||
}
|
||||
|
||||
val staticLogo = when (account.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||
else binding.ivBankLogo.setImageDrawable(null)
|
||||
|
||||
val hash = account.profileImageHash
|
||||
boundHash = hash
|
||||
when {
|
||||
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
|
||||
profileImageLoader.invoke(hash) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
|
||||
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -11,15 +8,9 @@ import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
|
||||
class AccountsFragment : Fragment() {
|
||||
|
||||
@@ -27,7 +18,6 @@ class AccountsFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private lateinit var adapter: AccountsAdapter
|
||||
private val profileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
||||
@@ -35,49 +25,9 @@ class AccountsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
adapter = AccountsAdapter(
|
||||
accounts = emptyList(),
|
||||
onAccountClick = { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
},
|
||||
profileImageLoader = { hash, onLoaded ->
|
||||
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.anyMibSession() ?: return@withContext null
|
||||
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
|
||||
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (bitmap != null) {
|
||||
profileImageCache[hash] = bitmap
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
},
|
||||
localProfileImageLoader = { loginTag, profileId, onLoaded ->
|
||||
val cacheKey = "$loginTag|$profileId"
|
||||
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val ctx = requireContext()
|
||||
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
|
||||
} else if (loginTag.startsWith("fahipay_")) {
|
||||
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
} else null
|
||||
}
|
||||
if (bitmap != null) {
|
||||
profileImageCache[cacheKey] = bitmap
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
adapter = AccountsAdapter(emptyList()) { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
}
|
||||
adapter.onTransferClick = { account ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||
}
|
||||
@@ -93,10 +43,7 @@ class AccountsFragment : Fragment() {
|
||||
insets
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||
adapter.updateAccounts(it)
|
||||
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.Filter
|
||||
import android.widget.Filterable
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
class BmlQrPayFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentBmlQrPayBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var merchantInfo: BmlQrPayInfo? = null
|
||||
private var selectedAccount: BankAccount? = null
|
||||
|
||||
companion object {
|
||||
private const val ARG_QR_URL = "qr_url"
|
||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||
|
||||
fun newInstance(qrUrl: String, fromAccountNumber: String? = null) = BmlQrPayFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_QR_URL, qrUrl)
|
||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentBmlQrPayBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setupFromDropdown()
|
||||
|
||||
binding.etAmount.addTextChangedListener { updatePayButton() }
|
||||
|
||||
binding.btnClearFromInfo.setOnClickListener {
|
||||
selectedAccount = null
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.actvFrom.setText("", false)
|
||||
updatePayButton()
|
||||
}
|
||||
|
||||
binding.btnPay.setOnClickListener { initiatePay() }
|
||||
|
||||
val qrUrl = arguments?.getString(ARG_QR_URL) ?: run {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed(); return
|
||||
}
|
||||
lookupMerchant(qrUrl)
|
||||
}
|
||||
|
||||
private fun setupFromDropdown() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
val bmlAccounts = accounts.filter {
|
||||
it.bank == "BML" &&
|
||||
it.profileType != "BML_LOAN" &&
|
||||
it.profileType != "BML_CREDIT"
|
||||
}
|
||||
val adapter = BmlAccountAdapter(bmlAccounts)
|
||||
binding.actvFrom.setAdapter(adapter)
|
||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||
val picked = adapter.getItem(position) as? BankAccount ?: return@setOnItemClickListener
|
||||
selectedAccount = picked
|
||||
showFromCard(picked)
|
||||
updatePayButton()
|
||||
}
|
||||
|
||||
// Pre-select card passed in from the card wallet/dashboard
|
||||
val preselect = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||
if (preselect != null && selectedAccount == null) {
|
||||
bmlAccounts.firstOrNull { it.accountNumber == preselect }?.let {
|
||||
selectedAccount = it
|
||||
showFromCard(it)
|
||||
updatePayButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFromCard(account: BankAccount) {
|
||||
binding.tvFromAccountName.text = account.accountBriefName
|
||||
binding.tvFromAccountNumber.text = account.accountNumber
|
||||
val currency = account.currencyName.ifBlank { "MVR" }
|
||||
binding.tvFromBalance.text = "$currency ${account.availableBalance}"
|
||||
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, "#0066A1"))
|
||||
binding.tilFrom.visibility = View.GONE
|
||||
binding.cardFromInfo.visibility = View.VISIBLE
|
||||
|
||||
// Update amount prefix to match account currency
|
||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||
}
|
||||
|
||||
private fun lookupMerchant(qrUrl: String) {
|
||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val session = app.anyBmlSession() ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
return
|
||||
}
|
||||
|
||||
binding.tvLookingUp.visibility = View.VISIBLE
|
||||
binding.cardMerchant.visibility = View.GONE
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
binding.tvLookingUp.visibility = View.GONE
|
||||
if (info == null) {
|
||||
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
return@launch
|
||||
}
|
||||
merchantInfo = info
|
||||
populateMerchant(info)
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateMerchant(info: BmlQrPayInfo) {
|
||||
binding.tvMerchantName.text = info.merchantName
|
||||
binding.tvMerchantAddress.text = info.merchantAddress
|
||||
binding.ivMerchantIcon.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
binding.cardMerchant.visibility = View.VISIBLE
|
||||
|
||||
// Dynamic QR: pre-fill amount and lock the field
|
||||
if (info.amount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(info.amount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
updatePayButton()
|
||||
}
|
||||
|
||||
private fun updatePayButton() {
|
||||
val merchant = merchantInfo ?: run { binding.btnPay.isEnabled = false; return }
|
||||
val account = selectedAccount ?: run { binding.btnPay.isEnabled = false; return }
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
binding.btnPay.isEnabled = amount > 0.0
|
||||
}
|
||||
|
||||
private fun initiatePay() {
|
||||
val info = merchantInfo ?: return
|
||||
val account = selectedAccount ?: run {
|
||||
Toast.makeText(requireContext(), R.string.bml_qr_select_account, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) {
|
||||
binding.tilAmount.error = "Enter a valid amount"
|
||||
return
|
||||
}
|
||||
binding.tilAmount.error = null
|
||||
|
||||
val debitAccount = account.internalId.ifBlank {
|
||||
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val currency = info.currency
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.transfer)
|
||||
.setMessage("Pay $currency ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${account.accountBriefName} · ${account.accountNumber}")
|
||||
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
|
||||
executePay(account, debitAccount, info.requestId, amount, currency, info.merchantName)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun executePay(
|
||||
account: BankAccount,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String,
|
||||
merchantName: String
|
||||
) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginId = account.loginTag.removePrefix("bml_")
|
||||
val session = app.bmlSessionFor(account) ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: run {
|
||||
Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnPay.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val initiated = BmlQrPayClient().initiatePayment(session, debitAccount, requestId, amount, currency)
|
||||
if (!initiated) return@withContext null
|
||||
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) } ?: otp
|
||||
BmlQrPayClient().confirmPayment(session, debitAccount, requestId, amount, currency, confirmOtp)
|
||||
} catch (e: Exception) {
|
||||
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (result == null) {
|
||||
binding.btnPay.isEnabled = true
|
||||
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
showSuccessDialog(
|
||||
merchant = result.merchant.ifBlank { merchantName },
|
||||
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
currency = result.currency.ifBlank { currency }
|
||||
)
|
||||
} else {
|
||||
binding.btnPay.isEnabled = true
|
||||
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||
}
|
||||
|
||||
// Green checkmark icon
|
||||
container.addView(ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_check_circle)
|
||||
setColorFilter(Color.parseColor("#4CAF50"))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
(64 * dp).toInt(), (64 * dp).toInt()
|
||||
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
|
||||
})
|
||||
|
||||
// Amount
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = "$currency $amount"
|
||||
textSize = 28f
|
||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK))
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
||||
})
|
||||
|
||||
// Merchant name
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = merchant
|
||||
textSize = 14f
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY))
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = Gravity.CENTER_HORIZONTAL }
|
||||
})
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.bml_qr_payment_success)
|
||||
.setView(container)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||
val sizePx = (resources.displayMetrics.density * 40).toInt()
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.color = bgColor
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = sizePx * 0.42f
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val metrics = paint.fontMetrics
|
||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
return bm
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = "BML QR Pay"
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class BmlAccountAdapter(private val accounts: List<BankAccount>) :
|
||||
BaseAdapter(), Filterable {
|
||||
|
||||
override fun getCount() = accounts.size
|
||||
override fun getItem(position: Int) = accounts[position]
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getDropDownView(position, convertView, parent)
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val acc = accounts[position]
|
||||
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||
convertView.tag as ItemAccountDropdownBinding
|
||||
} else {
|
||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
.also { it.root.tag = it }
|
||||
}
|
||||
val ownerPrefix = if (acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.root.alpha = 1f
|
||||
return b.root
|
||||
}
|
||||
|
||||
override fun getFilter() = object : Filter() {
|
||||
override fun performFiltering(c: CharSequence?) =
|
||||
FilterResults().apply { values = accounts; count = accounts.size }
|
||||
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||
override fun convertResultToString(r: Any?) =
|
||||
(r as? BankAccount)?.let {
|
||||
val prefix = if (it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||
"$prefix${it.accountBriefName}"
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,7 @@ class ContactPickerAdapter(
|
||||
val isSameAsFrom: Boolean = false,
|
||||
val isManualEntry: Boolean = false,
|
||||
val imageHash: String? = null,
|
||||
val inactiveReason: String? = null,
|
||||
val balance: String? = null,
|
||||
val bankLogoRes: Int? = null
|
||||
val inactiveReason: String? = null
|
||||
) : PickerItem()
|
||||
}
|
||||
|
||||
@@ -91,31 +89,14 @@ class ContactPickerAdapter(
|
||||
binding.tvPrimary.text = item.displayName
|
||||
binding.tvSecondary.text = item.subtitle
|
||||
|
||||
if (item.balance != null) {
|
||||
binding.tvBalance.text = item.balance
|
||||
binding.tvBalance.visibility = android.view.View.VISIBLE
|
||||
} else {
|
||||
binding.tvBalance.visibility = android.view.View.GONE
|
||||
}
|
||||
|
||||
val cached = item.imageHash?.let { imageCache[it] }
|
||||
when {
|
||||
cached != null -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivIcon.setImageBitmap(cached)
|
||||
}
|
||||
item.bankLogoRes != null -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivIcon.setImageResource(item.bankLogoRes)
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
}
|
||||
else -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
}
|
||||
if (cached != null) {
|
||||
binding.ivIcon.setImageBitmap(cached)
|
||||
} else {
|
||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
}
|
||||
|
||||
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
|
||||
|
||||
@@ -24,11 +24,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
|
||||
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
@@ -151,7 +147,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
}
|
||||
|
||||
private fun attachMediator(pages: List<TabDef>) {
|
||||
@@ -229,29 +225,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
for (acc in filteredRegular) {
|
||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||
val isSame = acc.accountNumber == fromAccountNumber
|
||||
val parsedBalance = AccountListParser.from(acc)?.balance
|
||||
?: "${acc.currencyName} ${acc.availableBalance}"
|
||||
val balance = if (hide) maskAmount(parsedBalance) else parsedBalance
|
||||
val logoRes = when (acc.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
val accBal = if (hide) "••••••" else acc.availableBalance
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = acc.accountNumber,
|
||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} $accBal",
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
imageHash = acc.profileImageHash,
|
||||
inactiveReason = if (isSame) null
|
||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -265,26 +249,18 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||
val isSame = acc.accountNumber == fromAccountNumber
|
||||
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
|
||||
val isDebit = acc.profileType == "BML_DEBIT"
|
||||
val parsedBalance = if (isDebit) null
|
||||
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
|
||||
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
|
||||
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
val cardBal = if (hide) "••••••" else acc.availableBalance
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = acc.accountNumber,
|
||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} $cardBal",
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
imageHash = acc.profileImageHash,
|
||||
inactiveReason = if (isSame) null
|
||||
else if (!isActive) acc.statusDesc
|
||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -315,17 +291,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun fetchImage(hash: String) {
|
||||
if (!pendingHashes.add(hash)) return
|
||||
// Local image keys for BML/Fahipay (prefixed with "local:")
|
||||
if (hash.startsWith("local:")) {
|
||||
val key = hash.removePrefix("local:")
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
|
||||
pendingHashes.remove(hash); return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val sess = session ?: return
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -353,16 +318,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
||||
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
||||
|
||||
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
|
||||
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
|
||||
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
|
||||
"FAHIPAY" -> {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
|
||||
@@ -48,6 +48,7 @@ class ContactsFragment : Fragment() {
|
||||
private var currentSearch: String = ""
|
||||
private var mediator: TabLayoutMediator? = null
|
||||
private lateinit var pagerAdapter: ContactsPagerAdapter
|
||||
private var contactsRefreshing = false
|
||||
|
||||
private data class TabPage(val categoryId: String?, val label: String)
|
||||
|
||||
@@ -135,8 +136,8 @@ class ContactsFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
contactsRefreshing = true
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
}
|
||||
|
||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||
@@ -148,6 +149,10 @@ class ContactsFragment : Fragment() {
|
||||
pagerAdapter.updateContacts(allContacts)
|
||||
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
if (contactsRefreshing) {
|
||||
contactsRefreshing = false
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -10,7 +8,6 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -35,22 +32,6 @@ class DashboardFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var pendingQrAccountNumber: String? = null
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
pendingQrAccountNumber = null
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -300,21 +281,18 @@ class DashboardFragment : Fragment() {
|
||||
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
||||
}
|
||||
}
|
||||
val isMib = item is CardItem.Mib
|
||||
btnPayQr.setOnClickListener {
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||
val nfcSupported = nfcAdapter != null
|
||||
btnPayNfc.isEnabled = nfcSupported
|
||||
btnPayNfc.setOnClickListener {
|
||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||
if (nfcSupported) {
|
||||
btnPayNfc.setOnClickListener {
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
btnPayNfc.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class FinancingFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private lateinit var adapter: FinancingAdapter
|
||||
private var financingRefreshing = false
|
||||
|
||||
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||
@@ -45,8 +46,8 @@ class FinancingFragment : Fragment() {
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
financingRefreshing = true
|
||||
(activity as? HomeActivity)?.triggerRefreshFinancing()
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||
@@ -73,6 +74,10 @@ class FinancingFragment : Fragment() {
|
||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
if (financingRefreshing) {
|
||||
financingRefreshing = false
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -549,12 +549,12 @@ fun applyNavLabelVisibility() {
|
||||
autoRefresh(store)
|
||||
}
|
||||
|
||||
fun showConnectivityBanner(message: String) {
|
||||
private fun showConnectivityBanner(message: String) {
|
||||
binding.connectivityBanner.text = message
|
||||
binding.connectivityBanner.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fun hideConnectivityBanner() {
|
||||
private fun hideConnectivityBanner() {
|
||||
binding.connectivityBanner.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ object NavCustomization {
|
||||
|
||||
data class NavItemDef(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
@DrawableRes val iconRes: Int,
|
||||
@StringRes val titleRes: Int,
|
||||
@StringRes val descriptionRes: Int
|
||||
@@ -17,48 +16,42 @@ object NavCustomization {
|
||||
|
||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||
val ALL_SWAPPABLE = listOf(
|
||||
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||
NavItemDef(R.id.nav_activities, "nav_activities", R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
|
||||
NavItemDef(R.id.nav_transfer_history, "nav_transfer_history", R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
||||
NavItemDef(R.id.nav_finances, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
||||
NavItemDef(R.id.nav_pay_with_card, "nav_pay_with_card", R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
||||
NavItemDef(R.id.nav_card_settings, "nav_card_settings", R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
||||
NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
||||
NavItemDef(R.id.nav_settings, "nav_settings", R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
||||
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
|
||||
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
||||
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
||||
NavItemDef(R.id.nav_pay_with_card, R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
||||
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
||||
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
||||
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
||||
)
|
||||
|
||||
private fun keyToId(key: String?, default: Int) =
|
||||
ALL_SWAPPABLE.find { it.key == key }?.id ?: default
|
||||
|
||||
private fun idToKey(id: Int) =
|
||||
ALL_SWAPPABLE.find { it.id == id }?.key
|
||||
|
||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
||||
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
|
||||
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
||||
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
|
||||
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
|
||||
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
|
||||
)
|
||||
|
||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||
prefs.edit()
|
||||
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
||||
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
|
||||
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
||||
.putInt("bottom_nav_slot_1", slots[0])
|
||||
.putInt("bottom_nav_slot_2", slots[1])
|
||||
.putInt("bottom_nav_slot_3", slots[2])
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
||||
keyToId(prefs.getString("quick_action_1_key", null), R.id.nav_transfer),
|
||||
keyToId(prefs.getString("quick_action_2_key", null), R.id.nav_pay_mv_qr),
|
||||
prefs.getInt("quick_action_1", R.id.nav_transfer),
|
||||
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
|
||||
)
|
||||
|
||||
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
||||
prefs.edit()
|
||||
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
|
||||
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
|
||||
.putInt("quick_action_1", ids[0])
|
||||
.putInt("quick_action_2", ids[1])
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,15 +32,11 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@@ -53,19 +49,10 @@ class PayMvQrFragment : Fragment() {
|
||||
private var selectedAccount: BankAccount? = null
|
||||
private var generatedBitmap: Bitmap? = null
|
||||
private var generateJob: Job? = null
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
|
||||
// BML card/gateway QR — hand off to dedicated payment screen
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr == null || qr.accountNumber == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
@@ -415,105 +402,9 @@ class PayMvQrFragment : Fragment() {
|
||||
}
|
||||
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
|
||||
val displayData = AccountListParser.from(acc)
|
||||
val typeLabel = displayData?.typeLabel
|
||||
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||
else acc.accountTypeName.trim()
|
||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||
if (typeLabel.isNotBlank()) {
|
||||
b.tvDropdownAccountType.text = typeLabel
|
||||
b.tvDropdownAccountType.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.tvDropdownAccountType.visibility = View.GONE
|
||||
}
|
||||
b.tvDropdownBalance.text = displayData?.balance ?: ""
|
||||
b.tvDropdownBalance.text = ""
|
||||
b.root.alpha = 1f
|
||||
|
||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||
when {
|
||||
networkIcon != null -> {
|
||||
b.ivDropdownCardLogo.setImageResource(networkIcon)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "BML" -> {
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.bml_logo_vector)
|
||||
if (acc.profileId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "FAHIPAY" -> {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.fahipay_logo)
|
||||
if (loginId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "MIB" -> {
|
||||
val hash = acc.profileImageHash
|
||||
val cached = hash?.let { dropdownProfileImageCache[it] }
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = hash
|
||||
if (cached != null) {
|
||||
imageView.setImageBitmap(cached)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.mib_logo)
|
||||
if (hash != null) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sess = app.anyMibSession() ?: return@withContext null
|
||||
val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null
|
||||
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[hash] = bitmap
|
||||
if (imageView.tag == hash) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
else -> b.ivDropdownCardLogo.visibility = View.GONE
|
||||
}
|
||||
return b.root
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
@@ -12,7 +10,6 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -32,22 +29,6 @@ class PayWithCardFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var pendingQrAccountNumber: String? = null
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
pendingQrAccountNumber = null
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -150,21 +131,18 @@ class PayWithCardFragment : Fragment() {
|
||||
bindCardStatus(tvCardStatus, bmlStatus)
|
||||
}
|
||||
}
|
||||
val isMib = item is CardItem.Mib
|
||||
btnPayQr.setOnClickListener {
|
||||
if (isMib) {
|
||||
Toast.makeText(context, R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
|
||||
val nfcSupported = nfcAdapter != null
|
||||
btnPayNfc.isEnabled = nfcSupported
|
||||
btnPayNfc.setOnClickListener {
|
||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
if (nfcSupported) {
|
||||
btnPayNfc.setOnClickListener {
|
||||
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
btnPayNfc.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,6 @@ import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.view.ScaleGestureDetector
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -58,8 +52,6 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
textMode = ZxingCpp.TextMode.PLAIN
|
||||
)
|
||||
|
||||
private var camera: Camera? = null
|
||||
private var torchEnabled = false
|
||||
private var cameraStarted = false
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
@@ -112,36 +104,8 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
}
|
||||
insets
|
||||
}
|
||||
binding.btnCancel.setOnClickListener { finish() }
|
||||
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
||||
binding.zoomSlider.addOnChangeListener { _, value, fromUser ->
|
||||
if (fromUser) camera?.cameraControl?.setLinearZoom(value)
|
||||
}
|
||||
val scaleDetector = ScaleGestureDetector(this,
|
||||
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
val state = camera?.cameraInfo?.zoomState?.value ?: return true
|
||||
camera?.cameraControl?.setZoomRatio(
|
||||
(state.zoomRatio * detector.scaleFactor)
|
||||
.coerceIn(state.minZoomRatio, state.maxZoomRatio)
|
||||
)
|
||||
return true
|
||||
}
|
||||
})
|
||||
binding.previewView.setOnTouchListener { _, event ->
|
||||
scaleDetector.onTouchEvent(event)
|
||||
true
|
||||
}
|
||||
binding.btnFlashlight.setOnClickListener {
|
||||
torchEnabled = !torchEnabled
|
||||
camera?.cameraControl?.enableTorch(torchEnabled)
|
||||
val drawableRes = if (torchEnabled) R.drawable.ic_flashlight_to_on else R.drawable.ic_flashlight_to_off
|
||||
val drawable = AppCompatResources.getDrawable(this, drawableRes)
|
||||
binding.btnFlashlight.icon = drawable
|
||||
(drawable as? Animatable)?.start()
|
||||
binding.btnFlashlight.iconTint = ColorStateList.valueOf(
|
||||
if (torchEnabled) Color.parseColor("#FFEB3B") else Color.WHITE
|
||||
)
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
@@ -215,12 +179,9 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
|
||||
try {
|
||||
provider.unbindAll()
|
||||
camera = provider.bindToLifecycle(
|
||||
provider.bindToLifecycle(
|
||||
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
||||
)
|
||||
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
|
||||
binding.zoomSlider.value = state.linearZoom
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -14,8 +10,6 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -28,13 +22,11 @@ import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.FinancingCache
|
||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlin.coroutines.resume
|
||||
@@ -45,8 +37,6 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
|
||||
class SettingsLoginsFragment : Fragment() {
|
||||
|
||||
@@ -54,248 +44,6 @@ class SettingsLoginsFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
// ── Profile image picker state ─────────────────────────────────────────────
|
||||
|
||||
private sealed class PendingImageTarget {
|
||||
data class Mib(val loginId: String, val profile: MibProfile) : PendingImageTarget()
|
||||
data class Bml(val profileId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||
data class Fahipay(val loginId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||
}
|
||||
private var pendingImageTarget: PendingImageTarget? = null
|
||||
private var cameraPhotoUri: Uri? = null
|
||||
|
||||
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||
handlePickedImage(bitmap)
|
||||
}
|
||||
|
||||
private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (!success) return@registerForActivityResult
|
||||
val uri = cameraPhotoUri ?: return@registerForActivityResult
|
||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||
handlePickedImage(bitmap)
|
||||
}
|
||||
|
||||
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val ctx = requireContext()
|
||||
val inputStream = ctx.contentResolver.openInputStream(uri) ?: return null
|
||||
val original = BitmapFactory.decodeStream(inputStream)
|
||||
inputStream.close()
|
||||
if (original == null) return null
|
||||
val maxDim = 512
|
||||
val scale = minOf(maxDim.toFloat() / original.width, maxDim.toFloat() / original.height, 1f)
|
||||
if (scale < 1f) {
|
||||
Bitmap.createScaledBitmap(original, (original.width * scale).toInt(), (original.height * scale).toInt(), true)
|
||||
} else original
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun handlePickedImage(bitmap: Bitmap) {
|
||||
val target = pendingImageTarget ?: return
|
||||
when (target) {
|
||||
is PendingImageTarget.Mib -> uploadMibProfileImage(target.loginId, target.profile, bitmap)
|
||||
is PendingImageTarget.Bml -> {
|
||||
ProfileImageStore.save(requireContext(), ProfileImageStore.bmlKey(target.profileId), bitmap)
|
||||
target.refreshImage(bitmap)
|
||||
}
|
||||
is PendingImageTarget.Fahipay -> {
|
||||
ProfileImageStore.save(requireContext(), ProfileImageStore.fahipayKey(target.loginId), bitmap)
|
||||
target.refreshImage(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadMibProfileImage(loginId: String, profile: MibProfile, bitmap: Bitmap) {
|
||||
val ctx = requireContext()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val progress = MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_uploading))
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.mibSessions[loginId] ?: app.anyMibSession() ?: return@withContext null
|
||||
val flow = app.mibLoginFlows[loginId] ?: return@withContext null
|
||||
flow.switchProfile(session, profile)
|
||||
// MIB server enforces a small payload limit (~4KB); scale to 100px max
|
||||
val mibMax = 100
|
||||
val mibScale = minOf(mibMax.toFloat() / bitmap.width, mibMax.toFloat() / bitmap.height, 1f)
|
||||
val mibBitmap = if (mibScale < 1f)
|
||||
Bitmap.createScaledBitmap(bitmap, (bitmap.width * mibScale).toInt(), (bitmap.height * mibScale).toInt(), true)
|
||||
else bitmap
|
||||
val baos = ByteArrayOutputStream()
|
||||
mibBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos)
|
||||
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
flow.uploadProfileImage(session, profile, base64)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
progress.dismiss()
|
||||
if (result == null) {
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_upload_failed))
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.show()
|
||||
} else {
|
||||
clearAllCaches(ctx)
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showImagePickerMenu(anchor: View, target: PendingImageTarget, currentBitmap: Bitmap?) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
|
||||
val items = mutableListOf<Triple<Int, String, () -> Unit>>()
|
||||
items += Triple(R.drawable.ic_image, getString(R.string.profile_image_select)) {
|
||||
pendingImageTarget = target
|
||||
galleryLauncher.launch("image/*")
|
||||
}
|
||||
items += Triple(R.drawable.ic_camera, getString(R.string.profile_image_camera)) {
|
||||
pendingImageTarget = target
|
||||
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
|
||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
|
||||
cameraPhotoUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
|
||||
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
|
||||
removeProfileImage(ctx, target)
|
||||
}
|
||||
}
|
||||
|
||||
val list = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val vp = (8 * dp).toInt()
|
||||
setPadding(0, vp, 0, vp)
|
||||
}
|
||||
for ((iconRes, label, action) in items) {
|
||||
val iconSize = (24 * dp).toInt()
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
val hp = (24 * dp).toInt(); val vp2 = (12 * dp).toInt()
|
||||
setPadding(hp, vp2, hp, vp2)
|
||||
}
|
||||
val iconColor = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||
val icon = ImageView(ctx).apply {
|
||||
setImageResource(iconRes)
|
||||
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
|
||||
}
|
||||
val tv = TextView(ctx).apply {
|
||||
text = label
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginStart = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
row.addView(icon, LinearLayout.LayoutParams(iconSize, iconSize))
|
||||
row.addView(tv)
|
||||
list.addView(row)
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.profile_image_title)
|
||||
.setView(list)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
val rows = (0 until list.childCount).map { list.getChildAt(it) }
|
||||
rows.forEachIndexed { i, row ->
|
||||
row.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
items[i].third()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSavedImage(ctx: Context, target: PendingImageTarget): Boolean = when (target) {
|
||||
is PendingImageTarget.Mib -> target.profile.customerImage != null
|
||||
is PendingImageTarget.Bml -> ProfileImageStore.exists(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||
is PendingImageTarget.Fahipay -> ProfileImageStore.exists(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||
}
|
||||
|
||||
private fun removeProfileImage(ctx: Context, target: PendingImageTarget) {
|
||||
when (target) {
|
||||
is PendingImageTarget.Mib -> {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val progress = MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_deleting))
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.mibSessions[target.loginId] ?: app.anyMibSession() ?: return@withContext
|
||||
val flow = app.mibLoginFlows[target.loginId] ?: return@withContext
|
||||
flow.switchProfile(session, target.profile)
|
||||
flow.deleteProfileImage(session, target.profile)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
progress.dismiss()
|
||||
clearAllCaches(ctx)
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
is PendingImageTarget.Bml -> {
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||
target.refreshImage(null)
|
||||
}
|
||||
is PendingImageTarget.Fahipay -> {
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||
target.refreshImage(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePencilButton(ctx: Context): ImageView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val size = (32 * dp).toInt()
|
||||
return ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_edit)
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeCircleAvatarView(ctx: Context, sizeDp: Int): ImageView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val size = (sizeDp * dp).toInt()
|
||||
return ImageView(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
clipToOutline = true
|
||||
outlineProvider = object : android.view.ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: android.graphics.Outline) {
|
||||
outline.setOval(0, 0, view.width, view.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAvatarBitmap(iv: ImageView, bitmap: Bitmap?) {
|
||||
if (bitmap != null) {
|
||||
iv.setImageBitmap(bitmap)
|
||||
iv.imageTintList = null
|
||||
} else {
|
||||
iv.setImageResource(R.drawable.ic_image)
|
||||
val color = com.google.android.material.color.MaterialColors.getColor(
|
||||
iv, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||
iv.imageTintList = android.content.res.ColorStateList.valueOf(color)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -352,7 +100,18 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
showLoginDetails(
|
||||
title = getString(R.string.fahipay_name),
|
||||
details = buildString {
|
||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
|
||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
|
||||
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
|
||||
}.trim(),
|
||||
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,21 +218,8 @@ class SettingsLoginsFragment : Fragment() {
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val pencil = makePencilButton(ctx).apply {
|
||||
alpha = 0.38f
|
||||
isEnabled = false
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = p.profileId !in hidden
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
android.widget.Toast.makeText(ctx, "Work in progress", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
row.addView(textCol)
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
p to toggle
|
||||
@@ -581,10 +327,6 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
|
||||
val toggleRows = bmlProfiles.map { p ->
|
||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
setAvatarBitmap(avatarIv, currentBitmap)
|
||||
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
@@ -607,24 +349,8 @@ class SettingsLoginsFragment : Fragment() {
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val pencil = makePencilButton(ctx)
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = p.profileId !in hidden
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val target = PendingImageTarget.Bml(p.profileId) { newBitmap ->
|
||||
setAvatarBitmap(avatarIv, newBitmap)
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
showImagePickerMenu(pencil, target, cur)
|
||||
}
|
||||
val avatarSize = (36 * dp).toInt()
|
||||
row.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
row.addView(textCol)
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
p to toggle
|
||||
@@ -888,75 +614,6 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFahipayLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: CredentialStore.FahipayUserProfile?
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||
}
|
||||
scroll.addView(container)
|
||||
|
||||
// Avatar row with pencil
|
||||
val avatarRow = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val avatarSize = (56 * dp).toInt()
|
||||
val avatarIv = makeCircleAvatarView(ctx, 56)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
setAvatarBitmap(avatarIv, currentBitmap)
|
||||
|
||||
val pencil = makePencilButton(ctx)
|
||||
val target = PendingImageTarget.Fahipay(loginId) { newBitmap ->
|
||||
setAvatarBitmap(avatarIv, newBitmap)
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
showImagePickerMenu(pencil, target, cur)
|
||||
}
|
||||
avatarRow.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||
avatarRow.addView(pencil)
|
||||
container.addView(avatarRow)
|
||||
|
||||
// Account info lines
|
||||
listOfNotNull(
|
||||
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
|
||||
profile?.nid?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.fahipay_name))
|
||||
.setView(scroll)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
@@ -985,7 +642,6 @@ class SettingsLoginsFragment : Fragment() {
|
||||
app.mibLoginFlows.remove(loginId)
|
||||
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
||||
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
||||
viewModel.financing.value = emptyList()
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
@@ -996,17 +652,12 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
// Remove all per-profile sessions for this login from the in-memory map
|
||||
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
||||
profiles.forEach { p ->
|
||||
app.bmlSessions.remove(p.profileId)
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
}
|
||||
profiles.forEach { app.bmlSessions.remove(it.profileId) }
|
||||
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||
store.clearBmlCredentials(loginId)
|
||||
app.bmlProfilesMap.remove(loginId)
|
||||
app.bmlLoginFlows.remove(loginId)
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||
viewModel.bmlLimits.value = emptyList()
|
||||
viewModel.bmlLoanDetails.value = emptyMap()
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
@@ -1014,7 +665,6 @@ class SettingsLoginsFragment : Fragment() {
|
||||
|
||||
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
||||
val ctx = requireContext()
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
store.clearFahipayCredentials(loginId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.fahipaySessions.remove(loginId)
|
||||
@@ -1027,7 +677,6 @@ class SettingsLoginsFragment : Fragment() {
|
||||
private fun clearAllCaches(ctx: Context) {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
// Note: ProfileImageStore is intentionally NOT cleared here — profile images are user-set data
|
||||
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
|
||||
import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.FinancingCache
|
||||
@@ -23,7 +21,6 @@ class SettingsStorageFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsStorageBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
|
||||
@@ -35,7 +32,6 @@ class SettingsStorageFragment : Fragment() {
|
||||
val ctx = requireContext()
|
||||
clearAllCaches(ctx)
|
||||
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,13 +48,6 @@ class SettingsStorageFragment : Fragment() {
|
||||
private fun clearAllCaches(ctx: Context) {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
viewModel.accounts.value = emptyList()
|
||||
viewModel.mibCards.value = null
|
||||
viewModel.financing.value = emptyList()
|
||||
viewModel.bmlLoanDetails.value = emptyMap()
|
||||
viewModel.bmlLimits.value = emptyList()
|
||||
viewModel.contacts.value = emptyList()
|
||||
viewModel.contactCategories.value = emptyList()
|
||||
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,6 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
@@ -61,8 +59,6 @@ import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
@@ -86,17 +82,11 @@ class TransferFragment : Fragment() {
|
||||
private var resolvedAccountNumber = ""
|
||||
private var resolvedRecipientName = ""
|
||||
private var resolvedBankName = ""
|
||||
private var resolvedToOwnAccount: BankAccount? = null
|
||||
|
||||
// Selected Fahipay service when source is Fahipay and destination is a phone number
|
||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||
private var selectedFahipayService: String? = null
|
||||
|
||||
// BML QR merchant payment mode (set when navigated from a card QR scan)
|
||||
private var bmlQrInfo: BmlQrPayInfo? = null
|
||||
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
// BML business profile OTP flow state
|
||||
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||
private var bmlOtpState = BmlOtpState.NONE
|
||||
@@ -123,14 +113,6 @@ class TransferFragment : Fragment() {
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
|
||||
// BML card/gateway QR — hand off to dedicated payment screen
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr == null || qr.accountNumber == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
@@ -150,14 +132,6 @@ class TransferFragment : Fragment() {
|
||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||
|
||||
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_BML_QR_URL, qrUrl)
|
||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||
}
|
||||
}
|
||||
|
||||
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||
@@ -208,7 +182,6 @@ class TransferFragment : Fragment() {
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||
accountDropdownAdapter?.notifyDataSetChanged()
|
||||
selectedAccount?.let { showFromCard(it) }
|
||||
resolvedToOwnAccount?.let { showToCard(it) }
|
||||
}
|
||||
|
||||
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||
@@ -251,58 +224,6 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||
|
||||
arguments?.getString(ARG_BML_QR_URL)?.let { lookupBmlQrMerchant(it) }
|
||||
}
|
||||
|
||||
private fun lookupBmlQrMerchant(qrUrl: String) {
|
||||
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
||||
val base64Url = android.util.Base64.encodeToString(
|
||||
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val session = app.anyBmlSession() ?: return
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
if (info == null) {
|
||||
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
return@launch
|
||||
}
|
||||
bmlQrInfo = info
|
||||
|
||||
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||
binding.tvToAccountName.text = info.merchantName
|
||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
binding.btnClearToInfo.visibility = View.GONE
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill amount if dynamic QR
|
||||
if (info.amount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(info.amount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
// Remarks not applicable for merchant QR payments
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLookupLoading() {
|
||||
@@ -372,102 +293,27 @@ class TransferFragment : Fragment() {
|
||||
val bankLabel = when (account.bank) {
|
||||
"BML" -> "BML"
|
||||
"FAHIPAY" -> "FP"
|
||||
"MIB" -> "MIB"
|
||||
else -> null
|
||||
}
|
||||
val typeLabel = AccountListParser.from(account)?.typeLabel
|
||||
?: if (account.bank == "BML") BmlDashboardParser.productLabel(account.accountTypeName)
|
||||
else account.accountTypeName.ifBlank { account.profileType }
|
||||
val typeLabel = when {
|
||||
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
||||
account.profileType == "BML_CREDIT" -> "Credit Card"
|
||||
account.profileType == "BML_DEBIT" -> "Debit Card"
|
||||
account.accountTypeName.isNotBlank() -> account.accountTypeName
|
||||
else -> account.profileType
|
||||
}
|
||||
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val isDebitCard = account.profileType == "BML_DEBIT"
|
||||
val balancePart = if (isDebitCard) null else "${account.currencyName} ${account.availableBalance}"
|
||||
val balancePart = "${account.currencyName} ${account.availableBalance}"
|
||||
binding.tvFromAccountName.text = account.accountBriefName
|
||||
binding.tvFromAccountNumber.text = account.accountNumber
|
||||
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel).joinToString(" · ")
|
||||
val balanceDisplay = balancePart?.let { if (hide) maskAmount(it) else it }
|
||||
if (balanceDisplay != null) {
|
||||
binding.tvFromBalance.text = balanceDisplay
|
||||
binding.tvFromBalance.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.tvFromBalance.visibility = View.GONE
|
||||
}
|
||||
val networkIcon = BmlCardParser.cardNetworkIcon(account)
|
||||
when {
|
||||
networkIcon != null -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(networkIcon)
|
||||
}
|
||||
account.bank == "BML" -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.bml_logo_vector)
|
||||
}
|
||||
account.bank == "FAHIPAY" -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||
}
|
||||
else -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.mib_logo)
|
||||
if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash)
|
||||
}
|
||||
}
|
||||
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, if (hide) maskAmount(balancePart) else balancePart).joinToString(" · ")
|
||||
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
|
||||
binding.tilFrom.visibility = View.GONE
|
||||
binding.cardFromInfo.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun showToCard(account: BankAccount) {
|
||||
resolvedToOwnAccount = account
|
||||
val colorHex = when (account.bank) {
|
||||
"BML" -> "#0066A1"
|
||||
"FAHIPAY" -> "#15BEA7"
|
||||
else -> "#FE860E"
|
||||
}
|
||||
val bankLabel = when (account.bank) {
|
||||
"BML" -> "BML"
|
||||
"FAHIPAY" -> "FP"
|
||||
"MIB" -> "MIB"
|
||||
else -> null
|
||||
}
|
||||
val typeLabel = AccountListParser.from(account)?.typeLabel
|
||||
?: if (account.bank == "BML") BmlDashboardParser.productLabel(account.accountTypeName)
|
||||
else account.accountTypeName.ifBlank { account.profileType }
|
||||
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val isDebitCard = account.profileType == "BML_DEBIT"
|
||||
val balancePart = if (isDebitCard) null else AccountListParser.from(account)?.balance
|
||||
?: "${account.currencyName} ${account.availableBalance}"
|
||||
|
||||
binding.tvToAccountName.text = account.accountBriefName
|
||||
binding.tvToBankBic.text = account.accountNumber
|
||||
binding.tvToAccountDetails.text = listOfNotNull(bankLabel, typeLabel).joinToString(" · ")
|
||||
binding.tvToAccountDetails.visibility = View.VISIBLE
|
||||
val balanceDisplay = balancePart?.let { if (hide) maskAmount(it) else it }
|
||||
if (balanceDisplay != null) {
|
||||
binding.tvToBalance.text = balanceDisplay
|
||||
binding.tvToBalance.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
}
|
||||
val networkIcon = BmlCardParser.cardNetworkIcon(account)
|
||||
when {
|
||||
networkIcon != null -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(networkIcon)
|
||||
}
|
||||
account.bank == "BML" -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.bml_logo_vector)
|
||||
}
|
||||
account.bank == "FAHIPAY" -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||
}
|
||||
else -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.mib_logo)
|
||||
if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true)
|
||||
}
|
||||
if (account.bank != "BML" && account.profileImageHash != null) {
|
||||
loadFromPhoto(account.profileImageHash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,10 +326,7 @@ class TransferFragment : Fragment() {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_binding != null) {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivFromPhoto.setImageBitmap(bitmap)
|
||||
}
|
||||
if (_binding != null) binding.ivFromPhoto.setImageBitmap(bitmap)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
@@ -499,7 +342,6 @@ class TransferFragment : Fragment() {
|
||||
binding.btnClearToInfo.setOnClickListener {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedToOwnAccount = null
|
||||
selectedFahipayService = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||
@@ -515,7 +357,6 @@ class TransferFragment : Fragment() {
|
||||
if (binding.cardToInfo.visibility == View.VISIBLE) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedToOwnAccount = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
@@ -625,16 +466,9 @@ class TransferFragment : Fragment() {
|
||||
resolvedRecipientName = info.accountName
|
||||
resolvedBankName = info.bankId
|
||||
|
||||
if (matchedAcc != null) {
|
||||
showToCard(matchedAcc)
|
||||
} else {
|
||||
binding.tvToAccountName.text = displayName
|
||||
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
||||
}
|
||||
binding.tvToAccountName.text = displayName
|
||||
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
|
||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
@@ -759,18 +593,9 @@ class TransferFragment : Fragment() {
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
||||
|
||||
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == accountNumber }
|
||||
if (ownAccount != null) {
|
||||
showToCard(ownAccount)
|
||||
} else {
|
||||
resolvedToOwnAccount = null
|
||||
binding.tvToAccountName.text = displayName
|
||||
binding.tvToBankBic.text = subtitle
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
||||
}
|
||||
binding.tvToAccountName.text = displayName
|
||||
binding.tvToBankBic.text = subtitle
|
||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
@@ -805,76 +630,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
// ── Transfer ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Shared confirm dialog with optional biometric gate, used for both transfers and QR payments. */
|
||||
private fun showConfirmWithBiometric(
|
||||
title: String,
|
||||
message: String? = null,
|
||||
customView: android.view.View? = null,
|
||||
biometricSubtitle: String,
|
||||
onConfirmed: () -> Unit
|
||||
) {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
if (customView != null) builder.setView(customView) else builder.setMessage(message)
|
||||
val dialog = builder.show()
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
val canAuth = BiometricManager.from(requireContext())
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
if (biometricTransferConfirm && canAuth) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
dialog.dismiss()
|
||||
onConfirmed()
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||
})
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(getString(R.string.biometric_transfer_title))
|
||||
.setSubtitle(biometricSubtitle)
|
||||
.setNegativeButtonText(getString(android.R.string.cancel))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initiateTransfer() {
|
||||
// BML QR merchant payment — uses shared confirm dialog, no receipt
|
||||
bmlQrInfo?.let { info ->
|
||||
val src = selectedAccount ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
showConfirmWithBiometric(
|
||||
title = getString(R.string.transfer),
|
||||
message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}",
|
||||
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||
onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val src = selectedAccount ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -954,31 +710,36 @@ class TransferFragment : Fragment() {
|
||||
activity.triggerRefresh()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||
} else if (!ok) {
|
||||
if (msg == "CONNECTIVITY") {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
} else {
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.transfer)
|
||||
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
|
||||
if (isUsdToMvr || isSrcCredit) {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
LinearLayout(ctx).apply {
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
|
||||
addView(TextView(ctx).apply { text = mainMsg })
|
||||
if (isUsdToMvr) addView(TextView(ctx).apply {
|
||||
}
|
||||
container.addView(TextView(ctx).apply { text = mainMsg })
|
||||
if (isUsdToMvr) {
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
|
||||
setTextColor(Color.RED)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||
})
|
||||
if (isSrcCredit) addView(TextView(ctx).apply {
|
||||
}
|
||||
if (isSrcCredit) {
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
|
||||
setTextColor(Color.RED)
|
||||
textSize = 16f
|
||||
@@ -986,123 +747,45 @@ class TransferFragment : Fragment() {
|
||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||
})
|
||||
}
|
||||
} else null
|
||||
showConfirmWithBiometric(
|
||||
title = getString(R.string.transfer),
|
||||
message = if (warningView == null) mainMsg else null,
|
||||
customView = warningView,
|
||||
biometricSubtitle = "$currency $amountStr → $destDisplay",
|
||||
onConfirmed = { doTransfer() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeBmlQrPayment(
|
||||
src: BankAccount,
|
||||
debitAccount: String,
|
||||
info: BmlQrPayInfo,
|
||||
amount: Double
|
||||
) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val session = bmlSessionFor(src) ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
dialogBuilder.setView(container)
|
||||
} else {
|
||||
dialogBuilder.setMessage(mainMsg)
|
||||
}
|
||||
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: run { Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
val dialog = dialogBuilder.show()
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (bmlGatewayQr) {
|
||||
val preOk = BmlQrPayClient().preInitiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!preOk) return@withContext null
|
||||
}
|
||||
val initiated = BmlQrPayClient().initiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!initiated) return@withContext null
|
||||
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)
|
||||
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||
BmlQrPayClient().confirmPayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||
} catch (e: Exception) {
|
||||
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (result == null) {
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
if (result.success) {
|
||||
showBmlQrSuccessDialog(
|
||||
merchant = result.merchant.ifBlank { info.merchantName },
|
||||
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
currency = result.currency.ifBlank { info.currency }
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
val canAuth = BiometricManager.from(requireContext())
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
if (biometricTransferConfirm && canAuth) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
dialog.dismiss()
|
||||
doTransfer()
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||
})
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(getString(R.string.biometric_transfer_title))
|
||||
.setSubtitle("$currency $amountStr → $destDisplay")
|
||||
.setNegativeButtonText(getString(android.R.string.cancel))
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
val container = android.widget.LinearLayout(ctx).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
gravity = android.view.Gravity.CENTER_HORIZONTAL
|
||||
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||
}
|
||||
container.addView(android.widget.ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_check_circle)
|
||||
setColorFilter(android.graphics.Color.parseColor("#4CAF50"))
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
(64 * dp).toInt(), (64 * dp).toInt()
|
||||
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
|
||||
})
|
||||
container.addView(android.widget.TextView(ctx).apply {
|
||||
text = "$currency $amount"
|
||||
textSize = 28f
|
||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK))
|
||||
gravity = android.view.Gravity.CENTER
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
||||
})
|
||||
container.addView(android.widget.TextView(ctx).apply {
|
||||
text = merchant
|
||||
textSize = 14f
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY))
|
||||
gravity = android.view.Gravity.CENTER
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL }
|
||||
})
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.bml_qr_payment_success)
|
||||
.setView(container)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun doMibTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
@@ -1168,7 +851,7 @@ class TransferFragment : Fragment() {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
Triple(false, e.message ?: "Transfer failed", null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1221,7 +904,7 @@ class TransferFragment : Fragment() {
|
||||
// Step 1: initiate
|
||||
val initiated = try {
|
||||
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
|
||||
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
|
||||
|
||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||
|
||||
@@ -1253,7 +936,7 @@ class TransferFragment : Fragment() {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
Triple(false, e.message ?: "Transfer failed", null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1495,7 +1178,7 @@ class TransferFragment : Fragment() {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
|
||||
Triple(false, e.message ?: "Transfer failed", null as TransferReceiptData?)
|
||||
}
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
@@ -1509,11 +1192,7 @@ class TransferFragment : Fragment() {
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||
} else {
|
||||
binding.btnTransfer.isEnabled = true
|
||||
if (msg == "CONNECTIVITY") {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
} else {
|
||||
binding.tilBmlOtp.error = msg
|
||||
}
|
||||
binding.tilBmlOtp.error = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1553,8 +1232,7 @@ class TransferFragment : Fragment() {
|
||||
private fun updateTransferButton() {
|
||||
if (bmlOtpState != BmlOtpState.NONE) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
val recipientReady = if (bmlQrInfo != null) bmlQrInfo != null else resolvedAccountNumber.isNotBlank()
|
||||
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||
val hasAll = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
|
||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||
val bankOffline = "NO_INTERNET" in errors ||
|
||||
@@ -1574,7 +1252,6 @@ class TransferFragment : Fragment() {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedBankName = ""
|
||||
resolvedToOwnAccount = null
|
||||
selectedFahipayService = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.chipGroupService.visibility = View.GONE
|
||||
@@ -1601,10 +1278,7 @@ class TransferFragment : Fragment() {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_binding != null) {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(bitmap)
|
||||
}
|
||||
if (_binding != null) binding.ivToPhoto.setImageBitmap(bitmap)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
@@ -1732,104 +1406,10 @@ class TransferFragment : Fragment() {
|
||||
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
val displayData = AccountListParser.from(acc)
|
||||
val typeLabel = displayData?.typeLabel
|
||||
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||
else acc.accountTypeName.trim()
|
||||
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
||||
if (typeLabel.isNotBlank()) {
|
||||
b.tvDropdownAccountType.text = typeLabel
|
||||
b.tvDropdownAccountType.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.tvDropdownAccountType.visibility = View.GONE
|
||||
}
|
||||
val balance = displayData?.balance ?: ""
|
||||
val balance = AccountListParser.from(acc)?.balance ?: ""
|
||||
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
||||
b.root.alpha = if (inactive) 0.4f else 1f
|
||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||
when {
|
||||
networkIcon != null -> {
|
||||
b.ivDropdownCardLogo.setImageResource(networkIcon)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "BML" -> {
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.bml_logo_vector)
|
||||
if (acc.profileId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "FAHIPAY" -> {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.fahipay_logo)
|
||||
if (loginId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "MIB" -> {
|
||||
val hash = acc.profileImageHash
|
||||
val cached = hash?.let { dropdownProfileImageCache[it] }
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = hash
|
||||
if (cached != null) {
|
||||
imageView.setImageBitmap(cached)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.mib_logo)
|
||||
if (hash != null) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sess = app.anyMibSession() ?: return@withContext null
|
||||
val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null
|
||||
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
|
||||
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[hash] = bitmap
|
||||
if (imageView.tag == hash) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
else -> b.ivDropdownCardLogo.visibility = View.GONE
|
||||
}
|
||||
b.root
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||
@@ -162,15 +161,8 @@ class TransferHistoryFragment : Fragment() {
|
||||
val accounts = accountStates.map { it.account }
|
||||
accountStates.clear()
|
||||
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||
// Restore cache immediately so data stays visible while refreshing
|
||||
val cached = TransactionCache.load(requireContext(), "transfer")
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
loadNextPages()
|
||||
}
|
||||
|
||||
@@ -186,14 +178,6 @@ class TransferHistoryFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val bannerMsg = java.util.concurrent.atomic.AtomicReference<String?>(null)
|
||||
fun trackError(e: Exception) {
|
||||
when {
|
||||
e is java.io.IOException -> bannerMsg.compareAndSet(null, "IO")
|
||||
e is BankServerException -> bannerMsg.compareAndSet(null, "SERVER:${e.bankName}")
|
||||
}
|
||||
}
|
||||
|
||||
val newTransactions = withContext(Dispatchers.IO) {
|
||||
val results = mutableListOf<BankTransaction>()
|
||||
|
||||
@@ -231,7 +215,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
list
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
|
||||
} catch (_: Exception) { emptyList<BankTransaction>() }
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
@@ -249,7 +233,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
if (total > 0) state.fahipayTotal = total
|
||||
state.fahipayNextStart += list.size
|
||||
results.addAll(list)
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||
@@ -258,25 +242,23 @@ class TransferHistoryFragment : Fragment() {
|
||||
val session = app.mibSessions[loginId] ?: continue
|
||||
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
||||
app.mibMutex.withLock {
|
||||
try {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||
for (state in states) {
|
||||
try {
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
start = state.mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) state.mibTotalCount = total
|
||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
results.addAll(list)
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
}
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||
for (state in states) {
|
||||
try {
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
start = state.mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) state.mibTotalCount = total
|
||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
results.addAll(list)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,16 +267,6 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
if (_binding == null) return@launch
|
||||
|
||||
val raw = bannerMsg.get()
|
||||
when {
|
||||
raw == null -> (activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
raw == "IO" -> (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
raw.startsWith("SERVER:") -> (activity as? HomeActivity)?.showConnectivityBanner(
|
||||
getString(R.string.connectivity_server_error, raw.removePrefix("SERVER:"))
|
||||
)
|
||||
}
|
||||
|
||||
if (!firstBatchDone) {
|
||||
firstBatchDone = true
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Stores and loads profile images locally for BML and Fahipay accounts.
|
||||
*
|
||||
* Key conventions:
|
||||
* BML profile: "bml_${profileId}"
|
||||
* Fahipay login: "fahipay_${loginId}"
|
||||
*/
|
||||
object ProfileImageStore {
|
||||
|
||||
private fun dir(context: Context): File =
|
||||
File(context.filesDir, "profile_images").also { it.mkdirs() }
|
||||
|
||||
private fun file(context: Context, key: String): File =
|
||||
File(dir(context), "${key.replace(Regex("[^A-Za-z0-9_\\-]"), "_")}.jpg")
|
||||
|
||||
fun save(context: Context, key: String, bitmap: Bitmap) {
|
||||
try {
|
||||
file(context, key).outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun load(context: Context, key: String): Bitmap? = try {
|
||||
val f = file(context, key)
|
||||
if (f.exists()) BitmapFactory.decodeFile(f.absolutePath) else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
fun delete(context: Context, key: String) {
|
||||
try { file(context, key).delete() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun exists(context: Context, key: String): Boolean =
|
||||
try { file(context, key).exists() } catch (_: Exception) { false }
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
try { dir(context).listFiles()?.forEach { it.delete() } } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/** Key for a BML profile given its profileId. */
|
||||
fun bmlKey(profileId: String) = "bml_$profileId"
|
||||
|
||||
/** Key for a Fahipay login given its loginId. */
|
||||
fun fahipayKey(loginId: String) = "fahipay_$loginId"
|
||||
|
||||
/** Derives the loginId from a loginTag (e.g. "fahipay_abc" → "abc"). */
|
||||
fun loginIdFromTag(loginTag: String): String =
|
||||
loginTag.substringAfter('_', loginTag)
|
||||
}
|
||||
@@ -1,19 +1,9 @@
|
||||
package sh.sar.basedbank.util.bmlapi
|
||||
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
object BmlCardParser {
|
||||
|
||||
/** Returns the drawable res for the card network logo, or null for non-card/unknown. */
|
||||
fun cardNetworkIcon(account: BankAccount): Int? = when {
|
||||
account.productCode.startsWith("C1") -> R.drawable.visa
|
||||
account.productCode.startsWith("C3") -> R.drawable.americanexpress
|
||||
account.productCode == "C8905" || account.productCode == "C8995" -> R.drawable.visa
|
||||
account.productCode.startsWith("C8") -> R.drawable.mastercard
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the asset path for the card image.
|
||||
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="250"
|
||||
android:propertyName="trimPathEnd"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="1"
|
||||
android:valueType="floatType"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate" />
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="250"
|
||||
android:propertyName="trimPathEnd"
|
||||
android:valueFrom="1"
|
||||
android:valueTo="0"
|
||||
android:valueType="floatType"
|
||||
android:interpolator="@android:interpolator/accelerate_decelerate" />
|
||||
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,15.2c1.767,0 3.2,-1.433 3.2,-3.2s-1.433,-3.2 -3.2,-3.2 -3.2,1.433 -3.2,3.2 1.433,3.2 3.2,3.2zM9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z"/>
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/black"
|
||||
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
|
||||
</vector>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- base for "turning off" animation: line starts invisible -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/black"
|
||||
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
|
||||
<path
|
||||
android:name="line"
|
||||
android:strokeColor="@android:color/black"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M2,4 L22,20"
|
||||
android:trimPathEnd="0" />
|
||||
</vector>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- base for "turning on" animation: line starts fully visible -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/black"
|
||||
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
|
||||
<path
|
||||
android:name="line"
|
||||
android:strokeColor="@android:color/black"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M2,4 L22,20"
|
||||
android:trimPathEnd="1" />
|
||||
</vector>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- bolt with line through it (flash_off) -->
|
||||
<path
|
||||
android:fillColor="@android:color/black"
|
||||
android:pathData="M3.27,3L2,4.27l5,5V13h3v9l3.58,-6.14L17.73,20 19,18.73 3.27,3zM17,10h-4l4,-8H7v2.18l8.46,8.46L17,10z" />
|
||||
</vector>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/ic_flashlight_anim_off">
|
||||
<target
|
||||
android:name="line"
|
||||
android:animation="@animator/flashlight_line_appear" />
|
||||
</animated-vector>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/ic_flashlight_anim_on">
|
||||
<target
|
||||
android:name="line"
|
||||
android:animation="@animator/flashlight_line_disappear" />
|
||||
</animated-vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/black"
|
||||
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01 3.5,-4.51 4.5,6H5l3.5,-4.5z" />
|
||||
</vector>
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black">
|
||||
@@ -12,49 +11,26 @@
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/btnContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="48dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="16dp">
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/zoomSlider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="1"
|
||||
android:value="0"
|
||||
app:labelBehavior="gone"
|
||||
app:thumbColor="@android:color/white"
|
||||
app:trackColorActive="@android:color/white"
|
||||
app:trackColorInactive="#80FFFFFF" />
|
||||
|
||||
<LinearLayout
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPickImage"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:orientation="horizontal">
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/qr_pick_image" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPickImage"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/qr_pick_image"
|
||||
app:icon="@drawable/ic_image" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnFlashlight"
|
||||
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:icon="@drawable/ic_flashlight_anim_on" />
|
||||
|
||||
</LinearLayout>
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCancel"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/back" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -6,29 +6,13 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<FrameLayout
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/accounts_empty"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Merchant info card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardMerchant"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="4dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorPrimary">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivMerchantIcon"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMerchantName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMerchantAddress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Loading placeholder shown while looking up merchant -->
|
||||
<TextView
|
||||
android:id="@+id/tvLookingUp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="@string/bml_qr_looking_up"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:visibility="visible" />
|
||||
|
||||
<!-- From label + account dropdown -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/transfer_label_from"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilFrom"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/transfer_from"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/actvFrom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="none"
|
||||
android:focusable="false"
|
||||
android:focusableInTouchMode="false" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Selected source account info card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardFromInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="4dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorPrimary">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivFromPhoto"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFromAccountName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFromAccountNumber"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFromBalance"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnClearFromInfo"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/transfer_clear_recipient" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Amount -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAmount"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/transfer_amount"
|
||||
app:prefixText="MVR "
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAmount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/transfer"
|
||||
android:enabled="false" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -55,7 +55,6 @@
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
|
||||
<!-- Amount (optional) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAmount"
|
||||
|
||||
@@ -101,15 +101,6 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFromBalance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnClearFromInfo"
|
||||
android:layout_width="40dp"
|
||||
@@ -227,29 +218,10 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToAccountDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToBalance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnClearToInfo"
|
||||
android:layout_width="40dp"
|
||||
|
||||
@@ -14,16 +14,6 @@
|
||||
android:gravity="center_vertical"
|
||||
android:foreground="?attr/selectableItemBackground">
|
||||
|
||||
<!-- Bank / profile logo -->
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivBankLogo"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_marginEnd="12dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto" />
|
||||
|
||||
<!-- Left: name / number -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
|
||||
@@ -3,63 +3,37 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivDropdownCardLogo"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:visibility="gone"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto" />
|
||||
<TextView
|
||||
android:id="@+id/tvDropdownAccountName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDropdownAccountName"
|
||||
android:layout_width="match_parent"
|
||||
android:id="@+id/tvDropdownAccountNumber"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDropdownAccountNumber"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDropdownBalance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
</LinearLayout>
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDropdownAccountType"
|
||||
android:layout_width="match_parent"
|
||||
android:id="@+id/tvDropdownBalance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -47,13 +47,4 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBalance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
<string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string>
|
||||
<string name="nav_close_drawer">ނެވިގޭޝަން ލައްޕާ</string>
|
||||
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
|
||||
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
|
||||
|
||||
@@ -189,13 +189,6 @@
|
||||
<string name="login_detail_customer_id">Customer ID</string>
|
||||
<string name="login_detail_id_card">ID Card</string>
|
||||
<string name="login_detail_profiles">Profiles</string>
|
||||
<string name="profile_image_title">Profile photo</string>
|
||||
<string name="profile_image_select">Select image</string>
|
||||
<string name="profile_image_camera">Take from camera</string>
|
||||
<string name="profile_image_remove">Remove</string>
|
||||
<string name="profile_image_uploading">Uploading image…</string>
|
||||
<string name="profile_image_upload_failed">Failed to upload image</string>
|
||||
<string name="profile_image_deleting">Removing image…</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
@@ -245,15 +238,6 @@
|
||||
<string name="transfer_send_otp_via">Send verification code via</string>
|
||||
<string name="transfer_otp_code_hint">Verification code</string>
|
||||
|
||||
<!-- BML QR Pay -->
|
||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||
|
||||
<!-- Accounts -->
|
||||
<string name="accounts_empty">No accounts found</string>
|
||||
|
||||
<!-- Contacts -->
|
||||
<string name="contacts_empty">No contacts found</string>
|
||||
<string name="contacts_search_hint">Search contacts</string>
|
||||
@@ -314,7 +298,6 @@
|
||||
<string name="nav_pay_with_card">Pay with Card</string>
|
||||
<string name="card_pay_qr">QR Pay</string>
|
||||
<string name="card_pay_nfc">NFC Pay</string>
|
||||
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
|
||||
<string name="card_action_change_pin">Change PIN</string>
|
||||
<string name="card_action_freeze">Freeze</string>
|
||||
<string name="card_action_block">Block</string>
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
<paths>
|
||||
<cache-path name="receipt_cache" path="receipts/" />
|
||||
<cache-path name="qr_cache" path="qr/" />
|
||||
<cache-path name="profile_photo_tmp" path="." />
|
||||
</paths>
|
||||
|
||||
+12
-28
@@ -183,35 +183,19 @@ The `product_code` field identifies the specific card product. Known mappings:
|
||||
|
||||
| `product_code` | Card name | Asset |
|
||||
|---|---|---|
|
||||
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
||||
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
||||
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
|
||||
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
||||
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
|
||||
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
||||
| `C8201` | Mastercard Prepaid | `master_prepaid` |
|
||||
| `C8205` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
||||
| `C8005` | Mastercard Prepaid Travel (alt) | `master_prepaid_travel` |
|
||||
| `C3007` | Amex Debit Green | `amex_debit_green` |
|
||||
| `C1007` | Visa Debit | `visa_debit` |
|
||||
| `C1003` | Visa Gold | `visa_gold` |
|
||||
| `C8022` | Mastercard Gold | `master_gold` |
|
||||
| `C1020` | Visa Debit Platinum | `visa_debit_platinum` |
|
||||
| `C8902` | Mastercard Islamic | `master_islamic` |
|
||||
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
||||
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
||||
| `C8901`, `C8991`, `C8980`, `C8981` | Mastercard Passport | `master_passport` |
|
||||
| `C8902`, `C8907`, `C8909`, `C8912`, `C8992`, `C8996`, `C8997`, `C8982`, `C8983` | Mastercard Islamic | `master_islamic` |
|
||||
| `C8905`, `C8995` | Visa Credit | `visa_credit` |
|
||||
| `C8925`, `C8926` | Visa Student Blue | `visa_student_blue` |
|
||||
| `C3001`, `C3011`, `C3050`, `C3051`, `C3031` | Amex Credit Green | `amex_credit_green` |
|
||||
| `C3003`, `C3013`, `C3053`, `C3023`, `C3033`, `C3052` | Amex Debit Gold | `amex_debit_gold` |
|
||||
| `C3005`, `C3015`, `C3055`, `C3054` | Amex Platinum | `amex_platinum` |
|
||||
| `C3007`, `C3017`, `C3097`, `C3095`, `C3077`, `C3177` | Amex Debit Green | `amex_debit_green` |
|
||||
| `C3009`, `C3019`, `C3029`, `C3099`, `C3088`, `C3188` | Amex Credit Gold | `amex_credit_gold` |
|
||||
| `C1001`, `C1011`, `C1082`, `C1081`, `C1101`, `C1111`, `C1181`, `C1182` | Visa Debit Generic | `visa_debit_generic` |
|
||||
| `C1003`, `C1013`, `C1083`, `C1084`, `C1103`, `C1113`, `C1183`, `C1184` | Visa Gold | `visa_gold` |
|
||||
| `C1005`, `C1006`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
|
||||
| `C1007`, `C1027`, `C1097`, `C1107`, `C1197`, `C1077`, `C1177` | Visa Debit | `visa_debit` |
|
||||
| `C1009`, `C1019`, `C1085`, `C1086`, `C1109`, `C1119`, `C1185`, `C1186` | Visa Platinum | `visa_platinum` |
|
||||
| `C1017` | Visa Infinite | `visa_infinite` |
|
||||
| `C1020`, `C1021` | Visa Debit Platinum | `visa_debit_platinum` |
|
||||
| `C1030`, `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
|
||||
| `C1040`, `C1041`, `C1047`, `C1048`, `C1050`, `C1051`, `C1087`, `C1088`, `C1140`, `C1141`, `C1147`, `C1148`, `C1150`, `C1151`, `C1187`, `C1188` | Visa Student Black | `visa_student_black` |
|
||||
| `C1059`, `C1062`, `C1070`, `C1072`, `C1159`, `C1162` | Mastercard Prepaid Business | `master_prepaid_business` |
|
||||
| `C1061`, `C1063`, `C1071`, `C1073`, `C1161`, `C1163` | Mastercard | `master` |
|
||||
| _(any other)_ | — | default card image |
|
||||
| _(unknown)_ | Any other code | default card image |
|
||||
|
||||
Additional product codes exist in the wild (Amex credit/gold/platinum, Mastercard Business, Odiveriyaa, Passport, World, Visa corporate/infinite/student etc.) but their codes have not been mapped yet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Reference in New Issue
Block a user