Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97d0c5a18
|
||
|
|
4ac328cf52
|
||
|
|
c2f473a6a3
|
||
|
|
9f40c56b49
|
||
|
|
0747fbdbfd
|
||
|
|
528e9eeef8
|
||
|
|
0e7f329a4b
|
||
|
|
2082e8fd0c
|
||
|
|
97a0cb218f
|
||
|
|
0158d6dcd8
|
||
|
|
320eaa2ffb
|
||
|
|
1886113ae7
|
||
|
|
41e7bc70e9
|
||
|
|
d786609bd1
|
||
|
|
2246e5929f
|
||
|
|
d0eee817ec
|
||
|
|
af414914c7
|
||
|
|
ec5b791a45
|
||
|
|
dd4aed0f94
|
||
|
|
fd4cdfecac
|
||
|
|
92f5af76e6
|
||
|
|
bc81255b31
|
||
|
|
acd11ef3eb
|
||
|
|
fc778f2a90
|
||
|
|
778bcc4d75
|
||
|
|
97c8033014
|
||
|
|
58d43f33d6
|
||
|
|
af791fc5ad
|
||
|
|
d8ef3a63c6
|
||
|
|
fbbfdd8537
|
||
|
|
169c3c144c
|
||
|
|
71ef7a5b55
|
||
|
|
32563ea398
|
||
|
|
41ce8a65ad
|
||
|
|
c7d3efa64e
|
||
|
|
2688118f52
|
||
|
|
e717360d00
|
||
|
|
2f26599931
|
||
|
|
f8f9d82440
|
||
|
|
a37354fc82
|
||
|
|
66568a464a
|
||
|
|
5210e6207f
|
||
|
|
369c6e4a37
|
||
|
|
fbedc631c5
|
||
|
|
7d6c192a63
|
||
|
|
e7f7590654
|
||
|
|
a048be939c
|
||
|
|
102a986201
|
||
|
|
440db13a0f
|
||
|
|
0dc81fef62
|
||
|
|
7e52b510a1
|
||
|
|
0d7833af5c
|
||
|
|
9e45e36c0a
|
||
|
|
c883b6b521
|
||
|
|
5dc37aadf0
|
||
|
|
1e3cf3420b
|
||
|
|
d98dcfd407
|
||
|
|
3b55fa30a8
|
||
|
|
7f0f2707a1
|
||
|
|
117733b766
|
||
|
|
d769df6f6b
|
||
|
|
caf0db60da
|
||
|
|
b48022d249
|
||
|
|
7574a5e8b0
|
||
|
|
992c8d4364
|
||
|
|
80fd238195
|
||
|
|
0357d7e0bc
|
||
|
|
71dd654edc
|
||
|
|
a75d8e420a
|
||
|
|
ca6ecc4283
|
||
|
|
f59c2be6c4
|
||
|
|
eb019a20a0
|
||
|
|
c356762baa
|
||
|
|
255f43db24
|
||
|
|
01cae559cf
|
||
|
|
015919a4ac
|
||
|
|
93a7c8bbde
|
||
|
|
8f4672f269
|
||
|
|
00e6b40ee0
|
@@ -27,24 +27,21 @@ jobs:
|
|||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
|
|
||||||
- name: Extract release notes
|
- name: Extract release notes
|
||||||
run: |
|
run: |
|
||||||
echo "No release notes" > release_notes.md
|
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
# VERSION="${{ gitea.ref_name }}"
|
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||||
# VERSION="${VERSION#v}"
|
|
||||||
#
|
if [ -f "$CHANGELOG" ]; then
|
||||||
# awk -v ver="$VERSION" '
|
cp "$CHANGELOG" release_notes.md
|
||||||
# BEGIN { found=0 }
|
else
|
||||||
# /^## \[/ {
|
echo "No changelog found at $CHANGELOG"
|
||||||
# if (found) exit
|
echo "No release notes" > release_notes.md
|
||||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
fi
|
||||||
# }
|
|
||||||
# found { print }
|
|
||||||
# ' CHANGELOG.md > release_notes.md
|
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
env:
|
env:
|
||||||
@@ -53,7 +50,7 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
TITLE="${APP_NAME} ${TAG}"
|
TITLE="${APP_NAME} ${TAG}"
|
||||||
NOTES_FILE="release_notes.md"
|
NOTES_FILE="release_notes.md"
|
||||||
@@ -101,12 +98,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
CAPTION="${APP_NAME} ${TAG}"
|
CAPTION="$(printf '%s\n\n%s' "${APP_NAME} ${TAG}" "$(cat release_notes.md)" | head -c 1024)"
|
||||||
|
|
||||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||||
-F "chat_id=${TG_CHAT_ID}" \
|
-F "chat_id=${TG_CHAT_ID}" \
|
||||||
-F "document=@${ASSET_PATH}" \
|
-F "document=@${ASSET_PATH}" \
|
||||||
-F "caption=${CAPTION}"
|
--form-string "caption=${CAPTION}"
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
|||||||
|
|
||||||
- Android 8.0+ (API 26)
|
- Android 8.0+ (API 26)
|
||||||
- Existing accounts with MIB, BML, or Fahipay
|
- Existing accounts with MIB, BML, or Fahipay
|
||||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank. See [how to get your TOTP seed](docs/thijooree/faq/totpseed/README.md)
|
||||||
|
|
||||||
## Download APK
|
## Download APK
|
||||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md).
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 22
|
versionCode = 33
|
||||||
versionName = "1.0.21"
|
versionName = "1.0.32"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name">
|
android:label="@string/app_name">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -45,20 +46,24 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LockActivity"
|
android:name=".LockActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.onboarding.OnboardingActivity"
|
android:name=".ui.onboarding.OnboardingActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.login.LoginActivity"
|
android:name=".ui.login.LoginActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustPan" />
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".nfc.BmlTapToPayActivity"
|
android:name=".nfc.BmlTapToPayActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/Theme.BasedBank" />
|
android:theme="@style/Theme.BasedBank" />
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
|
||||||
|
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
|
||||||
|
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
|
||||||
|
*/
|
||||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||||
Request.Builder().url(url)
|
Request.Builder().url(url)
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
.header("accept", "application/json")
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("user-agent", BML_USER_AGENT)
|
||||||
|
.header("authorization", "Bearer ${session.accessToken}")
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
|
||||||
|
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it
|
||||||
|
* can go through the regular BML QR payment flow.
|
||||||
|
*/
|
||||||
|
class BmlMerchantTxnClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the transaction's EMV QR payload (`vendorQrCode`).
|
||||||
|
*
|
||||||
|
* A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the
|
||||||
|
* page itself sends need no auth and return the full transaction:
|
||||||
|
* - on load, `activeBrowserId` (`<id>_<epoch millis>`);
|
||||||
|
* - on picking "BML" as the payment method, `provider: bml_mpos`.
|
||||||
|
*
|
||||||
|
* A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects
|
||||||
|
* one. Links already opened with BML chosen return it from the first.
|
||||||
|
*/
|
||||||
|
fun fetchQrPayload(transactionId: String): String {
|
||||||
|
val browserId = JSONObject()
|
||||||
|
.put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")
|
||||||
|
patch(transactionId, browserId).vendorQrCode()?.let { return it }
|
||||||
|
// Whether the provider PATCH returns the QR itself or it is generated a moment later has
|
||||||
|
// not been observed, so re-read a few times before giving up. Re-reads use the load PATCH:
|
||||||
|
// each provider PATCH counts as another payment attempt.
|
||||||
|
var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML))
|
||||||
|
repeat(3) {
|
||||||
|
txn.vendorQrCode()?.let { return it }
|
||||||
|
Thread.sleep(1000)
|
||||||
|
txn = patch(transactionId, browserId)
|
||||||
|
}
|
||||||
|
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun patch(transactionId: String, body: JSONObject): JSONObject {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$API_BASE/transactions/$transactionId")
|
||||||
|
.patch(body.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Origin", PAGE_ORIGIN)
|
||||||
|
.header("Referer", "$PAGE_ORIGIN/")
|
||||||
|
.build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
if (!response.isSuccessful || !text.trimStart().startsWith("{"))
|
||||||
|
throw Exception("Transaction lookup failed (HTTP ${response.code})")
|
||||||
|
JSONObject(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup
|
||||||
|
* already rejects a paid or expired QR with its own message. `isNull` first — `optString`
|
||||||
|
* turns a JSON null into the string "null".
|
||||||
|
*/
|
||||||
|
private fun JSONObject.vendorQrCode(): String? =
|
||||||
|
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
|
||||||
|
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
|
||||||
|
private const val PROVIDER_BML = "bml_mpos"
|
||||||
|
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
|
||||||
|
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
|
||||||
|
|
||||||
|
/** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */
|
||||||
|
fun parseTransactionId(input: String): String? {
|
||||||
|
val s = input.trim()
|
||||||
|
val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1)
|
||||||
|
return id?.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
|||||||
val currency: String
|
val currency: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
|
||||||
|
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
|
||||||
|
* parse failures stay plain exceptions so the generic message is used for those instead.
|
||||||
|
*/
|
||||||
|
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
|
||||||
|
|
||||||
data class BmlQrPayResult(
|
data class BmlQrPayResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val merchant: String = "",
|
val merchant: String = "",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -10,17 +11,27 @@ class BmlQrPayClient {
|
|||||||
private val client = newBmlApiClient()
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a BML QR URL to merchant details.
|
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
* `35` → `20` → `01` reference.
|
||||||
|
*
|
||||||
|
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
|
||||||
|
* resolves too, so this is parity rather than a requirement.
|
||||||
*/
|
*/
|
||||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
|
||||||
|
val key = Base64.encodeToString(
|
||||||
|
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||||
val request = bmlApiRequest(session,
|
val request = bmlApiRequest(session,
|
||||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
val body = response.body?.string() ?: throw Exception("No response")
|
val body = response.body?.string() ?: throw Exception("No response")
|
||||||
|
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
|
||||||
|
if (!body.trimStart().startsWith("{"))
|
||||||
|
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
|
||||||
val json = JSONObject(body)
|
val json = JSONObject(body)
|
||||||
if (!json.optBoolean("success"))
|
if (!json.optBoolean("success"))
|
||||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
throw BmlQrPayLookupException(
|
||||||
|
json.optInt("code"),
|
||||||
|
json.optString("message").ifBlank { "Lookup failed" })
|
||||||
val payload = json.getJSONObject("payload")
|
val payload = json.getJSONObject("payload")
|
||||||
val addr2 = payload.optString("narrative2").trim()
|
val addr2 = payload.optString("narrative2").trim()
|
||||||
val addr3 = payload.optString("narrative3").trim()
|
val addr3 = payload.optString("narrative3").trim()
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */
|
||||||
|
fun updateOtpSeed(otpSeed: String) {
|
||||||
|
if (storedOtpSeed != null) storedOtpSeed = otpSeed
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Public entry point ───────────────────────────────────────────────────
|
// ─── Public entry point ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun setupAccountSearch() {
|
private fun setupAccountSearch() {
|
||||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||||
|
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||||
|
performLookup()
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performLookup() {
|
private fun performLookup() {
|
||||||
@@ -387,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
// Auto-fill alias with existing alias or name
|
// Auto-fill alias with existing alias or name
|
||||||
if (binding.etAlias.text.isNullOrBlank()) {
|
if (binding.etAlias.text.isNullOrBlank()) {
|
||||||
binding.etAlias.setText(validation.name)
|
binding.etAlias.setText(sh.sar.basedbank.util.bmlapi.BmlDashboardParser.toTitleCase(validation.name))
|
||||||
}
|
}
|
||||||
binding.etCurrency.setText(validation.currency)
|
binding.etCurrency.setText(validation.currency)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupMerchant(qrUrl: String) {
|
private fun lookupMerchant(qrUrl: String) {
|
||||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val session = app.anyBmlSession() ?: run {
|
val session = app.anyBmlSession() ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||||
catch (_: Exception) { null }
|
catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (_binding == null) return@launch
|
if (_binding == null) return@launch
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.SheetContactDetailsBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
|
||||||
|
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: SheetContactDetailsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private val contactsFragment get() = parentFragment as? ContactsFragment
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val args = requireArguments()
|
||||||
|
val contactId = args.getString(ARG_CONTACT_ID)
|
||||||
|
val profileId = args.getString(ARG_PROFILE_ID)
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId }
|
||||||
|
?.let { ContactListParser.from(it) }
|
||||||
|
if (contact == null) dismissAllowingStateLoss() else bind(contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bind(contact: ContactDisplay) {
|
||||||
|
val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2
|
||||||
|
val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||||
|
binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx))
|
||||||
|
|
||||||
|
binding.tvName.text = contact.name
|
||||||
|
|
||||||
|
binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE
|
||||||
|
// QR stays visible for every contact; greyed out where PayMV QR isn't supported yet
|
||||||
|
val canQr = canShowPayMvQr(contact)
|
||||||
|
binding.btnQr.isEnabled = canQr
|
||||||
|
binding.tvQrLabel.alpha = if (canQr) 1f else 0.38f
|
||||||
|
binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE
|
||||||
|
binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.btnTransfer.setOnClickListener {
|
||||||
|
val parent = contactsFragment
|
||||||
|
dismiss()
|
||||||
|
parent?.openTransfer(contact)
|
||||||
|
}
|
||||||
|
binding.btnQr.setOnClickListener {
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
val bank = contactBank(contact) ?: return@setOnClickListener
|
||||||
|
val qrName = contact.realName.ifBlank { contact.name }
|
||||||
|
dismiss()
|
||||||
|
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
|
||||||
|
}
|
||||||
|
binding.btnEdit.setOnClickListener {
|
||||||
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
binding.btnDelete.setOnClickListener {
|
||||||
|
val parent = contactsFragment
|
||||||
|
dismiss()
|
||||||
|
parent?.confirmDelete(contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvAccount.text = contact.accountNumber
|
||||||
|
val copy = View.OnClickListener { copyAccount(contact.accountNumber) }
|
||||||
|
binding.rowAccount.setOnClickListener(copy)
|
||||||
|
binding.btnCopyAccount.setOnClickListener(copy)
|
||||||
|
binding.btnShareAccount.setOnClickListener {
|
||||||
|
shareAccount(contact.realName.ifBlank { contact.name }, contact.accountNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name
|
||||||
|
binding.tvRealName.text = contact.realName
|
||||||
|
binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.tvCurrency.text = contact.currency ?: ""
|
||||||
|
binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.tvBank.text = contact.bankName ?: ""
|
||||||
|
binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE
|
||||||
|
val bankLogo = bankLogoRes(contact)
|
||||||
|
if (bankLogo != null) {
|
||||||
|
binding.ivBankIcon.setImageResource(bankLogo)
|
||||||
|
binding.ivBankIcon.imageTintList = null
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.sourceSection.visibility = View.GONE
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val appContext = requireContext().applicationContext
|
||||||
|
val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) }
|
||||||
|
val b = _binding ?: return@launch
|
||||||
|
if (source == null) return@launch
|
||||||
|
b.ivSourceIcon.setImageResource(source.logoRes)
|
||||||
|
b.tvSource.text = source.name
|
||||||
|
b.tvSourceBank.setText(source.bankNameRes)
|
||||||
|
b.sourceSection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which bank the contact's account is at ("BML" / "MIB"); null when we can't tell. */
|
||||||
|
private fun contactBank(contact: ContactDisplay): String? = when {
|
||||||
|
contact.network == TransferNetwork.MIB -> "MIB"
|
||||||
|
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> "BML"
|
||||||
|
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> "MIB"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
|
||||||
|
private fun bankLogoRes(contact: ContactDisplay): Int? = when (contactBank(contact)) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PayMV QR is only offered for contacts whose account is at BML for now.
|
||||||
|
* TODO: enable for MIB and other banks once their PayMV QR payloads are verified —
|
||||||
|
* PayMvQrFragment.forContact already takes the bank, so this check is the only gate.
|
||||||
|
*/
|
||||||
|
private fun canShowPayMvQr(contact: ContactDisplay): Boolean =
|
||||||
|
contactBank(contact) == "BML" &&
|
||||||
|
contact.currency?.contains("USD", ignoreCase = true) != true // PayMV QR is MVR only
|
||||||
|
|
||||||
|
private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String)
|
||||||
|
|
||||||
|
/** Which of the user's own bank profiles this contact is saved under. */
|
||||||
|
private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? {
|
||||||
|
val store = CredentialStore(context)
|
||||||
|
return when (contact.network) {
|
||||||
|
TransferNetwork.BML -> {
|
||||||
|
// BML contacts carry either the loginId or a BML profileId
|
||||||
|
val loginId = store.getBmlLoginIds().firstOrNull { loginId ->
|
||||||
|
loginId == contact.profileId ||
|
||||||
|
store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId }
|
||||||
|
} ?: return null
|
||||||
|
val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||||
|
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name)
|
||||||
|
?: return null
|
||||||
|
SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name)
|
||||||
|
}
|
||||||
|
TransferNetwork.FAHIPAY -> {
|
||||||
|
val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId ->
|
||||||
|
store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
} ?: return null
|
||||||
|
SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId ->
|
||||||
|
store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||||
|
} ?: return null
|
||||||
|
SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareAccount(accountName: String, account: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, "$accountName\n$account")
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(intent, getString(R.string.contact_share_account)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAccount(account: String) {
|
||||||
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||||
|
Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_CONTACT_ID = "contact_id"
|
||||||
|
private const val ARG_PROFILE_ID = "profile_id"
|
||||||
|
|
||||||
|
fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply {
|
||||||
|
arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||||
import sh.sar.basedbank.util.ContactDisplay
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
class ContactsAdapter(
|
class ContactsAdapter(
|
||||||
private val imageCache: MutableMap<String, Bitmap>,
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onImageNeeded: (hash: String) -> Unit,
|
private val onImageNeeded: (hash: String) -> Unit,
|
||||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
private val onContactClick: (ContactDisplay) -> Unit,
|
||||||
private val onTransferClick: (ContactDisplay) -> Unit
|
private val onTransferClick: (ContactDisplay) -> Unit
|
||||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
@@ -66,12 +65,9 @@ class ContactsAdapter(
|
|||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.btnEditContact.setOnClickListener {
|
binding.root.setOnClickListener {
|
||||||
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
binding.btnDeleteContact.setOnClickListener {
|
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
@@ -107,37 +103,33 @@ class ContactsAdapter(
|
|||||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnTransferContact.visibility =
|
binding.btnTransferContact.visibility =
|
||||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnEditContact.visibility =
|
|
||||||
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
|
||||||
binding.btnDeleteContact.visibility =
|
|
||||||
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
|
||||||
|
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
binding.ivContactPhoto.setImageBitmap(photo)
|
binding.ivContactPhoto.setImageBitmap(photo)
|
||||||
} else {
|
} else {
|
||||||
|
val sizePx = binding.ivContactPhoto.context.resources
|
||||||
|
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||||
|
.coerceAtLeast(96)
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
binding.ivContactPhoto.setImageBitmap(
|
||||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
|
||||||
val sizePx = binding.ivContactPhoto.context.resources
|
|
||||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
|
||||||
.coerceAtLeast(96)
|
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
|
||||||
|
val bgColor = try { Color.parseColor(colorHex) } catch (e: 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import android.text.style.StyleSpan
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
|
|||||||
ContactsAdapter(
|
ContactsAdapter(
|
||||||
imageCache = sharedImageCache,
|
imageCache = sharedImageCache,
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
onContactClick = { contact -> showDetails(contact) },
|
||||||
onTransferClick = { contact -> openTransfer(contact) }
|
onTransferClick = { contact -> openTransfer(contact) }
|
||||||
).also { a ->
|
).also { a ->
|
||||||
a.setFilter(page.categoryId, currentSearch)
|
a.setFilter(page.categoryId, currentSearch)
|
||||||
@@ -170,7 +177,12 @@ class ContactsFragment : Fragment() {
|
|||||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openTransfer(contact: ContactDisplay) {
|
private fun showDetails(contact: ContactDisplay) {
|
||||||
|
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||||
|
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openTransfer(contact: ContactDisplay) {
|
||||||
val fragment = TransferFragment.newInstance(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.accountNumber,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.name,
|
displayName = contact.name,
|
||||||
@@ -181,10 +193,26 @@ class ContactsFragment : Fragment() {
|
|||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: ContactDisplay) {
|
internal fun confirmDelete(contact: ContactDisplay) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
val ctx = requireContext()
|
||||||
|
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
||||||
|
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||||
|
|
||||||
|
val prompt = getString(R.string.contact_delete_message, contact.name)
|
||||||
|
val message = SpannableStringBuilder(prompt).apply {
|
||||||
|
val start = prompt.indexOf(contact.name)
|
||||||
|
if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
append("\n").append(contact.accountNumber)
|
||||||
|
append("\n\n")
|
||||||
|
val warnStart = length
|
||||||
|
append(getString(R.string.contact_delete_warning))
|
||||||
|
setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog)
|
||||||
|
.setIcon(icon)
|
||||||
.setTitle(R.string.contact_delete_title)
|
.setTitle(R.string.contact_delete_title)
|
||||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
.setMessage(message)
|
||||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
|
|||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||||
.map { CardItem.Bml(it) }
|
.map { CardItem.Bml(it) }
|
||||||
val all = bmlItems + mibItems
|
val rank = credStore.loginRank()
|
||||||
|
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
|
||||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
val ordered = if (defaultNum != null) {
|
val ordered = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
|||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
|
||||||
expandedPositions.clear()
|
expandedPositions.clear()
|
||||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
val mibItems = mibDeals.map { Item.Mib(it) }
|
||||||
|
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||||
|
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
|
|||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
class FinancingFragment : Fragment() {
|
class FinancingFragment : Fragment() {
|
||||||
|
|
||||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
|||||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||||
|
|
||||||
adapter.update(latestMibDeals, bmlLoans)
|
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
|
||||||
|
val order = CredentialStore(requireContext()).getLoginOrder()
|
||||||
|
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
|
||||||
|
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
|
||||||
|
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
|
||||||
|
|
||||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
if (securitySet) lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Payment guard ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The manifest has this activity handle theme, language, font-size and display-size changes
|
||||||
|
// itself, because recreating it mid-payment tears down the screen waiting on the bank's
|
||||||
|
// answer — the money can move with nothing left to say so. Those changes still need a
|
||||||
|
// recreate to re-inflate with the new resources, so it runs straight away when nothing is in
|
||||||
|
// flight and otherwise waits until the last payment finishes.
|
||||||
|
|
||||||
|
private var paymentsInFlight = 0
|
||||||
|
private var recreatePending = false
|
||||||
|
private var lastConfig: Configuration? = null
|
||||||
|
|
||||||
|
/** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */
|
||||||
|
inner class PaymentGuard internal constructor() {
|
||||||
|
private var ended = false
|
||||||
|
fun end() {
|
||||||
|
if (ended) return
|
||||||
|
ended = true
|
||||||
|
paymentsInFlight--
|
||||||
|
if (paymentsInFlight == 0 && recreatePending) {
|
||||||
|
recreatePending = false
|
||||||
|
// Posted so a receipt screen committed in the same pass is saved with the state
|
||||||
|
binding.root.post { recreate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */
|
||||||
|
fun beginPayment(owner: LifecycleOwner): PaymentGuard {
|
||||||
|
paymentsInFlight++
|
||||||
|
val guard = PaymentGuard()
|
||||||
|
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) = guard.end()
|
||||||
|
})
|
||||||
|
return guard
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
val previous = lastConfig
|
||||||
|
lastConfig = Configuration(newConfig)
|
||||||
|
// Size and orientation changes are handled in place; these need fresh resources.
|
||||||
|
val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_LOCALE or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_DENSITY
|
||||||
|
if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return
|
||||||
|
if (paymentsInFlight > 0) recreatePending = true else recreate()
|
||||||
|
}
|
||||||
|
|
||||||
fun lockApp() = lock()
|
fun lockApp() = lock()
|
||||||
|
|
||||||
fun notifyWheelLockTap() {
|
fun notifyWheelLockTap() {
|
||||||
@@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
}
|
}
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
lastConfig = Configuration(resources.configuration)
|
||||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The app bar only pads for the status bar. In landscape the navigation bar (and any
|
||||||
|
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
|
||||||
|
// draw underneath it, out of reach.
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
|
||||||
|
val sides = insets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||||
|
v.updatePadding(left = sides.left, right = sides.right)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||||
val frag = when (item.itemId) {
|
val frag = when (item.itemId) {
|
||||||
@@ -244,9 +308,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||||
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||||
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
val cachedCards = CardsCache.load(this)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
|
||||||
val cachedFinancing = FinancingCache.load(this)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||||
@@ -528,9 +592,13 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
private fun routeSharedQrText(text: String) {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let {
|
||||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it))
|
||||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
return
|
||||||
|
}
|
||||||
|
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||||
|
if (bmlTarget != null) {
|
||||||
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||||
@@ -774,47 +842,13 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||||
|
|
||||||
val bmlClient = BmlAccountClient()
|
// Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
|
||||||
|
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
|
||||||
for (profile in savedProfiles) {
|
for (profile in savedProfiles) {
|
||||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
if (profile.profileId in hiddenProfiles) continue
|
||||||
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
|
||||||
if (saved == null) {
|
|
||||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
|
||||||
.filter { it.profileId == profile.profileId }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
|
||||||
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
|
||||||
|
|
||||||
suspend fun fetchWithSession(session: BmlSession) {
|
|
||||||
bmlClient.checkProfile(session)
|
|
||||||
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
|
||||||
app.bmlSessions[profile.profileId] = session
|
|
||||||
allAccounts += accounts
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun tryRefresh() {
|
|
||||||
if (refreshToken == null) throw Exception("No refresh token")
|
|
||||||
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
|
||||||
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
|
||||||
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
|
||||||
if (newSession.refreshToken.isNotBlank())
|
|
||||||
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
|
||||||
if (newSession.expiresAt > 0)
|
|
||||||
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
|
||||||
fetchWithSession(newSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tokenKnownExpired) {
|
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
|
||||||
tryRefresh()
|
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
|
||||||
} catch (_: AuthExpiredException) {
|
|
||||||
tryRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
refreshErrors.add("NO_INTERNET")
|
refreshErrors.add("NO_INTERNET")
|
||||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
@@ -985,15 +1019,17 @@ fun applyNavLabelVisibility() {
|
|||||||
refreshBmlLoanDetails()
|
refreshBmlLoanDetails()
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshMibCards(loginId, session, profiles)
|
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filters accounts whose profileId the user has hidden in settings. */
|
/** Filters accounts whose profileId or account number the user has hidden in settings. */
|
||||||
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
||||||
val store = CredentialStore(this@HomeActivity)
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
|
||||||
return filter { acc ->
|
return filter { acc ->
|
||||||
|
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
|
||||||
when (acc.bank) {
|
when (acc.bank) {
|
||||||
"MIB" -> {
|
"MIB" -> {
|
||||||
val loginId = acc.loginTag.removePrefix("mib_")
|
val loginId = acc.loginTag.removePrefix("mib_")
|
||||||
@@ -1022,10 +1058,140 @@ fun applyNavLabelVisibility() {
|
|||||||
return filter { it.profileId !in hidden }
|
return filter { it.profileId !in hidden }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
/** Drops MIB cards belonging to profiles the user has hidden. */
|
||||||
fun applyProfileVisibility() {
|
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
|
||||||
val current = viewModel.accounts.value ?: return
|
val store = CredentialStore(this@HomeActivity)
|
||||||
viewModel.accounts.value = current.filterVisibleAccounts()
|
return filter { card ->
|
||||||
|
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
|
||||||
|
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
|
||||||
|
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
|
||||||
|
*/
|
||||||
|
fun applyVisibility() {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
|
||||||
|
viewModel.mibCards.value?.let { cards ->
|
||||||
|
val visible = cards.filterVisibleCards()
|
||||||
|
viewModel.mibCards.value = visible
|
||||||
|
CardsCache.save(this, visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
|
||||||
|
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
|
||||||
|
if (profileIds.isEmpty()) return
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val loginTag = "mib_$loginId"
|
||||||
|
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
|
||||||
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
|
val session = app.mibSessions[loginId]
|
||||||
|
if (session != null && enabled.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
|
||||||
|
if (accounts.isNotEmpty()) {
|
||||||
|
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
// Session expired — log this one login in again (it now includes the unhidden profiles)
|
||||||
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
|
||||||
|
try {
|
||||||
|
val flow = MibLoginFlow(store)
|
||||||
|
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||||
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
|
app.mibLoginFlows[loginId] = flow
|
||||||
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
|
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
if (fresh == null) return@launch
|
||||||
|
app.mibAccounts = fresh
|
||||||
|
AccountCache.save(this@HomeActivity, fresh)
|
||||||
|
applyVisibility()
|
||||||
|
val session = app.mibSessions[loginId] ?: return@launch
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
|
||||||
|
// Financing deals carry no profile id, so the login's visible profiles are fetched together
|
||||||
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
|
||||||
|
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
|
||||||
|
if (profileIds.isEmpty()) return
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val store = CredentialStore(this)
|
||||||
|
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
|
||||||
|
if (enabled.isEmpty()) return
|
||||||
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val fetched = withContext(Dispatchers.IO) {
|
||||||
|
enabled.mapNotNull { profile ->
|
||||||
|
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
if (fetched.isEmpty()) return@launch
|
||||||
|
val fetchedIds = fetched.map { it.first }.toSet()
|
||||||
|
val loginTag = "bml_$loginId"
|
||||||
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
|
||||||
|
fetched.flatMap { it.second }
|
||||||
|
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
|
||||||
|
applyVisibility()
|
||||||
|
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
|
||||||
|
refreshBmlLoanDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
|
||||||
|
* Returns null when the profile has no saved session; throws on network/server errors.
|
||||||
|
*/
|
||||||
|
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
|
||||||
|
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||||
|
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||||
|
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||||
|
val bmlClient = BmlAccountClient()
|
||||||
|
|
||||||
|
fun fetchWithSession(session: BmlSession): List<BankAccount> {
|
||||||
|
bmlClient.checkProfile(session)
|
||||||
|
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
|
||||||
|
app.bmlSessions[profile.profileId] = session
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryRefresh(): List<BankAccount> {
|
||||||
|
if (refreshToken == null) throw Exception("No refresh token")
|
||||||
|
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||||
|
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||||
|
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||||
|
if (newSession.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||||
|
if (newSession.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||||
|
return fetchWithSession(newSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (tokenKnownExpired) {
|
||||||
|
tryRefresh()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||||
|
} catch (_: AuthExpiredException) {
|
||||||
|
tryRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshBmlLimits(session: BmlSession) {
|
private fun refreshBmlLimits(session: BmlSession) {
|
||||||
@@ -1259,7 +1425,7 @@ fun applyNavLabelVisibility() {
|
|||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshMibCards(loginId, session, profiles)
|
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,7 +1450,8 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
if (cards.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
||||||
existing.removeAll { it.loginTag == "mib_$loginId" }
|
val fetchedIds = profiles.map { it.profileId }.toSet()
|
||||||
|
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
|
||||||
existing += cards
|
existing += cards
|
||||||
viewModel.mibCards.postValue(existing)
|
viewModel.mibCards.postValue(existing)
|
||||||
CardsCache.save(this@HomeActivity, existing)
|
CardsCache.save(this@HomeActivity, existing)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
|
|||||||
import sh.sar.basedbank.api.models.BankContactCategory
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
sealed class CardItem {
|
sealed class CardItem {
|
||||||
data class Mib(val card: MibCard) : CardItem()
|
abstract val loginTag: String
|
||||||
data class Bml(val account: BankAccount) : CardItem()
|
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
|
||||||
|
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
|
||||||
|
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
|
||||||
|
override fun setValue(value: List<BankAccount>?) {
|
||||||
|
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||||
/** BML loan details keyed by account internalId. */
|
/** BML loan details keyed by account internalId. */
|
||||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
|||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
|
||||||
|
override fun setValue(value: List<MibCard>?) {
|
||||||
|
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
|
||||||
|
|
||||||
|
/** Re-applies the login order after the user changes it. */
|
||||||
|
fun resortByLoginOrder() {
|
||||||
|
accounts.value = accounts.value
|
||||||
|
mibCards.value = mibCards.value
|
||||||
|
}
|
||||||
|
|
||||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
@@ -36,4 +56,7 @@ class HomeViewModel : ViewModel() {
|
|||||||
* for HTTP 5xx server errors from specific banks.
|
* for HTTP 5xx server errors from specific banks.
|
||||||
*/
|
*/
|
||||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
||||||
|
|
||||||
|
/** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */
|
||||||
|
var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PersistableBundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -18,13 +29,23 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
|
import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding
|
||||||
|
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.OtpauthParser
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class OtpFragment : Fragment() {
|
class OtpFragment : Fragment() {
|
||||||
@@ -32,7 +53,35 @@ class OtpFragment : Fragment() {
|
|||||||
private var _binding: FragmentOtpBinding? = null
|
private var _binding: FragmentOtpBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
private data class OtpEntry(val label: String, val seed: String)
|
private data class OtpEntry(
|
||||||
|
val bank: String, val loginId: String, val account: String, val name: String?, val seed: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private val entries = mutableListOf<OtpEntry>()
|
||||||
|
private var adapter: OtpAdapter? = null
|
||||||
|
|
||||||
|
/** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */
|
||||||
|
private var scanTarget: android.widget.EditText? = 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
|
||||||
|
val target = scanTarget ?: return@registerForActivityResult
|
||||||
|
val found = OtpauthParser.parse(raw)
|
||||||
|
when {
|
||||||
|
found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||||
|
found.size == 1 -> target.setText(found[0].secret)
|
||||||
|
else -> {
|
||||||
|
val labels = found.map { e ->
|
||||||
|
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||||
|
}.toTypedArray()
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("Choose account")
|
||||||
|
.setItems(labels) { _, i -> target.setText(found[i].secret) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||||
@@ -45,18 +94,19 @@ class OtpFragment : Fragment() {
|
|||||||
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.b.tvOtpLabel.text = entries[position].label
|
val entry = entries[position]
|
||||||
update(holder.b, entries[position].seed)
|
val b = holder.b
|
||||||
holder.b.root.setOnClickListener {
|
b.tvOtpBank.text = entry.bank
|
||||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
b.tvOtpLabel.text = entry.name ?: "Authenticator"
|
||||||
if (code.isNotEmpty()) {
|
b.ivBankLogo.setImageResource(
|
||||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
)
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
update(b, entry.seed)
|
||||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||||
}
|
// Long-press opens the seed menu (export / update)
|
||||||
}
|
b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true }
|
||||||
}
|
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||||
|
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tick() {
|
fun tick() {
|
||||||
@@ -71,9 +121,196 @@ class OtpFragment : Fragment() {
|
|||||||
val secondsInPeriod = (epochSeconds % 30).toInt()
|
val secondsInPeriod = (epochSeconds % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
||||||
|
val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" }
|
||||||
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
||||||
|
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
|
||||||
b.otpProgress.progress = remaining
|
b.otpProgress.progress = remaining
|
||||||
b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}"
|
b.tvOtpCountdown.text = remaining.toString()
|
||||||
|
|
||||||
|
// Turn the ring and code red in the last few seconds of the window
|
||||||
|
val expiring = remaining <= 5
|
||||||
|
val accent = MaterialColors.getColor(b.root,
|
||||||
|
if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary)
|
||||||
|
b.otpProgress.setIndicatorColor(accent)
|
||||||
|
b.tvOtpCode.setTextColor(accent)
|
||||||
|
b.tvOtpCountdown.setTextColor(
|
||||||
|
if (expiring) accent
|
||||||
|
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSeedMenu(anchor: View, position: Int) {
|
||||||
|
if (position == RecyclerView.NO_POSITION) return
|
||||||
|
val popup = PopupMenu(anchor.context, anchor)
|
||||||
|
popup.menu.add(0, 1, 0, "Export seed")
|
||||||
|
popup.menu.add(0, 2, 1, "Update seed")
|
||||||
|
popup.setOnMenuItemClickListener { item ->
|
||||||
|
val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false
|
||||||
|
when (item.itemId) {
|
||||||
|
1 -> showExportDialog(entry)
|
||||||
|
2 -> showUpdateDialog(position)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
popup.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}"
|
||||||
|
|
||||||
|
// ── Export ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun showExportDialog(entry: OtpEntry) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val d = DialogOtpExportSeedBinding.inflate(layoutInflater)
|
||||||
|
val uri = OtpauthParser.buildUri(entry.bank, entry.seed)
|
||||||
|
d.tvSeed.text = entry.seed.chunked(4).joinToString(" ")
|
||||||
|
renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) }
|
||||||
|
d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") }
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(entryTitle(entry))
|
||||||
|
.setView(d.root)
|
||||||
|
.setPositiveButton("Done", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderQr(content: String, size: Int): Bitmap? = try {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||||
|
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE }
|
||||||
|
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
/** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */
|
||||||
|
private fun copySensitive(context: Context, text: String, message: String) {
|
||||||
|
val clip = ClipData.newPlainText("OTP seed", text)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
clip.description.extras = PersistableBundle().apply {
|
||||||
|
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun showUpdateDialog(position: Int) {
|
||||||
|
val entry = entries.getOrNull(position) ?: return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater)
|
||||||
|
d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " +
|
||||||
|
"can't be recovered. If you might still need it, export it first."
|
||||||
|
|
||||||
|
var newSeed: String? = null
|
||||||
|
// Same behaviour as the sign-in screen's preview card
|
||||||
|
val preview = d.cardOtp
|
||||||
|
preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") }
|
||||||
|
fun refreshPreview() {
|
||||||
|
val seed = newSeed
|
||||||
|
try {
|
||||||
|
if (seed == null) throw IllegalArgumentException()
|
||||||
|
preview.tvOtpCode.text = Totp.generate(seed)
|
||||||
|
preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||||
|
preview.otpTimer.max = 30
|
||||||
|
preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||||
|
preview.root.visibility = View.VISIBLE
|
||||||
|
} catch (_: Exception) {
|
||||||
|
preview.root.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle("Update seed · ${entry.bank}")
|
||||||
|
.setView(d.root)
|
||||||
|
.setPositiveButton("Replace", null)
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
fun validate() {
|
||||||
|
val raw = d.etNewSeed.text?.toString().orEmpty()
|
||||||
|
newSeed = OtpauthParser.resolveSecret(raw)
|
||||||
|
d.tilNewSeed.error = when {
|
||||||
|
raw.isBlank() -> null
|
||||||
|
newSeed == null -> "Not a valid TOTP seed"
|
||||||
|
newSeed == entry.seed -> "This is already the current seed"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (newSeed == entry.seed) newSeed = null
|
||||||
|
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null
|
||||||
|
refreshPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
d.etNewSeed.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) = validate()
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
d.btnScanNewSeed.setOnClickListener {
|
||||||
|
scanTarget = d.etNewSeed
|
||||||
|
qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
val ticker = viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
while (isActive) { refreshPreview(); delay(1_000) }
|
||||||
|
}
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
ticker.cancel()
|
||||||
|
if (scanTarget === d.etNewSeed) scanTarget = null
|
||||||
|
}
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||||
|
replace.isEnabled = false
|
||||||
|
replace.setOnClickListener {
|
||||||
|
val seed = newSeed ?: return@setOnClickListener
|
||||||
|
confirmReplace(entry) {
|
||||||
|
saveSeed(position, seed)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("Delete old seed?")
|
||||||
|
.setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " +
|
||||||
|
"recovered afterwards.")
|
||||||
|
.setPositiveButton("Replace") { _, _ -> onConfirm() }
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeed(position: Int, seed: String) {
|
||||||
|
val entry = entries.getOrNull(position) ?: return
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
when (entry.bank) {
|
||||||
|
"MIB" -> {
|
||||||
|
store.updateMibOtpSeed(entry.loginId, seed)
|
||||||
|
// The live flow keeps the seed in memory for silent re-login
|
||||||
|
(requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed)
|
||||||
|
}
|
||||||
|
"BML" -> store.updateBmlOtpSeed(entry.loginId, seed)
|
||||||
|
}
|
||||||
|
entries[position] = entry.copy(seed = seed)
|
||||||
|
adapter?.notifyItemChanged(position)
|
||||||
|
Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||||
|
val code = text.toString().replace(" ", "")
|
||||||
|
if (code.isEmpty() || code.contains('-')) return
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,21 +323,27 @@ class OtpFragment : Fragment() {
|
|||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
val entries = mutableListOf<OtpEntry>()
|
val rank = store.loginRank()
|
||||||
|
val tagged = mutableListOf<Pair<String, OtpEntry>>()
|
||||||
for (loginId in store.getMibLoginIds()) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
val name = store.loadMibFullName(loginId)
|
val name = store.loadMibFullName(loginId)
|
||||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
tagged.add(CredentialStore.loginKey("mib", loginId) to
|
||||||
|
OtpEntry("MIB", loginId, creds.username, name, creds.otpSeed))
|
||||||
}
|
}
|
||||||
for (loginId in store.getBmlLoginIds()) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||||
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
tagged.add(CredentialStore.loginKey("bml", loginId) to
|
||||||
|
OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||||
}
|
}
|
||||||
|
entries.clear()
|
||||||
|
entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second })
|
||||||
|
|
||||||
val adapter = OtpAdapter(entries)
|
val adapter = OtpAdapter(entries).also { this.adapter = it }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
// Fetch real names in background if not yet cached, then refresh labels
|
// Fetch real names in background if not yet cached, then refresh labels
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -120,9 +363,8 @@ class OtpFragment : Fragment() {
|
|||||||
mobile = profile.mobile,
|
mobile = profile.mobile,
|
||||||
enrolled = profile.enrolled
|
enrolled = profile.enrolled
|
||||||
))
|
))
|
||||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,9 +383,8 @@ class OtpFragment : Fragment() {
|
|||||||
idCard = info.idCard,
|
idCard = info.idCard,
|
||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
))
|
))
|
||||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +406,8 @@ class OtpFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
adapter = null
|
||||||
|
scanTarget = null
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.text.TextPaint
|
||||||
|
import android.text.TextUtils
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
@@ -48,6 +51,8 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private var selectedAccount: BankAccount? = null
|
private var selectedAccount: BankAccount? = null
|
||||||
|
/** Set when opened for a saved contact: the QR is for their account, not one of ours. */
|
||||||
|
private var contactTarget: QrTarget? = null
|
||||||
private var generatedBitmap: Bitmap? = null
|
private var generatedBitmap: Bitmap? = null
|
||||||
private var generateJob: Job? = null
|
private var generateJob: Job? = null
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
@@ -59,7 +64,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who the QR pays into — one of our own accounts, or a contact's. */
|
||||||
|
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
|
||||||
|
|
||||||
|
private fun currentTarget(): QrTarget? = contactTarget
|
||||||
|
?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) }
|
||||||
|
|
||||||
|
/** Name printed on the card and put in the payload (tag 59). */
|
||||||
|
private fun qrHolderName(account: BankAccount): String = when {
|
||||||
|
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
|
||||||
|
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
|
||||||
|
?: CredentialStore(requireContext())
|
||||||
|
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
|
||||||
|
?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
?: account.accountBriefName
|
||||||
|
else -> account.accountBriefName
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
contactTarget = arguments?.let { args ->
|
||||||
|
val number = args.getString(ARG_ACCOUNT_NUMBER) ?: return@let null
|
||||||
|
QrTarget(number, args.getString(ARG_ACCOUNT_NAME).orEmpty(), args.getString(ARG_BANK).orEmpty())
|
||||||
|
}
|
||||||
val basePaddingBottom = view.paddingBottom
|
val basePaddingBottom = view.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||||
@@ -68,7 +94,15 @@ class PayMvQrFragment : Fragment() {
|
|||||||
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
setupDropdown()
|
if (contactTarget != null) {
|
||||||
|
// Not our account, so no account picker and our phone number doesn't belong on it
|
||||||
|
binding.tilAccount.visibility = View.GONE
|
||||||
|
binding.layoutIncludePhone.visibility = View.GONE
|
||||||
|
binding.switchIncludePhone.isChecked = false
|
||||||
|
scheduleGenerate()
|
||||||
|
} else {
|
||||||
|
setupDropdown()
|
||||||
|
}
|
||||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
||||||
@@ -83,6 +117,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val eligible = accounts.filter {
|
val eligible = accounts.filter {
|
||||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||||
|
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
|
||||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||||
}
|
}
|
||||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
@@ -118,24 +153,26 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun generateQr() {
|
private suspend fun generateQr() {
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
val acquirer = when (account.bank) {
|
val acquirer = when (target.bank) {
|
||||||
"BML" -> "MALBMVMV"
|
"BML" -> "MALBMVMV"
|
||||||
"MIB" -> "MADVMVMV"
|
"MIB" -> "MADVMVMV"
|
||||||
"FAHIPAY" -> "FAHIMVMV"
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
else -> "MADVMVMV"
|
else -> "MADVMVMV"
|
||||||
}
|
}
|
||||||
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
|
||||||
?.replace(",", "")
|
val amountFormatted = amountRaw
|
||||||
?.toDoubleOrNull()
|
?.toDoubleOrNull()
|
||||||
?.takeIf { it > 0 }
|
?.takeIf { it > 0 }
|
||||||
?.let { "%.2f".format(it) }
|
?.let { "%.2f".format(it) }
|
||||||
|
// BML shows the amount on the card as typed (no forced decimals)
|
||||||
|
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
|
||||||
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val includePhone = binding.switchIncludePhone.isChecked
|
val account = selectedAccount
|
||||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
|
||||||
val store = CredentialStore(ctx)
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||||
val mobile = if (includePhone) {
|
val store = CredentialStore(ctx)
|
||||||
when (account.bank) {
|
when (account.bank) {
|
||||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||||
@@ -144,17 +181,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
when {
|
when {
|
||||||
m.startsWith("+") -> m
|
m.startsWith("+") -> m
|
||||||
m.length == 7 -> "+960$m"
|
m.length == 7 -> "+960$m"
|
||||||
|
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
|
||||||
else -> m
|
else -> m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val purpose = binding.etReference.text?.toString()?.trim()
|
val purpose = binding.etReference.text?.toString()?.trim()
|
||||||
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// The reference (62/05) is also printed vertically beside the QR, as each bank does
|
||||||
|
val reference = when (target.bank) {
|
||||||
|
// BML: base-32 account number followed by the amount as typed
|
||||||
|
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
|
||||||
|
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
|
||||||
|
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
|
||||||
|
else -> generateReference(9)
|
||||||
|
}
|
||||||
|
|
||||||
val bmp = withContext(Dispatchers.Default) {
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank)
|
||||||
renderQrCard(ctx, account, payload, amountFormatted)
|
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
|
||||||
|
else renderQrCard(ctx, target, payload, reference)
|
||||||
}
|
}
|
||||||
if (_binding == null) return
|
if (_binding == null) return
|
||||||
generatedBitmap = bmp
|
generatedBitmap = bmp
|
||||||
@@ -173,14 +221,19 @@ class PayMvQrFragment : Fragment() {
|
|||||||
acquirer: String,
|
acquirer: String,
|
||||||
amountStr: String?,
|
amountStr: String?,
|
||||||
mobile: String?,
|
mobile: String?,
|
||||||
purpose: String
|
purpose: String?,
|
||||||
|
ref: String,
|
||||||
|
bank: String
|
||||||
): String {
|
): String {
|
||||||
fun tlv(tag: String, value: String): String {
|
fun tlv(tag: String, value: String): String {
|
||||||
val len = value.length
|
val len = value.length
|
||||||
return tag + (if (len < 10) "0$len" else "$len") + value
|
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||||
}
|
}
|
||||||
val format = tlv("00", "01")
|
val format = tlv("00", "01")
|
||||||
val poi = tlv("01", "11")
|
// Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount
|
||||||
|
// as "***" when they don't; its scanner may reject QRs that differ
|
||||||
|
val fahipay = bank == "FAHIPAY"
|
||||||
|
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
|
||||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
val sub01 = tlv("01", acquirer)
|
val sub01 = tlv("01", acquirer)
|
||||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||||
@@ -190,21 +243,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||||
val mcc = tlv("52", "0000")
|
val mcc = tlv("52", "0000")
|
||||||
val currency = tlv("53", "462")
|
val currency = tlv("53", "462")
|
||||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
val amountTLV = when {
|
||||||
|
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
|
||||||
|
fahipay -> tlv("54", "***")
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
val country = tlv("58", "MV")
|
val country = tlv("58", "MV")
|
||||||
val name = tlv("59", accountName.take(25))
|
val name = tlv("59", accountName.uppercase().take(25))
|
||||||
val ref = generateReference()
|
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
|
||||||
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else ""
|
||||||
|
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
|
||||||
|
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
|
||||||
|
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
|
||||||
val timestamp = java.time.LocalDateTime.now()
|
val timestamp = java.time.LocalDateTime.now()
|
||||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
||||||
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
||||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304"
|
||||||
return prefix + crc16(prefix)
|
return prefix + crc16(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateReference(): String {
|
private fun generateReference(length: Int): String {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
return (1..9).map { chars.random() }.joinToString("")
|
return (1..length).map { chars.random() }.joinToString("")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun crc16(data: String): String {
|
private fun crc16(data: String): String {
|
||||||
@@ -221,88 +281,127 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
// ── QR card rendering ────────────────────────────────────────────────────
|
// ── QR card rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
|
||||||
|
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
|
||||||
|
* [PX_PER_DP] pixels per dp.
|
||||||
|
*/
|
||||||
private fun renderQrCard(
|
private fun renderQrCard(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
account: BankAccount,
|
target: QrTarget,
|
||||||
qrPayload: String,
|
qrPayload: String,
|
||||||
amountStr: String?
|
qrId: String
|
||||||
): Bitmap {
|
): Bitmap {
|
||||||
val W = 900
|
val sw = SCREEN_WIDTH_DP
|
||||||
val H = 1080
|
fun px(dp: Float) = dp * PX_PER_DP
|
||||||
val outerCorner = 48f
|
val mmaBlue = Color.parseColor("#0E5CA4")
|
||||||
val boxBlue = Color.parseColor("#2272B7")
|
|
||||||
val footerBlue = Color.parseColor("#1A5799")
|
|
||||||
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
|
||||||
|
|
||||||
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side
|
||||||
|
val qrSize = sw * 0.5f
|
||||||
|
val railW = sw / 1.85f
|
||||||
|
|
||||||
|
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
textSize = px(14f)
|
||||||
|
typeface = Typeface.DEFAULT
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
textSize = px(sw * 0.046f)
|
||||||
|
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||||
|
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
// Android RN Text lines include font padding: line height = bottom - top
|
||||||
|
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
|
||||||
|
|
||||||
|
// --- Vertical layout (dp, card-local) ---
|
||||||
|
val topCardTop = 2f
|
||||||
|
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
|
||||||
|
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
|
||||||
|
val logoBoxH = logoBoxW * 0.1116751269035533f
|
||||||
|
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
|
||||||
|
val payMvBoxH = payMvBoxW * 0.17333333333333334f
|
||||||
|
val brandH = maxOf(logoBoxH, payMvBoxH)
|
||||||
|
val nameText = target.name.uppercase()
|
||||||
|
val hasName = nameText.isNotBlank()
|
||||||
|
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
|
||||||
|
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
|
||||||
|
val nameH = if (hasName) lineHeight(namePaint) else 0f
|
||||||
|
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
|
||||||
|
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
|
||||||
|
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
|
||||||
|
val footerLineH = lineHeight(footerPaint)
|
||||||
|
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
|
||||||
|
|
||||||
|
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
// Clip to outer rounded card shape
|
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
|
||||||
val outerPath = Path()
|
val outerPath = Path().apply {
|
||||||
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW)
|
||||||
canvas.clipPath(outerPath)
|
|
||||||
canvas.drawColor(Color.WHITE)
|
|
||||||
|
|
||||||
// --- Bank logo top-left ---
|
|
||||||
val logoRes = when (account.bank) {
|
|
||||||
"BML" -> R.drawable.bml_logo_vector
|
|
||||||
"MIB" -> R.drawable.mib_faisanet_logo
|
|
||||||
else -> R.drawable.fahipay_logo_long
|
|
||||||
}
|
}
|
||||||
|
canvas.clipPath(outerPath)
|
||||||
|
canvas.drawColor(mmaBlue)
|
||||||
|
|
||||||
|
// topCard: white, 2dp inset, top corners 18
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
val r = px(18f)
|
||||||
|
canvas.drawPath(Path().apply {
|
||||||
|
addRoundRect(
|
||||||
|
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)),
|
||||||
|
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||||
|
)
|
||||||
|
}, paint)
|
||||||
|
|
||||||
|
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
|
||||||
|
val rowL = 2f + 40f
|
||||||
|
val rowR = cardW - 2f - 40f
|
||||||
|
val rowCenterY = brandTop + brandH / 2
|
||||||
|
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
|
||||||
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||||
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||||
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||||
val maxW = 180f; val maxH = 76f
|
// resizeMode "contain" inside the logo box, left-aligned in the row
|
||||||
val scale = minOf(maxW / nW, maxH / nH)
|
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH)
|
||||||
val lW = (nW * scale).toInt()
|
val lW = (nW * scale).toInt()
|
||||||
val lH = (nH * scale).toInt()
|
val lH = (nH * scale).toInt()
|
||||||
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
|
||||||
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
val lTop = (px(rowCenterY) - lH / 2f).toInt()
|
||||||
|
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
|
||||||
d.draw(canvas)
|
d.draw(canvas)
|
||||||
}
|
}
|
||||||
|
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
|
||||||
// --- "PayMV QR" top-right ---
|
paint.color = mmaBlue
|
||||||
paint.color = Color.parseColor("#1A1A2E")
|
paint.typeface = footerPaint.typeface
|
||||||
paint.textSize = 36f
|
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
|
||||||
paint.textAlign = Paint.Align.RIGHT
|
paint.textAlign = Paint.Align.RIGHT
|
||||||
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
paint.textSize = px(payMvBoxH)
|
||||||
|
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
|
||||||
|
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
|
||||||
|
canvas.drawText(
|
||||||
|
"PayMV QR", px(rowR),
|
||||||
|
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
|
||||||
|
)
|
||||||
|
|
||||||
// --- Blue rounded box ---
|
// --- qrCard: mmaBlue, radius 16, 40dp side margins ---
|
||||||
paint.color = boxBlue
|
val qrCardL = 2f + 40f
|
||||||
paint.textAlign = Paint.Align.LEFT
|
val qrCardR = cardW - 2f - 40f
|
||||||
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
paint.color = mmaBlue
|
||||||
|
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint)
|
||||||
|
|
||||||
// Account name (white, bold, uppercase, auto-scaled to fit)
|
if (hasName) {
|
||||||
paint.color = Color.WHITE
|
val maxNameW = px(qrCardR - qrCardL)
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
if (namePaint.measureText(nameText) > maxNameW) {
|
||||||
paint.textAlign = Paint.Align.CENTER
|
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||||
val nameText = account.accountBriefName.uppercase()
|
}
|
||||||
paint.textSize = 36f
|
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
|
||||||
val maxNameW = boxR - boxL - 48f
|
|
||||||
if (paint.measureText(nameText) > maxNameW) {
|
|
||||||
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
|
||||||
}
|
|
||||||
val nameBaseline = boxT + 68f
|
|
||||||
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
|
||||||
|
|
||||||
// Optional amount below name
|
|
||||||
val qrTopY: Float
|
|
||||||
if (!amountStr.isNullOrBlank()) {
|
|
||||||
paint.textSize = 28f
|
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
|
||||||
val amtBaseline = nameBaseline + 42f
|
|
||||||
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
|
||||||
qrTopY = amtBaseline + 20f
|
|
||||||
} else {
|
|
||||||
qrTopY = nameBaseline + 26f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR code — white modules on the same blue as the box background
|
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
|
||||||
val availH = boxB - qrTopY - 24f
|
val qrPx = px(qrSize).toInt()
|
||||||
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
val qrLeft = px(cardW / 2) - qrPx / 2f
|
||||||
val qrLeft = ((W - qrPx) / 2).toFloat()
|
|
||||||
try {
|
try {
|
||||||
val hints = mapOf(
|
val hints = mapOf(
|
||||||
EncodeHintType.MARGIN to 0,
|
EncodeHintType.MARGIN to 0,
|
||||||
@@ -312,23 +411,150 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val pixels = IntArray(qrPx * qrPx)
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
for (y in 0 until qrPx) {
|
for (y in 0 until qrPx) {
|
||||||
for (x in 0 until qrPx) {
|
for (x in 0 until qrPx) {
|
||||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null)
|
||||||
qrBm.recycle()
|
qrBm.recycle()
|
||||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
// --- Dark blue footer ---
|
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
|
||||||
paint.color = footerBlue
|
if (qrId.isNotEmpty()) {
|
||||||
paint.textAlign = Paint.Align.LEFT
|
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
color = Color.BLACK
|
||||||
|
alpha = (255 * 0.8f).toInt()
|
||||||
|
textSize = px(10f)
|
||||||
|
typeface = Typeface.DEFAULT
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
val qrRight = cardW / 2 + qrSize / 2
|
||||||
|
val cx = px(qrRight + railW / 1.37f - railW / 2)
|
||||||
|
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
|
||||||
|
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(-90f, cx, cy)
|
||||||
|
val fm = idPaint.fontMetrics
|
||||||
|
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
|
||||||
|
canvas.drawText(
|
||||||
|
"MALDIVES NATIONAL QR", px(cardW / 2),
|
||||||
|
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
|
||||||
|
)
|
||||||
|
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
|
||||||
|
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
|
||||||
|
* except the vertical reference, which is Montserrat as on Fahipay's.
|
||||||
|
*/
|
||||||
|
private fun renderFahipayQrCard(
|
||||||
|
ctx: Context,
|
||||||
|
target: QrTarget,
|
||||||
|
qrPayload: String,
|
||||||
|
reference: String
|
||||||
|
): Bitmap {
|
||||||
|
val w = 1240f
|
||||||
|
val h = 1322f
|
||||||
|
val blue = Color.parseColor("#005DA3")
|
||||||
|
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||||
|
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
|
||||||
|
|
||||||
|
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
// Blue card with a 6px border around the white area; footer is the blue below it
|
||||||
|
canvas.clipPath(Path().apply {
|
||||||
|
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
|
||||||
|
})
|
||||||
|
canvas.drawColor(blue)
|
||||||
paint.color = Color.WHITE
|
paint.color = Color.WHITE
|
||||||
paint.textSize = 32f
|
canvas.drawPath(Path().apply {
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
addRoundRect(
|
||||||
paint.textAlign = Paint.Align.CENTER
|
RectF(6f, 6f, w - 6f, 1174f),
|
||||||
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||||
|
)
|
||||||
|
}, paint)
|
||||||
|
|
||||||
|
// Square app icon, then the "FahiPay" wordmark, in one row
|
||||||
|
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
|
||||||
|
d.setBounds(94, 94, 163, 163)
|
||||||
|
d.draw(canvas)
|
||||||
|
}
|
||||||
|
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
|
||||||
|
val lH = 48
|
||||||
|
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
|
||||||
|
d.setBounds(178, 104, 178 + lW, 104 + lH)
|
||||||
|
d.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sized so capitals match the reference's cap heights; positions below are cap tops
|
||||||
|
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
|
||||||
|
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
typeface = tf
|
||||||
|
letterSpacing = spacingEm
|
||||||
|
textAlign = align
|
||||||
|
textSize = 100f
|
||||||
|
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
|
||||||
|
textSize = 100f * capH / h
|
||||||
|
}
|
||||||
|
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
|
||||||
|
|
||||||
|
// "PayMV QR" top-right
|
||||||
|
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
|
||||||
|
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
|
||||||
|
|
||||||
|
// Blue QR panel
|
||||||
|
paint.color = blue
|
||||||
|
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
|
||||||
|
|
||||||
|
// Account name
|
||||||
|
val nameText = target.name.uppercase()
|
||||||
|
if (nameText.isNotBlank()) {
|
||||||
|
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||||
|
val maxNameW = 1074f - 166f - 80f
|
||||||
|
if (namePaint.measureText(nameText) > maxNameW) {
|
||||||
|
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||||
|
}
|
||||||
|
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR — white modules on blue, no quiet zone
|
||||||
|
val qrPx = 562
|
||||||
|
try {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||||
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
|
for (y in 0 until qrPx) {
|
||||||
|
for (x in 0 until qrPx) {
|
||||||
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
|
canvas.drawBitmap(qrBm, 338f, 417f, null)
|
||||||
|
qrBm.recycle()
|
||||||
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
|
// Reference, blue, reading bottom-to-top in the white margin right of the panel,
|
||||||
|
// starting level with y=1087 and with its baseline at x=1171
|
||||||
|
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue }
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(-90f, 1171f, 1087f)
|
||||||
|
canvas.drawText(reference, 1171f, 1087f, refPaint)
|
||||||
|
canvas.restore()
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||||
|
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
|
||||||
|
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
@@ -337,13 +563,13 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun shareQr() {
|
private fun shareQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val uri = withContext(Dispatchers.IO) {
|
val uri = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val dir = File(requireContext().cacheDir, "qr")
|
val dir = File(requireContext().cacheDir, "qr")
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
val file = File(dir, "${safeName}_paymv_qr.png")
|
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||||
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
@@ -365,11 +591,11 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun saveQr() {
|
private fun saveQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
val filename = "${safeName}_PayMV_QR.png"
|
val filename = "${safeName}_PayMV_QR.png"
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -411,6 +637,25 @@ class PayMvQrFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||||
|
private const val ARG_ACCOUNT_NAME = "account_name"
|
||||||
|
private const val ARG_BANK = "bank"
|
||||||
|
|
||||||
|
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
|
||||||
|
private const val SCREEN_WIDTH_DP = 560f
|
||||||
|
private const val PX_PER_DP = 2f
|
||||||
|
|
||||||
|
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
|
||||||
|
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_ACCOUNT_NUMBER, accountNumber)
|
||||||
|
putString(ARG_ACCOUNT_NAME, name)
|
||||||
|
putString(ARG_BANK, bank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account dropdown adapter ──────────────────────────────────────────────
|
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||||
|
|
||||||
private inner class QrAccountAdapter(
|
private inner class QrAccountAdapter(
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
@@ -123,15 +123,11 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val screenW = resources.displayMetrics.widthPixels
|
stackAdapter = CardStackAdapter()
|
||||||
val peekPx = screenW / 8
|
|
||||||
cardWidth = screenW - 2 * peekPx
|
|
||||||
|
|
||||||
stackAdapter = CardStackAdapter(cardWidth)
|
|
||||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
binding.rvCards.adapter = stackAdapter
|
binding.rvCards.adapter = stackAdapter
|
||||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
|
||||||
binding.rvCards.clipToPadding = false
|
binding.rvCards.clipToPadding = false
|
||||||
|
applyCarouselWidth()
|
||||||
|
|
||||||
val snapHelper = PagerSnapHelper()
|
val snapHelper = PagerSnapHelper()
|
||||||
snapHelper.attachToRecyclerView(binding.rvCards)
|
snapHelper.attachToRecyclerView(binding.rvCards)
|
||||||
@@ -637,8 +633,13 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Held while tap mode is up: recreating the activity would clear the NFC payment token. */
|
||||||
|
private var tapGuard: HomeActivity.PaymentGuard? = null
|
||||||
|
|
||||||
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||||
isTapMode = enabled
|
isTapMode = enabled
|
||||||
|
tapGuard?.end()
|
||||||
|
tapGuard = if (enabled) (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) else null
|
||||||
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||||
if (enabled) enterTapMode(item!!) else exitTapMode()
|
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||||
}
|
}
|
||||||
@@ -904,7 +905,9 @@ class CardsFragment : Fragment() {
|
|||||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||||
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
||||||
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
|
val rank = store.loginRank()
|
||||||
|
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
|
||||||
|
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
|
||||||
// Move default BML card to front
|
// Move default BML card to front
|
||||||
cards = if (defaultNum != null) {
|
cards = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
@@ -957,6 +960,25 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sizes the carousel from the window width: each card leaves a 1/8 peek on either side. */
|
||||||
|
private fun applyCarouselWidth() {
|
||||||
|
val screenW = resources.displayMetrics.widthPixels
|
||||||
|
val peekPx = screenW / 8
|
||||||
|
cardWidth = screenW - 2 * peekPx
|
||||||
|
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomeActivity handles size changes itself (rotation, split screen) rather than being
|
||||||
|
// recreated, so the carousel has to re-measure for the new width on its own.
|
||||||
|
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
if (_binding == null) return
|
||||||
|
applyCarouselWidth()
|
||||||
|
stackAdapter.notifyDataSetChanged()
|
||||||
|
binding.rvCards.scrollToPosition(currentCardPosition)
|
||||||
|
binding.rvCards.post { if (_binding != null) applyCardScales() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyCardScales() {
|
private fun applyCardScales() {
|
||||||
val rv = binding.rvCards
|
val rv = binding.rvCards
|
||||||
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
||||||
@@ -1053,7 +1075,7 @@ class CardsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
private inner class CardStackAdapter : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
||||||
private var items: List<CardItem> = emptyList()
|
private var items: List<CardItem> = emptyList()
|
||||||
|
|
||||||
fun update(newItems: List<CardItem>) {
|
fun update(newItems: List<CardItem>) {
|
||||||
@@ -1068,6 +1090,8 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.bind(items[position])
|
holder.bind(items[position])
|
||||||
|
// Re-applied on every bind so a width change reaches recycled holders too
|
||||||
|
holder.itemView.layoutParams.width = cardWidth
|
||||||
// Pre-scale based on data position so initial render and off-screen cards are correct
|
// Pre-scale based on data position so initial render and off-screen cards are correct
|
||||||
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
||||||
val scale = 1f - 0.18f * fraction
|
val scale = 1f - 0.18f * fraction
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
val isDark = prefs.getString("theme", "system") == "dark"
|
val isDark = prefs.getString("theme", "system") == "dark"
|
||||||
updatePitchBlackState(isDark)
|
updatePitchBlackState(isDark)
|
||||||
|
|
||||||
|
// Receipts
|
||||||
|
binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false)
|
||||||
|
binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply()
|
||||||
|
}
|
||||||
|
|
||||||
// Accent color
|
// Accent color
|
||||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||||
binding.accentToggle.check(when (savedPreset) {
|
binding.accentToggle.check(when (savedPreset) {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -30,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlProfile
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
@@ -359,65 +363,86 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||||
|
|
||||||
private fun buildLoginsSection() {
|
private fun buildLoginsSection() {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val container = binding.loginsContainer
|
val container = binding.loginsContainer
|
||||||
container.removeAllViews()
|
container.removeAllViews()
|
||||||
|
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
val entries = mutableListOf<LoginEntry>()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
|
||||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
for (loginId in store.getMibLoginIds()) {
|
||||||
|
|
||||||
for (loginId in mibLoginIds) {
|
|
||||||
val profile = store.loadMibUserProfile(loginId)
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
val mibProfiles = store.loadMibProfiles(loginId)
|
val mibProfiles = store.loadMibProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
|
||||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
|
||||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in fahipayLoginIds) {
|
for (loginId in store.getFahipayLoginIds()) {
|
||||||
val profile = store.loadFahipayUserProfile(loginId)
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||||
showFahipayLoginDetails(store, loginId, profile)
|
showFahipayLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in mfaisaLoginIds) {
|
for (loginId in store.getMfaisaLoginIds()) {
|
||||||
val profile = store.loadMfaisaUserProfile(loginId)
|
val profile = store.loadMfaisaUserProfile(loginId)
|
||||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||||
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
|
||||||
showMfaisaLoginDetails(store, loginId, profile)
|
showMfaisaLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val rank = store.loginRank()
|
||||||
|
val sorted = entries.sortedBy { rank(it.key) }
|
||||||
|
val draggable = sorted.size > 1
|
||||||
|
for (entry in sorted) {
|
||||||
|
addLoginRow(
|
||||||
|
container, entry.logoRes, entry.displayName, entry.onClick,
|
||||||
|
dragTag = entry.key.takeIf { draggable },
|
||||||
|
onReordered = { order ->
|
||||||
|
store.setLoginOrder(order)
|
||||||
|
viewModel.resortByLoginOrder()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
private fun addLoginRow(
|
||||||
|
container: LinearLayout,
|
||||||
|
logoRes: Int,
|
||||||
|
displayName: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
dragTag: String? = null,
|
||||||
|
onReordered: (List<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
|
||||||
isClickable = true; isFocusable = true
|
isClickable = true; isFocusable = true
|
||||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
background = ta.getDrawable(0); ta.recycle()
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
setOnClickListener { onClick() }
|
setOnClickListener { onClick() }
|
||||||
|
tag = dragTag
|
||||||
}
|
}
|
||||||
val logo = ImageView(ctx).apply {
|
val logo = ImageView(ctx).apply {
|
||||||
setImageResource(logoRes)
|
setImageResource(logoRes)
|
||||||
@@ -430,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
row.addView(logo); row.addView(tvName)
|
row.addView(logo); row.addView(tvName)
|
||||||
|
if (dragTag != null) {
|
||||||
|
val handle = ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_reorder_lines)
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||||
|
contentDescription = getString(R.string.drag_to_reorder)
|
||||||
|
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
|
||||||
|
}
|
||||||
|
row.addView(handle)
|
||||||
|
attachDragHandle(handle, row, container, onReordered)
|
||||||
|
}
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
|
||||||
|
val ctx = handle.context
|
||||||
|
val lift = 8 * ctx.resources.displayMetrics.density
|
||||||
|
val dragBg = android.graphics.drawable.ColorDrawable(
|
||||||
|
com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
|
||||||
|
var lastY = 0f
|
||||||
|
var startIndex = 0
|
||||||
|
var savedBg: android.graphics.drawable.Drawable? = null
|
||||||
|
|
||||||
|
handle.setOnTouchListener { v, ev ->
|
||||||
|
when (ev.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
v.parent.requestDisallowInterceptTouchEvent(true)
|
||||||
|
lastY = ev.rawY
|
||||||
|
startIndex = group.indexOfChild(row)
|
||||||
|
row.animate().cancel()
|
||||||
|
savedBg = row.background
|
||||||
|
row.background = dragBg
|
||||||
|
row.translationZ = lift
|
||||||
|
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
row.translationY += ev.rawY - lastY
|
||||||
|
lastY = ev.rawY
|
||||||
|
var index = group.indexOfChild(row)
|
||||||
|
while (index < group.childCount - 1) {
|
||||||
|
val next = group.getChildAt(index + 1)
|
||||||
|
if (row.translationY <= next.height / 2f) break
|
||||||
|
group.removeView(next); group.addView(next, index)
|
||||||
|
row.translationY -= next.height
|
||||||
|
next.translationY = row.height.toFloat()
|
||||||
|
next.animate().translationY(0f).setDuration(150).start()
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
while (index > 0) {
|
||||||
|
val prev = group.getChildAt(index - 1)
|
||||||
|
if (row.translationY >= -prev.height / 2f) break
|
||||||
|
group.removeView(prev); group.addView(prev, index)
|
||||||
|
row.translationY += prev.height
|
||||||
|
prev.translationY = -row.height.toFloat()
|
||||||
|
prev.animate().translationY(0f).setDuration(150).start()
|
||||||
|
index--
|
||||||
|
}
|
||||||
|
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
|
||||||
|
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
v.parent.requestDisallowInterceptTouchEvent(false)
|
||||||
|
row.animate().translationY(0f).translationZ(0f).setDuration(150)
|
||||||
|
.withEndAction { row.background = savedBg }
|
||||||
|
.start()
|
||||||
|
if (group.indexOfChild(row) != startIndex) {
|
||||||
|
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
|
||||||
|
private fun addAccountRow(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
acc: BankAccount,
|
||||||
|
hiddenAccounts: MutableSet<String>,
|
||||||
|
indent: Boolean
|
||||||
|
): Pair<BankAccount, MaterialSwitch> {
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
if (indent) it.marginStart = (28 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
|
||||||
|
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
|
||||||
|
setTextAppearance(nameAppearance)
|
||||||
|
})
|
||||||
|
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
|
||||||
|
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||||
|
else acc.accountTypeName.trim()
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(" · ")
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
val toggle = MaterialSwitch(ctx).apply {
|
||||||
|
isChecked = acc.accountNumber !in hiddenAccounts
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
return acc to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
|
||||||
|
private fun addNestedAccountRows(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
hiddenAccounts: MutableSet<String>
|
||||||
|
): List<Pair<BankAccount, MaterialSwitch>> =
|
||||||
|
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
|
||||||
|
|
||||||
|
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
|
||||||
|
private fun addAccountsSection(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
hiddenAccounts: MutableSet<String>,
|
||||||
|
showDivider: Boolean
|
||||||
|
): List<Pair<BankAccount, MaterialSwitch>> {
|
||||||
|
if (accounts.isEmpty()) return emptyList()
|
||||||
|
if (showDivider) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.accounts)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
|
||||||
|
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
|
||||||
|
* the account listeners ignore changes made while a toggle is disabled.
|
||||||
|
*/
|
||||||
|
private fun syncAccountToggles(
|
||||||
|
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
|
||||||
|
hiddenAccounts: Set<String>,
|
||||||
|
isParentHidden: (BankAccount) -> Boolean
|
||||||
|
) {
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
if (isParentHidden(acc)) {
|
||||||
|
toggle.isEnabled = false
|
||||||
|
toggle.isChecked = false
|
||||||
|
} else {
|
||||||
|
toggle.isChecked = acc.accountNumber !in hiddenAccounts
|
||||||
|
toggle.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
|
||||||
|
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
|
||||||
|
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
|
||||||
|
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMibLoginDetails(
|
private fun showMibLoginDetails(
|
||||||
store: CredentialStore,
|
store: CredentialStore,
|
||||||
loginId: String,
|
loginId: String,
|
||||||
@@ -443,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||||
val hidden = originalHidden.toMutableSet()
|
val hidden = originalHidden.toMutableSet()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -485,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
|
// Build toggle rows — wired up after dialog.show() so we can reference the Save button.
|
||||||
|
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||||
|
// can have multiple accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = mibProfiles.map { p ->
|
val toggleRows = mibProfiles.map { p ->
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
@@ -526,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
p to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts that don't belong to any known profile still need to be reachable.
|
||||||
|
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
|
||||||
|
accountRows += addAccountsSection(
|
||||||
|
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||||
|
showDivider = mibProfiles.isNotEmpty() || profile != null
|
||||||
|
)
|
||||||
|
|
||||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (p, toggle) ->
|
toggleRows.forEach { (p, toggle) ->
|
||||||
// Disable the sole remaining visible toggle so it can't be turned off
|
// Disable the sole remaining visible toggle so it can't be turned off
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -559,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenMibProfileIds(loginId, hidden)
|
store.setHiddenMibProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||||
|
val home = activity as? HomeActivity
|
||||||
|
home?.applyVisibility()
|
||||||
|
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
}
|
}
|
||||||
val originalHidden = hidden.toSet()
|
val originalHidden = hidden.toSet()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -630,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||||
|
// can have multiple accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = bmlProfiles.map { p ->
|
val toggleRows = bmlProfiles.map { p ->
|
||||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||||
@@ -677,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
p to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts that don't belong to any known profile still need to be reachable.
|
||||||
|
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
|
||||||
|
accountRows += addAccountsSection(
|
||||||
|
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||||
|
showDivider = bmlProfiles.isNotEmpty() || profile != null
|
||||||
|
)
|
||||||
|
|
||||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (_, toggle) ->
|
toggleRows.forEach { (_, toggle) ->
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -720,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenBmlProfileIds(loginId, hidden)
|
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||||
|
val home = activity as? HomeActivity
|
||||||
|
home?.applyVisibility()
|
||||||
|
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val masked = "••••••"
|
val masked = "••••••"
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -997,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(ctx)
|
val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
.setTitle(getString(R.string.fahipay_name))
|
.setTitle(getString(R.string.fahipay_name))
|
||||||
.setView(scroll)
|
.setView(scroll)
|
||||||
.setPositiveButton(R.string.close, null)
|
.apply {
|
||||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
setNeutralButton(R.string.close, null)
|
||||||
|
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
|
|
||||||
|
if (loginAccounts.isNotEmpty()) {
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.applyVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
@@ -1087,6 +1379,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||||
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||||
val originalHidden = hidden.toSet()
|
val originalHidden = hidden.toSet()
|
||||||
|
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||||
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||||
@@ -1142,6 +1436,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each group's own pockets nest directly beneath it (tree view), since a group
|
||||||
|
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = profileRows.map { row ->
|
val toggleRows = profileRows.map { row ->
|
||||||
val v = LinearLayout(ctx).apply {
|
val v = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
@@ -1164,6 +1461,7 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
v.addView(label)
|
v.addView(label)
|
||||||
v.addView(toggle)
|
v.addView(toggle)
|
||||||
container.addView(v)
|
container.addView(v)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
|
||||||
row to toggle
|
row to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1470,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
toggleRows.forEach { (_, toggle) ->
|
toggleRows.forEach { (_, toggle) ->
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -1199,11 +1498,20 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
|
// All pockets come back in one login response, so hiding/unhiding never needs a request
|
||||||
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, pockets, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
(activity as? HomeActivity)?.applyVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ data class TransferReceiptData(
|
|||||||
// MIB receipt fields
|
// MIB receipt fields
|
||||||
val mibReferenceNo: String = "",
|
val mibReferenceNo: String = "",
|
||||||
val mibTransactionDate: String = "",
|
val mibTransactionDate: String = "",
|
||||||
|
val mibFromProfileName: String = "",
|
||||||
|
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
|
||||||
// BML receipt fields
|
// BML receipt fields
|
||||||
val bmlFromName: String = "",
|
val bmlFromName: String = "",
|
||||||
val bmlReference: String = "",
|
val bmlReference: String = "",
|
||||||
|
|||||||
@@ -11,23 +11,20 @@ import android.graphics.BitmapFactory
|
|||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Rect
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.PixelCopy
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
@@ -37,6 +34,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
import sh.sar.basedbank.databinding.DialogReceiptFullscreenBmlBinding
|
||||||
|
import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||||
@@ -65,6 +64,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS = "remarks"
|
private const val ARG_REMARKS = "remarks"
|
||||||
private const val ARG_MIB_REF = "mib_ref"
|
private const val ARG_MIB_REF = "mib_ref"
|
||||||
private const val ARG_MIB_DATE = "mib_date"
|
private const val ARG_MIB_DATE = "mib_date"
|
||||||
|
private const val ARG_MIB_FROM_PROFILE = "mib_from_profile"
|
||||||
|
private const val ARG_MIB_TXN_TYPE = "mib_txn_type"
|
||||||
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
||||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||||
@@ -93,6 +94,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
putString(ARG_REMARKS, data.remarks)
|
putString(ARG_REMARKS, data.remarks)
|
||||||
putString(ARG_MIB_REF, data.mibReferenceNo)
|
putString(ARG_MIB_REF, data.mibReferenceNo)
|
||||||
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
||||||
|
putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName)
|
||||||
|
putString(ARG_MIB_TXN_TYPE, data.mibTransactionType)
|
||||||
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
||||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||||
@@ -159,15 +162,21 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
|
||||||
parentFragmentManager.popBackStack()
|
|
||||||
}
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
||||||
shareReceipt()
|
shareReceipt()
|
||||||
}
|
}
|
||||||
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||||
saveReceipt()
|
saveReceipt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("always_fullscreen_receipt", false)
|
||||||
|
if (alwaysFullScreen) {
|
||||||
|
// The normal page is skipped: keep it laid out (share/save capture its card) but hidden,
|
||||||
|
// and leave the receipt entirely when the full-screen view is closed
|
||||||
|
view.alpha = 0f
|
||||||
|
view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data binding ──────────────────────────────────────────────────────────
|
// ── Data binding ──────────────────────────────────────────────────────────
|
||||||
@@ -175,43 +184,64 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
||||||
val args = requireArguments()
|
val args = requireArguments()
|
||||||
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
||||||
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
|
|
||||||
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
||||||
val toLabel = args.getString(ARG_TO_LABEL, "")
|
val toLabel = args.getString(ARG_TO_LABEL, "")
|
||||||
val currency = args.getString(ARG_CURRENCY, "MVR")
|
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||||
val amount = args.getString(ARG_AMOUNT, "")
|
val amount = args.getString(ARG_AMOUNT, "")
|
||||||
|
|
||||||
// From avatar: initials first, then load profile image if hash available
|
// From avatar: initials first, then load profile image if hash available
|
||||||
binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor))
|
binding.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel))
|
||||||
binding.tvFromLabel.text = fromLabel
|
binding.tvFromLabel.text = fromLabel
|
||||||
if (fromProfileHash != null) {
|
if (fromProfileHash != null) {
|
||||||
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
|
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// To avatar: use already-rendered bitmap from TransferFragment if available
|
// To avatar: use already-rendered bitmap from TransferFragment if available
|
||||||
val toAvatar = pendingToAvatarBitmap
|
val toAvatar = pendingToAvatarBitmap
|
||||||
if (toAvatar != null) {
|
if (toAvatar != null) {
|
||||||
binding.ivToAvatar.setImageBitmap(toAvatar)
|
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar))
|
||||||
} else {
|
} else {
|
||||||
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
|
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel))
|
||||||
}
|
}
|
||||||
binding.tvToLabel.text = toLabel
|
binding.tvToLabel.text = toLabel
|
||||||
|
|
||||||
binding.tvAmount.text = "$currency $amount"
|
binding.tvAmount.text = "$currency $amount"
|
||||||
|
|
||||||
|
val toBank = args.getString(ARG_TO_BANK, "")
|
||||||
|
val rawDate = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
||||||
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
|
binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel }
|
||||||
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
|
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, ""))
|
||||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
.filter { it.isNotBlank() }.joinToString("\n")
|
||||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvToBank.text = toBank
|
||||||
|
// Receipts saved before the type was recorded fall back to a guess from the bank
|
||||||
|
binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank {
|
||||||
|
if (toBank == "MIB") "MIB Transfer" else "Quick Transfer"
|
||||||
|
}
|
||||||
|
binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm")
|
||||||
|
binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy")
|
||||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||||
|
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
|
||||||
|
|
||||||
copyOnLongClick(
|
copyOnLongClick(
|
||||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus,
|
||||||
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank,
|
||||||
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */
|
||||||
|
private fun formatMibDate(raw: String, pattern: String): String {
|
||||||
|
if (raw.isBlank()) return ""
|
||||||
|
val out = DateTimeFormatter.ofPattern(pattern, Locale.US)
|
||||||
|
for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) {
|
||||||
|
try {
|
||||||
|
return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.anyMibSession() ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
@@ -377,21 +407,45 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures the receipt card using PixelCopy, which correctly handles
|
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
|
||||||
* hardware-accelerated views (avoids the black-square problem with view.draw()).
|
* dimensions, so the captured image isn't affected by the on-screen scale
|
||||||
|
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||||
*/
|
*/
|
||||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||||
val view = _receiptCard ?: run { callback(null); return }
|
val shown = _receiptCard ?: run { callback(null); return }
|
||||||
if (view.width == 0 || view.height == 0) { callback(null); return }
|
if (shown.width == 0 || shown.height == 0) { callback(null); return }
|
||||||
|
|
||||||
|
// BML: the preview follows the app theme, but shared/saved images are always light
|
||||||
|
val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") {
|
||||||
|
inflateLightBmlCard(shown.width)
|
||||||
|
} else shown
|
||||||
|
|
||||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||||
val location = IntArray(2)
|
val canvas = Canvas(bitmap)
|
||||||
view.getLocationInWindow(location)
|
canvas.drawColor(Color.WHITE)
|
||||||
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
|
view.draw(canvas)
|
||||||
|
callback(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
|
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
|
||||||
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
|
private fun inflateLightBmlCard(widthPx: Int): View {
|
||||||
}, Handler(Looper.getMainLooper()))
|
val config = android.content.res.Configuration(resources.configuration).apply {
|
||||||
|
uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
}
|
||||||
|
val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply {
|
||||||
|
applyOverrideConfiguration(config)
|
||||||
|
}
|
||||||
|
val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx))
|
||||||
|
bindBml(binding)
|
||||||
|
val card = binding.receiptCard
|
||||||
|
(card.parent as? ViewGroup)?.removeView(card)
|
||||||
|
card.measure(
|
||||||
|
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
|
||||||
|
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||||
|
)
|
||||||
|
card.layout(0, 0, card.measuredWidth, card.measuredHeight)
|
||||||
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatBmlTimestamp(raw: String): String {
|
private fun formatBmlTimestamp(raw: String): String {
|
||||||
@@ -403,26 +457,46 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
/** Center-crops [src] to a square and masks it to a circle. */
|
||||||
|
private fun circleCrop(src: Bitmap): Bitmap {
|
||||||
|
val size = minOf(src.width, src.height)
|
||||||
|
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
|
||||||
|
shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply {
|
||||||
|
setLocalMatrix(android.graphics.Matrix().apply {
|
||||||
|
setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */
|
||||||
|
private fun makeMibInitialsBitmap(name: String): Bitmap {
|
||||||
val sizePx = (resources.displayMetrics.density * 52).toInt()
|
val sizePx = (resources.displayMetrics.density * 52).toInt()
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
paint.color = bgColor
|
paint.color = Color.parseColor("#C6E1FD")
|
||||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
paint.color = Color.WHITE
|
paint.color = Color.parseColor("#1168F3")
|
||||||
paint.textSize = sizePx * 0.42f
|
paint.textSize = sizePx * 0.36f
|
||||||
paint.textAlign = Paint.Align.CENTER
|
paint.textAlign = Paint.Align.CENTER
|
||||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
paint.typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||||
|
val initials = name.split(Regex("\\s+"))
|
||||||
|
.mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() }
|
||||||
|
.take(2).joinToString("").ifEmpty { "?" }
|
||||||
val metrics = paint.fontMetrics
|
val metrics = paint.fontMetrics
|
||||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFullScreenReceipt() {
|
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
|
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
|
||||||
|
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
|
||||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
|
||||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||||
@@ -464,6 +538,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||||
|
if (closePageOnDismiss) closeReceiptPage()
|
||||||
}
|
}
|
||||||
dialog.show()
|
dialog.show()
|
||||||
dialog.window?.let { win ->
|
dialog.window?.let { win ->
|
||||||
@@ -475,6 +550,121 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BML full-screen receipt: status bar stays visible, top bar with back button,
|
||||||
|
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
||||||
|
* Follows the app theme (light/dark).
|
||||||
|
*/
|
||||||
|
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||||
|
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard
|
||||||
|
(card.parent as? ViewGroup)?.removeView(card)
|
||||||
|
page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
))
|
||||||
|
|
||||||
|
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||||
|
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||||
|
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||||
|
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||||
|
|
||||||
|
val topBasePadding = page.topBar.paddingTop
|
||||||
|
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
page.topBar.updatePadding(top = topBasePadding + bars.top)
|
||||||
|
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||||
|
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.setContentView(page.root)
|
||||||
|
dialog.window?.let { win ->
|
||||||
|
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.statusBarColor = Color.TRANSPARENT
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.navigationBarColor = Color.TRANSPARENT
|
||||||
|
val isLight = (resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIB full-screen receipt: edge-to-edge card whose green header runs under the
|
||||||
|
* (visible) status bar, a floating close button top-right just below the status bar,
|
||||||
|
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
|
||||||
|
*/
|
||||||
|
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||||
|
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) }
|
||||||
|
val card = cardBinding.receiptCard
|
||||||
|
(card.parent as? ViewGroup)?.removeView(card)
|
||||||
|
page.cardHolder.addView(card, ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
))
|
||||||
|
|
||||||
|
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||||
|
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||||
|
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||||
|
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||||
|
|
||||||
|
val header = cardBinding.receiptHeader
|
||||||
|
val headerBaseHeight = header.layoutParams.height
|
||||||
|
val headerBasePadding = header.paddingTop
|
||||||
|
val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin
|
||||||
|
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
// Grow the green header under the status bar, keeping its content below it
|
||||||
|
header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top }
|
||||||
|
header.updatePadding(top = headerBasePadding + bars.top)
|
||||||
|
page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams)
|
||||||
|
.apply { topMargin = closeBaseMargin + bars.top }
|
||||||
|
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||||
|
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.setContentView(page.root)
|
||||||
|
dialog.window?.let { win ->
|
||||||
|
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.statusBarColor = Color.TRANSPARENT
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.navigationBarColor = Color.TRANSPARENT
|
||||||
|
val isLight = (resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||||
|
// Status bar sits over the green header, so always use light icons
|
||||||
|
isAppearanceLightStatusBars = false
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pops this receipt off the back stack, returning to whatever screen opened it. */
|
||||||
|
private fun closeReceiptPage() {
|
||||||
|
if (!isAdded || parentFragmentManager.isStateSaved) return
|
||||||
|
parentFragmentManager.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
for (tv in views) {
|
for (tv in views) {
|
||||||
tv.setOnLongClickListener {
|
tv.setOnLongClickListener {
|
||||||
@@ -494,13 +684,9 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||||
_receiptCard = null
|
_receiptCard = null
|
||||||
pendingToAvatarBitmap = null
|
pendingToAvatarBitmap = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
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.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.BmlQrPayLookupException
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
||||||
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.RecentPick
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
|
||||||
|
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
|
||||||
|
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
|
||||||
|
*
|
||||||
|
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
|
||||||
|
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
|
||||||
|
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
|
||||||
|
* BML-specific.
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||||
|
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
|
||||||
|
* the view is recreated.
|
||||||
|
*/
|
||||||
|
class BmlTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
|
/** Asks the fragment to drop the selected source and show the empty From picker. */
|
||||||
|
private val clearSource: () -> Unit,
|
||||||
|
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||||
|
private val onStateChanged: () -> Unit,
|
||||||
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||||
|
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||||
|
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
|
private var otpState = OtpState.NONE
|
||||||
|
set(value) {
|
||||||
|
// The whole OTP flow counts as a payment in flight: a theme change mid-way would
|
||||||
|
// otherwise recreate the screen between initiate and confirm.
|
||||||
|
if (field == OtpState.NONE && value != OtpState.NONE) {
|
||||||
|
otpGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||||
|
} else if (value == OtpState.NONE) {
|
||||||
|
otpGuard?.end()
|
||||||
|
otpGuard = null
|
||||||
|
}
|
||||||
|
field = value
|
||||||
|
}
|
||||||
|
private var otpGuard: HomeActivity.PaymentGuard? = null
|
||||||
|
private var otpChannel: String? = null
|
||||||
|
|
||||||
|
private data class PendingTransfer(
|
||||||
|
val src: BankAccount,
|
||||||
|
val debitAccount: String,
|
||||||
|
val creditAccount: String,
|
||||||
|
val amount: Double,
|
||||||
|
val amountStr: String,
|
||||||
|
val remarks: String,
|
||||||
|
val transferType: String,
|
||||||
|
val currency: String,
|
||||||
|
val bank: String?,
|
||||||
|
val destDisplay: String,
|
||||||
|
val destAccount: String,
|
||||||
|
val toBank: String,
|
||||||
|
val toAvatar: Bitmap?
|
||||||
|
)
|
||||||
|
private var pendingTransfer: PendingTransfer? = null
|
||||||
|
|
||||||
|
// Merchant QR state lives in the draft so it outlives this handler (dropped with the view).
|
||||||
|
private val draft get() = viewModel.transferDraft
|
||||||
|
|
||||||
|
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||||
|
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
|
||||||
|
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||||
|
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
|
||||||
|
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
|
||||||
|
private var qrLookupInFlight = false
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
|
||||||
|
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
|
||||||
|
|
||||||
|
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
|
||||||
|
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
|
||||||
|
|
||||||
|
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||||
|
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||||
|
|
||||||
|
fun sessionFor(account: BankAccount?): BmlSession? =
|
||||||
|
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
|
||||||
|
|
||||||
|
fun isBusinessProfile(account: BankAccount): Boolean {
|
||||||
|
val loginId = account.loginTag.removePrefix("bml_")
|
||||||
|
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||||
|
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
|
||||||
|
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
|
||||||
|
* resolve it, so the caller can fall back to the MIB IPS lookup.
|
||||||
|
*
|
||||||
|
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
|
||||||
|
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
|
||||||
|
* endpoint leaves `currency` blank — the caller enriches it via
|
||||||
|
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
|
||||||
|
*/
|
||||||
|
fun validateDestination(
|
||||||
|
session: BmlSession,
|
||||||
|
accountNumber: String,
|
||||||
|
verifyAsMib: Boolean
|
||||||
|
): MibIpsAccountInfo? {
|
||||||
|
val result = try {
|
||||||
|
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
|
||||||
|
else BmlValidateClient().validateAccount(session, accountNumber)
|
||||||
|
} catch (_: Exception) { null } ?: return null
|
||||||
|
val bankId = when (result.trnType) {
|
||||||
|
"IAT" -> "MALBMVMV"
|
||||||
|
else -> result.agnt ?: result.account
|
||||||
|
}
|
||||||
|
return MibIpsAccountInfo(
|
||||||
|
accountName = result.name,
|
||||||
|
accountNumber = result.account,
|
||||||
|
bankId = bankId,
|
||||||
|
currency = result.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
|
fun clearQrMerchant() {
|
||||||
|
draft.pendingBmlQrTarget = null
|
||||||
|
if (qrInfo == null) return
|
||||||
|
draft.bmlQrInfo = null
|
||||||
|
draft.bmlGatewayQr = false
|
||||||
|
fragment.setAmountLocked(false)
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
|
||||||
|
fun clearState() {
|
||||||
|
otpState = OtpState.NONE
|
||||||
|
otpChannel = null
|
||||||
|
pendingTransfer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode.
|
||||||
|
* Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment
|
||||||
|
* retries once sessions load (cold start) or when the view comes back (tab switched away
|
||||||
|
* mid-lookup).
|
||||||
|
*/
|
||||||
|
fun lookupQrMerchant(qrUrl: String) {
|
||||||
|
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
|
||||||
|
val draft = this.draft
|
||||||
|
draft.pendingBmlQrTarget = qrUrl
|
||||||
|
if (qrLookupInFlight) return
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
qrLookupInFlight = true
|
||||||
|
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||||
|
|
||||||
|
// The To row stays on screen with a spinner while loading and is only swapped for the
|
||||||
|
// merchant card once there is a merchant to show — hiding it up front left a gap that
|
||||||
|
// made the form jump twice.
|
||||||
|
fragment.startLookupLoading()
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
qrLookupInFlight = false
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
if (draft !== viewModel.transferDraft) return@launch
|
||||||
|
fragment.stopLookupLoading()
|
||||||
|
// Superseded: cleared meanwhile, or another QR was opened while this one ran
|
||||||
|
val latest = draft.pendingBmlQrTarget
|
||||||
|
if (latest != qrUrl) {
|
||||||
|
latest?.let { lookupQrMerchant(it) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
draft.pendingBmlQrTarget = null
|
||||||
|
val info = result.getOrNull()
|
||||||
|
if (info == null) {
|
||||||
|
// An expired or rejected QR is BML telling us something specific — show its own
|
||||||
|
// wording and stay put with the To row as it was, rather than bouncing the user
|
||||||
|
// out of the screen they just scanned from.
|
||||||
|
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||||
|
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||||
|
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
draft.bmlQrInfo = info
|
||||||
|
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount
|
||||||
|
// and need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||||
|
draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||||
|
if (info.amount == 0.0) {
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = "bmlqr:$qrUrl",
|
||||||
|
displayName = info.merchantName,
|
||||||
|
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||||
|
colorHex = "#0066A1",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the To row before touching the source: repainting the From card re-syncs the
|
||||||
|
// picker/scan buttons to the To row's visibility.
|
||||||
|
hideToRow()
|
||||||
|
|
||||||
|
// Only a BML card can pay a merchant QR — drop any other source, then auto-select
|
||||||
|
// the user's default card if no card was pre-selected
|
||||||
|
if (currentSource()?.let { isCard(it) } == false) clearSource()
|
||||||
|
if (currentSource() == null) {
|
||||||
|
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||||
|
if (defaultNum != null) {
|
||||||
|
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val defaultCard = allAccounts.firstOrNull {
|
||||||
|
it.accountNumber == defaultNum && isCard(it) &&
|
||||||
|
it.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (defaultCard != null) selectSource(defaultCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showQrMerchant(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a
|
||||||
|
* recreated view restores it — no network involved.
|
||||||
|
*/
|
||||||
|
fun showQrMerchant(info: BmlQrPayInfo) {
|
||||||
|
hideToRow()
|
||||||
|
// 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(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill amount if dynamic QR
|
||||||
|
if (info.amount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
|
fragment.setAmountLocked(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remarks not applicable for merchant QR payments
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.tilRemarks.alpha = 0.4f
|
||||||
|
|
||||||
|
onStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideToRow() {
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
|
||||||
|
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||||
|
*/
|
||||||
|
fun submitQrPayment() {
|
||||||
|
val info = qrInfo ?: return
|
||||||
|
val src = currentSource() ?: run {
|
||||||
|
Toast.makeText(ctx, 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(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||||
|
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||||
|
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = info.currency,
|
||||||
|
amountValue = "%.2f".format(amount),
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = fromDetail,
|
||||||
|
toName = info.merchantName,
|
||||||
|
toNumber = "",
|
||||||
|
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
debitAccount: String,
|
||||||
|
info: BmlQrPayInfo,
|
||||||
|
amount: Double,
|
||||||
|
dialog: AlertDialog,
|
||||||
|
frame: android.widget.FrameLayout
|
||||||
|
) {
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val session = sessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (gatewayQr) {
|
||||||
|
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(ctx).loadBmlCredentials(loginId)
|
||||||
|
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||||
|
BmlQrPayClient().confirmPayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (result.success) {
|
||||||
|
fragment.showSuccessInDialog(
|
||||||
|
dialog, frame,
|
||||||
|
amountCurrency = result.currency.ifBlank { info.currency },
|
||||||
|
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
toName = result.merchant.ifBlank { info.merchantName }
|
||||||
|
) {
|
||||||
|
fragment.clearForm()
|
||||||
|
host?.triggerRefresh()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs initiate + confirm on the calling (IO) thread and returns
|
||||||
|
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
|
||||||
|
*/
|
||||||
|
fun doTransfer(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>
|
||||||
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||||
|
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
?: return Triple(false, "BML contact not found for this account", null)
|
||||||
|
val (transferType, creditAccount, bank) = routed
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
// 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) }
|
||||||
|
|
||||||
|
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||||
|
|
||||||
|
// Step 2: confirm with fresh OTP
|
||||||
|
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) } ?: otp
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||||
|
if (result.success) {
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(amount),
|
||||||
|
currency = currency,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = destDisplay.ifBlank { destAccount },
|
||||||
|
toAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
remarks = remarks,
|
||||||
|
bmlFromName = src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
val time = result.timestamp.take(19).replace("T", " ")
|
||||||
|
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Business profile OTP flow ───────────────────────────────────────────
|
||||||
|
|
||||||
|
fun startBusinessOtpFlow(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>,
|
||||||
|
toAvatar: Bitmap?
|
||||||
|
) {
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
if (routed == null) {
|
||||||
|
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (transferType, creditAccount, bank) = routed
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
pendingTransfer = PendingTransfer(
|
||||||
|
src = src,
|
||||||
|
debitAccount = debitAccount,
|
||||||
|
creditAccount = creditAccount,
|
||||||
|
amount = amount,
|
||||||
|
amountStr = amountStr,
|
||||||
|
remarks = remarks,
|
||||||
|
transferType = transferType,
|
||||||
|
currency = currency,
|
||||||
|
bank = bank,
|
||||||
|
destDisplay = destDisplay,
|
||||||
|
destAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
toAvatar = toAvatar
|
||||||
|
)
|
||||||
|
|
||||||
|
otpState = OtpState.SELECTING_CHANNEL
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val sess = sessionFor(src)
|
||||||
|
val channels = if (sess != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||||
|
catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
} else emptyList<BmlOtpChannel>()
|
||||||
|
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (channels.isEmpty()) {
|
||||||
|
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
showChannelSelection(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
binding.containerBmlChannels.removeAllViews()
|
||||||
|
|
||||||
|
for (channel in channels) {
|
||||||
|
val iconRes = when (channel.channel) {
|
||||||
|
"email" -> R.drawable.ic_channel_email
|
||||||
|
"mobile" -> R.drawable.ic_channel_sms
|
||||||
|
else -> R.drawable.ic_channel_sms
|
||||||
|
}
|
||||||
|
val iconSize = (24 * dp).toInt()
|
||||||
|
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||||
|
marginStart = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.description
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.masked
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = (16 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||||
|
setPadding(hp, vp, hp, vp)
|
||||||
|
}
|
||||||
|
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
|
||||||
|
LinearLayout.LayoutParams(iconSize, iconSize))
|
||||||
|
row.addView(textCol)
|
||||||
|
row.setOnClickListener { selectOtpChannel(channel) }
|
||||||
|
binding.containerBmlChannels.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
disableTransferFields()
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectOtpChannel(channel: BmlOtpChannel) {
|
||||||
|
otpChannel = channel.channel
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
|
||||||
|
val pending = pendingTransfer ?: return
|
||||||
|
val sess = sessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val initiated = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
BmlTransferClient().initiateTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
pending.bank, channel.channel
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (!initiated) {
|
||||||
|
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
otpState = OtpState.AWAITING_OTP
|
||||||
|
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
|
||||||
|
binding.tilBmlOtp.visibility = View.VISIBLE
|
||||||
|
binding.etBmlOtp.requestFocus()
|
||||||
|
binding.btnTransfer.text = ctx.getString(R.string.transfer_verify_payment)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
|
||||||
|
fun verifyOtp() {
|
||||||
|
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||||
|
if (otp.isEmpty()) {
|
||||||
|
binding.tilBmlOtp.error = "Enter the verification code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
val pending = pendingTransfer ?: return
|
||||||
|
val channel = otpChannel ?: return
|
||||||
|
val sess = sessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
val capturedToAvatar = pending.toAvatar
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
otp, pending.remarks, pending.bank, channel
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val r = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(pending.amount),
|
||||||
|
currency = pending.currency,
|
||||||
|
fromLabel = pending.src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
|
||||||
|
toAccount = pending.destAccount,
|
||||||
|
toBank = pending.toBank,
|
||||||
|
remarks = pending.remarks,
|
||||||
|
bmlFromName = pending.src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
Triple(true, "", r)
|
||||||
|
} else {
|
||||||
|
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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
resetOtpState()
|
||||||
|
onTransferSuccess(receipt, capturedToAvatar)
|
||||||
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
if (msg == "CONNECTIVITY") {
|
||||||
|
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
|
||||||
|
} else {
|
||||||
|
binding.tilBmlOtp.error = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
|
||||||
|
fun resetOtpState() {
|
||||||
|
otpState = OtpState.NONE
|
||||||
|
otpChannel = null
|
||||||
|
pendingTransfer = null
|
||||||
|
if (fragment.view == null) return
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.GONE
|
||||||
|
binding.tilBmlOtp.visibility = View.GONE
|
||||||
|
binding.etBmlOtp.setText("")
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
enableTransferFields()
|
||||||
|
binding.btnTransfer.text = ctx.getString(R.string.transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.cardFromInfo.alpha = 0.5f
|
||||||
|
binding.btnClearFromInfo.isEnabled = false
|
||||||
|
binding.cardToInfo.alpha = 0.5f
|
||||||
|
binding.btnClearToInfo.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.cardFromInfo.alpha = 1f
|
||||||
|
binding.btnClearFromInfo.isEnabled = true
|
||||||
|
binding.cardToInfo.alpha = 1f
|
||||||
|
binding.btnClearToInfo.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared routing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the BML transfer type and the credit-account identifier the API expects.
|
||||||
|
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
|
||||||
|
* callers report as "BML contact not found".
|
||||||
|
*/
|
||||||
|
private fun routeTransfer(
|
||||||
|
destAccount: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>
|
||||||
|
): Triple<String, String, String?>? {
|
||||||
|
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
|
||||||
|
return when {
|
||||||
|
isSrcCard -> {
|
||||||
|
// CAD: card → own BML account
|
||||||
|
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||||
|
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
|
||||||
|
}
|
||||||
|
isDestMyCard -> {
|
||||||
|
// CPA: BML CASA → own card top-up
|
||||||
|
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
|
||||||
|
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
|
||||||
|
}
|
||||||
|
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||||
|
isDestMib -> {
|
||||||
|
// USD DOT: requires BML contact numeric ID
|
||||||
|
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||||
|
?: return null
|
||||||
|
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
|
||||||
|
}
|
||||||
|
else -> Triple("IAT", destAccount, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCard(account: BankAccount) =
|
||||||
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||||
|
* a given number; [label] names it in the recipient card, [destinationLabel] in the confirm
|
||||||
|
* dialog's "To" block.
|
||||||
|
*
|
||||||
|
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
|
||||||
|
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
|
||||||
|
* exhaustive `when`s over this enum will point at every site that needs updating.
|
||||||
|
*/
|
||||||
|
enum class FahipayService(val label: String, val destinationLabel: String) {
|
||||||
|
RAASTAS("Raastas", "Ooredoo · Raastas"),
|
||||||
|
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
|
||||||
|
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
|
||||||
|
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
|
||||||
|
* number into a set of payable services, the chip picker shown when more than one applies, and
|
||||||
|
* the selected service that the confirm dialog labels the destination with.
|
||||||
|
*
|
||||||
|
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
|
||||||
|
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
|
||||||
|
*
|
||||||
|
* **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
|
||||||
|
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
|
||||||
|
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
|
||||||
|
* other handlers.
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
|
||||||
|
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
|
||||||
|
*/
|
||||||
|
class FahipayTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
|
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||||
|
var service: FahipayService?
|
||||||
|
get() = viewModel.transferDraft.fahipayService
|
||||||
|
private set(value) { viewModel.transferDraft.fahipayService = value }
|
||||||
|
|
||||||
|
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||||
|
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
|
||||||
|
* else is rejected inline on the "To" field.
|
||||||
|
*/
|
||||||
|
fun lookupRecipient(rawInput: String) {
|
||||||
|
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
|
||||||
|
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lookupCarrier(rawInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the selected service and hides the chip picker. */
|
||||||
|
fun clearState() {
|
||||||
|
service = null
|
||||||
|
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private data class CarrierResult(
|
||||||
|
val dhiraagu: DhiraaguClient.Result,
|
||||||
|
val ooredoo: OoredooClient.CustType
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun lookupCarrier(number: String) {
|
||||||
|
fragment.startLookupLoading()
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) { queryCarriers(number) }
|
||||||
|
fragment.stopLookupLoading()
|
||||||
|
|
||||||
|
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||||
|
val services = servicesFor(result)
|
||||||
|
|
||||||
|
if (services.isEmpty()) return@launch
|
||||||
|
|
||||||
|
// Only one option — auto-select, no chip UI needed
|
||||||
|
if (services.size == 1) {
|
||||||
|
selectService(services[0], number, dhiraaguName)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple options (Ooredoo HYBRID) — show chips
|
||||||
|
showServiceChips(services, number, dhiraaguName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||||
|
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||||
|
*/
|
||||||
|
private fun queryCarriers(number: String): CarrierResult =
|
||||||
|
if (number.startsWith("7")) {
|
||||||
|
// Dhiraagu first, fall back to Ooredoo
|
||||||
|
val d = dhiraagu(number)
|
||||||
|
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||||
|
else OoredooClient.CustType.UNSUPPORTED
|
||||||
|
CarrierResult(d, o)
|
||||||
|
} else {
|
||||||
|
// Ooredoo first, fall back to Dhiraagu
|
||||||
|
val o = ooredoo(number)
|
||||||
|
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||||
|
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||||
|
CarrierResult(d, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dhiraagu(number: String) =
|
||||||
|
try { DhiraaguClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||||
|
|
||||||
|
private fun ooredoo(number: String) =
|
||||||
|
try { OoredooClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||||
|
|
||||||
|
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service picker ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun showServiceChips(
|
||||||
|
services: List<FahipayService>,
|
||||||
|
number: String,
|
||||||
|
dhiraaguName: String?
|
||||||
|
) {
|
||||||
|
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
|
||||||
|
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
|
||||||
|
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
|
||||||
|
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
|
||||||
|
binding.layoutServiceSelector.visibility = View.VISIBLE
|
||||||
|
binding.chipGroupService.clearCheck()
|
||||||
|
|
||||||
|
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
|
||||||
|
// resolve their display name from saved contacts instead.
|
||||||
|
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
|
||||||
|
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
|
||||||
|
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
|
||||||
|
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindChip(
|
||||||
|
chip: com.google.android.material.chip.Chip,
|
||||||
|
picked: FahipayService,
|
||||||
|
number: String,
|
||||||
|
ownerName: String?
|
||||||
|
) {
|
||||||
|
chip.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) {
|
||||||
|
selectService(picked, number, ownerName)
|
||||||
|
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
|
||||||
|
service = picked
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
val displayName = ownerName
|
||||||
|
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||||
|
?: number
|
||||||
|
fragment.prefillToDirectly(
|
||||||
|
accountNumber = number,
|
||||||
|
displayName = displayName,
|
||||||
|
subtitle = "${picked.label} · $number",
|
||||||
|
colorHex = "#FF6B00",
|
||||||
|
imageHash = null
|
||||||
|
)
|
||||||
|
fragment.focusAmount()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import android.view.View
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -22,6 +23,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||||
@@ -37,16 +39,25 @@ import sh.sar.basedbank.util.RecentsCache
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||||
* initiate-with-OTP, and confirm-with-OTP. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment]
|
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||||
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source.
|
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||||
|
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||||
|
* an M-Faisa merchant QR is loaded.
|
||||||
|
*
|
||||||
|
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||||
|
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||||
*
|
*
|
||||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||||
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||||
*/
|
*/
|
||||||
class MfaisaTransferHandler(
|
class MfaisaTransferHandler(
|
||||||
private val fragment: Fragment,
|
private val fragment: TransferFragment,
|
||||||
private val binding: FragmentTransferBinding,
|
private val binding: FragmentTransferBinding,
|
||||||
private val viewModel: HomeViewModel,
|
private val viewModel: HomeViewModel,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||||
private val onRecipientChanged: () -> Unit,
|
private val onRecipientChanged: () -> Unit,
|
||||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||||
@@ -55,13 +66,31 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
|
// Resolved state lives in the draft so it outlives this handler (dropped with the view).
|
||||||
|
private val draft get() = viewModel.transferDraft
|
||||||
|
|
||||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||||
var recipient: MfaisaTransferClient.Recipient? = null
|
var recipient: MfaisaTransferClient.Recipient?
|
||||||
private set
|
get() = draft.mfaisaRecipient
|
||||||
|
private set(value) { draft.mfaisaRecipient = value }
|
||||||
|
|
||||||
|
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||||
|
var qrInfo: MfaisaQrPayClient.QrMerchant?
|
||||||
|
get() = draft.mfaisaQrInfo
|
||||||
|
private set(value) { draft.mfaisaQrInfo = value }
|
||||||
|
|
||||||
private var lookupInFlight = false
|
private var lookupInFlight = false
|
||||||
|
|
||||||
|
/** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */
|
||||||
|
private var transferGuard: HomeActivity.PaymentGuard? = null
|
||||||
|
|
||||||
|
private fun endTransferFlow() {
|
||||||
|
transferGuard?.end()
|
||||||
|
transferGuard = null
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||||
@@ -80,7 +109,7 @@ class MfaisaTransferHandler(
|
|||||||
}
|
}
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
|
|
||||||
val source = currentSource() ?: return
|
val source = mfaisaSource() ?: return
|
||||||
val session = app.mfaisaSessionFor(source) ?: run {
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -105,6 +134,7 @@ class MfaisaTransferHandler(
|
|||||||
} else {
|
} else {
|
||||||
recipient = result
|
recipient = result
|
||||||
showResolvedRecipient(result)
|
showResolvedRecipient(result)
|
||||||
|
fragment.focusAmount()
|
||||||
}
|
}
|
||||||
} catch (_: MfaisaRecipientNotFoundException) {
|
} catch (_: MfaisaRecipientNotFoundException) {
|
||||||
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||||
@@ -122,7 +152,7 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val source = currentSource() ?: return
|
val source = mfaisaSource() ?: return
|
||||||
val r = recipient ?: run {
|
val r = recipient ?: run {
|
||||||
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -137,6 +167,8 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
binding.btnTransfer.isEnabled = false
|
binding.btnTransfer.isEnabled = false
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
endTransferFlow()
|
||||||
|
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val refId = try {
|
val refId = try {
|
||||||
@@ -144,6 +176,7 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
showError(e)
|
showError(e)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -153,22 +186,245 @@ class MfaisaTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||||
|
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||||
|
|
||||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||||
fun clearState() {
|
fun clearState() {
|
||||||
recipient = null
|
recipient = null
|
||||||
lookupInFlight = false
|
lookupInFlight = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
|
fun clearQrMerchant() {
|
||||||
|
if (qrInfo == null) return
|
||||||
|
qrInfo = null
|
||||||
|
fragment.setAmountLocked(false)
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||||
|
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||||
|
* the amount if the QR is dynamic.
|
||||||
|
*/
|
||||||
|
fun lookupQrMerchant(qrCodeId: String) {
|
||||||
|
val source = mfaisaSource() ?: run {
|
||||||
|
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||||
|
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||||
|
|
||||||
|
// The To row stays up with a spinner until there is a merchant to swap in
|
||||||
|
fragment.startLookupLoading()
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val merchant = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: return@withContext null
|
||||||
|
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
fragment.stopLookupLoading()
|
||||||
|
if (merchant == null) {
|
||||||
|
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
qrInfo = merchant
|
||||||
|
|
||||||
|
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||||
|
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||||
|
if (merchant.txnAmount.isNullOrBlank()) {
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||||
|
displayName = merchant.merchantName,
|
||||||
|
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||||
|
colorHex = "#ED1C24",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false,
|
||||||
|
bank = "MFAISA"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
showQrMerchant(merchant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a
|
||||||
|
* recreated view restores it — no network involved.
|
||||||
|
*/
|
||||||
|
fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) {
|
||||||
|
// Clear button is the only way to back out
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
binding.tvToAccountName.text = merchant.merchantName
|
||||||
|
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill + lock amount if the QR is dynamic
|
||||||
|
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||||
|
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||||
|
fragment.setAmountLocked(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onRecipientChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||||
|
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||||
|
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||||
|
*/
|
||||||
|
fun submitQrPayment() {
|
||||||
|
val merchant = qrInfo ?: return
|
||||||
|
val src = currentSource() ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (src.bank != "MFAISA") {
|
||||||
|
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", 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 remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||||
|
val amountValue = "%.2f".format(amount)
|
||||||
|
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = merchant.currencyCode,
|
||||||
|
amountValue = amountValue,
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = "M-Faisa",
|
||||||
|
toName = merchant.merchantName,
|
||||||
|
toNumber = merchant.merchantMsisdn,
|
||||||
|
toDetail = "Ooredoo M-Faisa merchant"
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
merchant: MfaisaQrPayClient.QrMerchant,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
dialog: AlertDialog
|
||||||
|
) {
|
||||||
|
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||||
|
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||||
|
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||||
|
val sourceMdn = "960${initialSession.msisdn}"
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val outcome = withContext(Dispatchers.IO) {
|
||||||
|
val client = MfaisaQrPayClient()
|
||||||
|
try {
|
||||||
|
val refId = try {
|
||||||
|
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||||
|
}
|
||||||
|
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||||
|
try {
|
||||||
|
client.confirmPurchase(confirmSession, refId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.confirmPurchase(fresh, refId)
|
||||||
|
}
|
||||||
|
Result.success(refId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure<String>(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
|
||||||
|
outcome.fold(
|
||||||
|
onSuccess = { _ ->
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MFAISA",
|
||||||
|
amount = amountStr,
|
||||||
|
currency = merchant.currencyCode,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#ED1C24",
|
||||||
|
toLabel = merchant.merchantName,
|
||||||
|
toAccount = merchant.merchantMsisdn,
|
||||||
|
toBank = "Ooredoo M-Faisa",
|
||||||
|
remarks = remarks,
|
||||||
|
mfaisaTransactionType = "Merchant payment",
|
||||||
|
mfaisaFromName = src.accountBriefName,
|
||||||
|
mfaisaFromMsisdn = src.accountNumber,
|
||||||
|
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||||
|
mfaisaTimestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
dialog.dismiss()
|
||||||
|
onTransferSuccess(receipt, null)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
showError(e, fallback = "Payment failed")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Internal ────────────────────────────────────────────────────────────
|
// ─── Internal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun currentSource(): BankAccount? =
|
/**
|
||||||
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() }
|
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable
|
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||||
|
* the source away from another bank.
|
||||||
|
*/
|
||||||
|
private fun mfaisaSource(): BankAccount? =
|
||||||
|
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||||
|
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||||
|
|
||||||
private fun sourceAccountNumberFromCard(): String =
|
/** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */
|
||||||
binding.tvFromAccountNumber.text?.toString().orEmpty()
|
fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) {
|
||||||
|
|
||||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
|
||||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||||
// card view, so we just populate its text fields and toggle visibility.
|
// card view, so we just populate its text fields and toggle visibility.
|
||||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||||
@@ -184,7 +440,7 @@ class MfaisaTransferHandler(
|
|||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
RecentsCache.save(ctx, RecentPick(
|
if (saveRecent) RecentsCache.save(ctx, RecentPick(
|
||||||
accountNumber = r.msisdn,
|
accountNumber = r.msisdn,
|
||||||
displayName = r.name.ifBlank { r.msisdn },
|
displayName = r.name.ifBlank { r.msisdn },
|
||||||
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||||
@@ -241,8 +497,7 @@ class MfaisaTransferHandler(
|
|||||||
refId: String,
|
refId: String,
|
||||||
errorMsg: String?
|
errorMsg: String?
|
||||||
) {
|
) {
|
||||||
val tf = fragment as? TransferFragment ?: return
|
val view = fragment.view ?: run { endTransferFlow(); return }
|
||||||
val view = fragment.view ?: return
|
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val colorMuted = MaterialColors.getColor(
|
val colorMuted = MaterialColors.getColor(
|
||||||
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||||
@@ -250,7 +505,7 @@ class MfaisaTransferHandler(
|
|||||||
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||||
|
|
||||||
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||||
val confirmView = tf.buildTransferConfirmView(
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
amountCurrency = "MVR",
|
amountCurrency = "MVR",
|
||||||
amountValue = amountValue,
|
amountValue = amountValue,
|
||||||
fromName = source.accountBriefName,
|
fromName = source.accountBriefName,
|
||||||
@@ -331,6 +586,7 @@ class MfaisaTransferHandler(
|
|||||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||||
d.dismiss()
|
d.dismiss()
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
.show()
|
.show()
|
||||||
@@ -355,6 +611,7 @@ class MfaisaTransferHandler(
|
|||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
endTransferFlow()
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
bank = "MFAISA",
|
bank = "MFAISA",
|
||||||
amount = amountValue,
|
amount = amountValue,
|
||||||
@@ -379,6 +636,7 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
showError(e)
|
showError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,11 +673,11 @@ class MfaisaTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showError(e: Exception) {
|
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||||
val msg = when {
|
val msg = when {
|
||||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||||
!e.message.isNullOrBlank() -> e.message!!
|
!e.message.isNullOrBlank() -> e.message!!
|
||||||
else -> "Transfer failed"
|
else -> fallback
|
||||||
}
|
}
|
||||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
|
import sh.sar.basedbank.api.mib.MibLookupException
|
||||||
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
|
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
|
||||||
|
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
|
||||||
|
* avatar API that every bank's "To" card borrows.
|
||||||
|
*
|
||||||
|
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
|
||||||
|
* paints nothing — every method is either a session accessor or a blocking API call meant for
|
||||||
|
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
|
||||||
|
* the view updates, so this handler can outlive a view recreation.
|
||||||
|
*/
|
||||||
|
class MibTransferHandler(
|
||||||
|
private val fragment: Fragment,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
|
/** The selected source's MIB session, falling back to any logged-in MIB session. */
|
||||||
|
val session: MibSession?
|
||||||
|
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
|
||||||
|
|
||||||
|
/** Any logged-in MIB session, ignoring which source is selected. */
|
||||||
|
val anySession: MibSession? get() = app.anyMibSession()
|
||||||
|
|
||||||
|
// ─── Destination lookup ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Either a resolved destination or the message to show the user — never both. */
|
||||||
|
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPS lookup for a destination account/alias. Blocking — call from IO.
|
||||||
|
* A missing MIB session is reported as "account not found", same as a failed lookup.
|
||||||
|
*/
|
||||||
|
fun lookupDestination(accountNumber: String): LookupOutcome {
|
||||||
|
val sess = session ?: return LookupOutcome(null, notFound())
|
||||||
|
return try {
|
||||||
|
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
|
||||||
|
} catch (e: MibLookupException) {
|
||||||
|
LookupOutcome(null, e.message)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
LookupOutcome(null, notFound())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint
|
||||||
|
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
|
||||||
|
*/
|
||||||
|
fun lookupCurrency(accountNumber: String): String {
|
||||||
|
val sess = session ?: return ""
|
||||||
|
return try { MibTransferClient().lookup(sess, accountNumber).currency }
|
||||||
|
catch (_: Exception) { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
|
||||||
|
|
||||||
|
// ─── Avatars ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
|
||||||
|
* Blocking — call from IO. Returns null on any failure, including a missing image.
|
||||||
|
*
|
||||||
|
* The session is passed in rather than read from [session] because callers differ: the "To"
|
||||||
|
* card uses the selected source's session, the account dropdown uses [anySession].
|
||||||
|
*/
|
||||||
|
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
|
||||||
|
val base64 = if (isProfile) {
|
||||||
|
app.anyMibFlow()?.fetchProfileImage(session, hash)
|
||||||
|
} else {
|
||||||
|
MibContactsClient().fetchProfileImageBase64(session, hash)
|
||||||
|
}
|
||||||
|
if (base64 == null) null else {
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
// ─── Transfer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
|
||||||
|
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
|
||||||
|
* "CONNECTIVITY" when the network was unreachable.
|
||||||
|
*/
|
||||||
|
fun doTransfer(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destName: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: String,
|
||||||
|
remarks: String,
|
||||||
|
bankName: String
|
||||||
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
|
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||||
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
|
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
|
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||||
|
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
||||||
|
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||||
|
val bankNo = if (isDestMib) 2 else 3
|
||||||
|
val toBank = when {
|
||||||
|
isDestMib -> "MIB"
|
||||||
|
else -> when (bankName.uppercase()) {
|
||||||
|
"MALBMVMV" -> "BML"
|
||||||
|
"MADVMVMV" -> "MIB"
|
||||||
|
else -> bankName.ifBlank { "LOCAL" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val isOwnAccount = isDestMib && app.mibAccounts.any {
|
||||||
|
it.accountNumber == destAccount && it.loginTag == src.loginTag &&
|
||||||
|
(src.profileId.isBlank() || it.profileId == src.profileId)
|
||||||
|
}
|
||||||
|
val transactionType = when {
|
||||||
|
isOwnAccount -> "Own Transfer"
|
||||||
|
isDestMib -> "MIB Transfer"
|
||||||
|
else -> "Quick Transfer"
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
// Switch to the profile that owns the source account
|
||||||
|
if (src.profileId.isNotBlank()) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
|
}
|
||||||
|
val result = MibTransferClient().transfer(
|
||||||
|
session = sess,
|
||||||
|
fromAccount = src.accountNumber,
|
||||||
|
toAccount = destAccount,
|
||||||
|
amount = amount,
|
||||||
|
currencyCode = currencyCode,
|
||||||
|
benefName = destName.ifBlank { "Recipient" },
|
||||||
|
bankNo = bankNo,
|
||||||
|
purpose = remarks,
|
||||||
|
otp = otp
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MIB",
|
||||||
|
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||||
|
currency = currency,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#FE860E",
|
||||||
|
fromProfileImageHash = src.profileImageHash,
|
||||||
|
toLabel = destDisplay.ifBlank { destName },
|
||||||
|
toAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
remarks = remarks,
|
||||||
|
mibReferenceNo = result.trxId,
|
||||||
|
mibTransactionDate = result.date,
|
||||||
|
mibFromProfileName = src.profileName,
|
||||||
|
mibTransactionType = transactionType
|
||||||
|
)
|
||||||
|
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the Transfer screen has filled in or resolved so far: source, recipient, form
|
||||||
|
* text and any loaded merchant QR.
|
||||||
|
*
|
||||||
|
* Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives
|
||||||
|
* both the view (switching tabs) and the fragment instance (a theme or language change
|
||||||
|
* recreates the activity) — the screen is repainted from here instead of re-running lookups.
|
||||||
|
* A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh
|
||||||
|
* draft.
|
||||||
|
*/
|
||||||
|
class TransferDraft {
|
||||||
|
var selectedAccount: BankAccount? = null
|
||||||
|
|
||||||
|
// Resolved recipient — set after a successful lookup or prefill
|
||||||
|
var resolvedAccountNumber = ""
|
||||||
|
var resolvedRecipientName = ""
|
||||||
|
var resolvedBankName = ""
|
||||||
|
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
||||||
|
var loadedToPhoto: Bitmap? = null
|
||||||
|
var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||||
|
var resolvedToOwnAccount: BankAccount? = null
|
||||||
|
var toSubtitle = ""
|
||||||
|
var toColorHex = "#607D8B"
|
||||||
|
var toImageHash: String? = null
|
||||||
|
|
||||||
|
// Form text, captured when the view goes away
|
||||||
|
var amount = ""
|
||||||
|
var remarks = ""
|
||||||
|
var toText = ""
|
||||||
|
|
||||||
|
// BML merchant QR
|
||||||
|
var bmlQrInfo: BmlQrPayInfo? = null
|
||||||
|
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
|
||||||
|
var bmlGatewayQr = false
|
||||||
|
/** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */
|
||||||
|
var pendingBmlQrTarget: String? = null
|
||||||
|
|
||||||
|
// M-Faisa
|
||||||
|
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
|
||||||
|
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||||
|
|
||||||
|
// Fahipay
|
||||||
|
var fahipayService: FahipayService? = null
|
||||||
|
}
|
||||||
@@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() {
|
|||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.cardOtp.setOnClickListener {
|
binding.cardOtp.root.setOnClickListener {
|
||||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
|
||||||
if (code.isNotEmpty()) {
|
if (code.isNotEmpty()) {
|
||||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
@@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() {
|
|||||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
val seed = resolveOtpSeed(otpSeedRaw)
|
val seed = resolveOtpSeed(otpSeedRaw)
|
||||||
if (seed.isEmpty()) {
|
if (seed.isEmpty()) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -211,12 +211,13 @@ class CredentialsFragment : Fragment() {
|
|||||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
|
|
||||||
binding.tvOtpCode.text = otp
|
binding.cardOtp.tvOtpCode.text = otp
|
||||||
binding.otpTimer.max = 30
|
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||||
binding.otpTimer.progress = remaining
|
binding.cardOtp.otpTimer.max = 30
|
||||||
binding.cardOtp.visibility = View.VISIBLE
|
binding.cardOtp.otpTimer.progress = remaining
|
||||||
|
binding.cardOtp.root.visibility = View.VISIBLE
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ data class ContactDisplay(
|
|||||||
val network: TransferNetwork,
|
val network: TransferNetwork,
|
||||||
val bankColor: String,
|
val bankColor: String,
|
||||||
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
||||||
|
val bankName: String?, // shown in the contact details sheet; null = hide row
|
||||||
|
val currency: String?, // shown in the contact details sheet; null = hide row
|
||||||
val imageHash: String?,
|
val imageHash: String?,
|
||||||
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
||||||
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
|
|||||||
|
|
||||||
class CredentialStore(context: Context) {
|
class CredentialStore(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
|
||||||
|
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
|
||||||
|
}
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||||
private val keyAlias = "basedbank_credential_key"
|
private val keyAlias = "basedbank_credential_key"
|
||||||
private val transformation = "AES/GCM/NoPadding"
|
private val transformation = "AES/GCM/NoPadding"
|
||||||
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
|
|||||||
data class FahipayCredentials(val idCard: String, val password: String)
|
data class FahipayCredentials(val idCard: String, val password: String)
|
||||||
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
||||||
|
|
||||||
|
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
|
||||||
|
|
||||||
|
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
|
||||||
|
fun getLoginOrder(): List<String> {
|
||||||
|
val all = getMibLoginIds().map { loginKey("mib", it) } +
|
||||||
|
getBmlLoginIds().map { loginKey("bml", it) } +
|
||||||
|
getFahipayLoginIds().map { loginKey("fahipay", it) } +
|
||||||
|
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
|
||||||
|
val saved = try {
|
||||||
|
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
return saved.filter { it in all } + all.filter { it !in saved }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
|
||||||
|
fun loginRank(): (String) -> Int {
|
||||||
|
val order = getLoginOrder()
|
||||||
|
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
|
||||||
|
fun setLoginOrder(keys: List<String>) {
|
||||||
|
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
|
||||||
|
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
|
||||||
|
setMibLoginIds(idsFor("mib"))
|
||||||
|
setBmlLoginIds(idsFor("bml"))
|
||||||
|
setFahipayLoginIds(idsFor("fahipay"))
|
||||||
|
setMfaisaLoginIds(idsFor("mfaisa"))
|
||||||
|
}
|
||||||
|
|
||||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
|
|
||||||
fun getMibLoginIds(): List<String> {
|
fun getMibLoginIds(): List<String> {
|
||||||
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setMibLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getMibLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addMibLoginId(loginId: String) {
|
private fun addMibLoginId(loginId: String) {
|
||||||
val ids = getMibLoginIds().toMutableList()
|
val ids = getMibLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -59,6 +100,11 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||||
|
fun updateMibOtpSeed(loginId: String, otpSeed: String) {
|
||||||
|
prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun loadMibCredentials(loginId: String): MibCredentials? {
|
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||||
@@ -160,6 +206,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setBmlLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getBmlLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addBmlLoginId(loginId: String) {
|
private fun addBmlLoginId(loginId: String) {
|
||||||
val ids = getBmlLoginIds().toMutableList()
|
val ids = getBmlLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -184,6 +235,11 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||||
|
fun updateBmlOtpSeed(loginId: String, otpSeed: String) {
|
||||||
|
prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||||
@@ -316,6 +372,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setFahipayLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getFahipayLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addFahipayLoginId(loginId: String) {
|
private fun addFahipayLoginId(loginId: String) {
|
||||||
val ids = getFahipayLoginIds().toMutableList()
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -473,6 +534,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setMfaisaLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getMfaisaLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addMfaisaLoginId(loginId: String) {
|
private fun addMfaisaLoginId(loginId: String) {
|
||||||
val ids = getMfaisaLoginIds().toMutableList()
|
val ids = getMfaisaLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -764,6 +830,15 @@ class CredentialStore(context: Context) {
|
|||||||
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
||||||
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
||||||
|
|
||||||
|
// ── Per-account visibility (account numbers are globally unique) ─────────
|
||||||
|
|
||||||
|
/** Returns the set of account numbers the user has chosen to hide, across all logins. */
|
||||||
|
fun getHiddenAccountNumbers(): Set<String> =
|
||||||
|
prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenAccountNumbers(accountNumbers: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply()
|
||||||
|
|
||||||
// ── Crypto primitives ─────────────────────────────────────────────────────
|
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun getOrCreateKey(): SecretKey {
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
|||||||
@@ -13,6 +13,27 @@ object OtpauthParser {
|
|||||||
else -> emptyList()
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw
|
||||||
|
* secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed.
|
||||||
|
*/
|
||||||
|
fun resolveSecret(input: String): String? {
|
||||||
|
val raw = input.trim()
|
||||||
|
val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw
|
||||||
|
val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase()
|
||||||
|
if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null
|
||||||
|
// Too short to be a real seed, e.g. a pasted 6-digit OTP code
|
||||||
|
if (clean.length < 8) return null
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shortest otpauth://totp link authenticator apps accept: just a label and the
|
||||||
|
* secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out.
|
||||||
|
*/
|
||||||
|
fun buildUri(label: String, secret: String): String =
|
||||||
|
"otpauth://totp/" + Uri.encode(label) + "?secret=$secret"
|
||||||
|
|
||||||
private fun parseStandard(raw: String): OtpEntry? {
|
private fun parseStandard(raw: String): OtpEntry? {
|
||||||
val uri = Uri.parse(raw)
|
val uri = Uri.parse(raw)
|
||||||
val secret = uri.getQueryParameter("secret") ?: return null
|
val secret = uri.getQueryParameter("secret") ?: return null
|
||||||
|
|||||||
@@ -9,22 +9,58 @@ data class PaymvQrData(
|
|||||||
|
|
||||||
object PaymvQrParser {
|
object PaymvQrParser {
|
||||||
|
|
||||||
|
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
|
||||||
|
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
|
||||||
|
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||||
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
*
|
||||||
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
* Three shapes exist:
|
||||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
* - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
|
||||||
|
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
|
||||||
|
* `35` → `20` → `01`;
|
||||||
|
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
|
||||||
|
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
|
||||||
|
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
|
||||||
|
*
|
||||||
|
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
|
||||||
|
* that follow it.
|
||||||
*/
|
*/
|
||||||
fun extractBmlGatewayUrl(raw: String): String? {
|
fun bmlQrPayTarget(raw: String): String? {
|
||||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||||
return try {
|
val ref = bmlQrReference(raw) ?: return null
|
||||||
val root = parseTlv(raw)
|
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||||
} catch (_: Exception) {
|
}
|
||||||
null
|
|
||||||
}
|
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
|
||||||
|
private fun supplementaryDomain(raw: String): String? = try {
|
||||||
|
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
|
||||||
|
private fun bmlQrReference(raw: String): String? = try {
|
||||||
|
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
|
||||||
|
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
|
||||||
|
inner?.get("01")?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
|
||||||
|
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
|
||||||
|
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
|
||||||
|
* "Unsupported payment link").
|
||||||
|
*/
|
||||||
|
fun bmlPayRequestKey(target: String): String {
|
||||||
|
if (target.startsWith("https://")) return target
|
||||||
|
return bmlQrReference(target) ?: target
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parse(raw: String): PaymvQrData? {
|
fun parse(raw: String): PaymvQrData? {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ object ReceiptStore {
|
|||||||
remarks = o.optString("remarks"),
|
remarks = o.optString("remarks"),
|
||||||
mibReferenceNo = o.optString("mibReferenceNo"),
|
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||||
mibTransactionDate = o.optString("mibTransactionDate"),
|
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||||
|
mibFromProfileName = o.optString("mibFromProfileName"),
|
||||||
|
mibTransactionType = o.optString("mibTransactionType"),
|
||||||
bmlFromName = o.optString("bmlFromName"),
|
bmlFromName = o.optString("bmlFromName"),
|
||||||
bmlReference = o.optString("bmlReference"),
|
bmlReference = o.optString("bmlReference"),
|
||||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||||
@@ -76,6 +78,8 @@ object ReceiptStore {
|
|||||||
put("remarks", d.remarks)
|
put("remarks", d.remarks)
|
||||||
put("mibReferenceNo", d.mibReferenceNo)
|
put("mibReferenceNo", d.mibReferenceNo)
|
||||||
put("mibTransactionDate", d.mibTransactionDate)
|
put("mibTransactionDate", d.mibTransactionDate)
|
||||||
|
put("mibFromProfileName", d.mibFromProfileName)
|
||||||
|
put("mibTransactionType", d.mibTransactionType)
|
||||||
put("bmlFromName", d.bmlFromName)
|
put("bmlFromName", d.bmlFromName)
|
||||||
put("bmlReference", d.bmlReference)
|
put("bmlReference", d.bmlReference)
|
||||||
put("bmlTimestamp", d.bmlTimestamp)
|
put("bmlTimestamp", d.bmlTimestamp)
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ object Totp {
|
|||||||
/**
|
/**
|
||||||
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
||||||
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
|
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
|
||||||
|
* [periodOffset] shifts the time window, e.g. 1 yields the next code.
|
||||||
*/
|
*/
|
||||||
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String {
|
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30, periodOffset: Long = 0): String {
|
||||||
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
|
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
|
||||||
val counter = System.currentTimeMillis() / 1000L / periodSeconds
|
val counter = System.currentTimeMillis() / 1000L / periodSeconds + periodOffset
|
||||||
val otp = hotp(key, counter, digits)
|
val otp = hotp(key, counter, digits)
|
||||||
return otp.toString().padStart(digits, '0')
|
return otp.toString().padStart(digits, '0')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ object BmlCardParser {
|
|||||||
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||||
"C8101" -> "cards/bml/master_masveriyaa.png"
|
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||||
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||||
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
"C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
|
||||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
"C8030" -> "cards/bml/master_business_debit.png"
|
||||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||||
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ object BmlContactParser {
|
|||||||
network = TransferNetwork.BML,
|
network = TransferNetwork.BML,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ object FahipayContactParser {
|
|||||||
network = TransferNetwork.FAHIPAY,
|
network = TransferNetwork.FAHIPAY,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = null, // Fahipay contacts show no detail line
|
detail = null, // Fahipay contacts show no detail line
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = contact.benefAccount,
|
transferSubtitle = contact.benefAccount,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object MibContactParser {
|
|||||||
network = network,
|
network = network,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Dark-mode replacement for drawable/bottom_receipt_wave.jpg (988x48):
|
||||||
|
receipt background above the teeth, footer colour below. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="329dp"
|
||||||
|
android:height="16dp"
|
||||||
|
android:viewportWidth="988"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/bml_receipt_bg"
|
||||||
|
android:pathData="M0,0 L988,0 L988,48 L0,48 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/bml_receipt_footer"
|
||||||
|
android:pathData="M0,48 L0,27 L24.08,8.28 Q30.88,3 37.67,8.28 L54.96,21.72 Q61.75,27 68.54,21.72 L85.83,8.28 Q92.63,3 99.42,8.28 L116.71,21.72 Q123.5,27 130.29,21.72 L147.58,8.28 Q154.38,3 161.17,8.28 L178.46,21.72 Q185.25,27 192.04,21.72 L209.33,8.28 Q216.13,3 222.92,8.28 L240.21,21.72 Q247,27 253.79,21.72 L271.08,8.28 Q277.88,3 284.67,8.28 L301.96,21.72 Q308.75,27 315.54,21.72 L332.83,8.28 Q339.63,3 346.42,8.28 L363.71,21.72 Q370.5,27 377.29,21.72 L394.58,8.28 Q401.38,3 408.17,8.28 L425.46,21.72 Q432.25,27 439.04,21.72 L456.33,8.28 Q463.13,3 469.92,8.28 L487.21,21.72 Q494,27 500.79,21.72 L518.08,8.28 Q524.88,3 531.67,8.28 L548.96,21.72 Q555.75,27 562.54,21.72 L579.83,8.28 Q586.63,3 593.42,8.28 L610.71,21.72 Q617.5,27 624.29,21.72 L641.58,8.28 Q648.38,3 655.17,8.28 L672.46,21.72 Q679.25,27 686.04,21.72 L703.33,8.28 Q710.13,3 716.92,8.28 L734.21,21.72 Q741,27 747.79,21.72 L765.08,8.28 Q771.88,3 778.67,8.28 L795.96,21.72 Q802.75,27 809.54,21.72 L826.83,8.28 Q833.63,3 840.42,8.28 L857.71,21.72 Q864.5,27 871.29,21.72 L888.58,8.28 Q895.38,3 902.17,8.28 L919.46,21.72 Q926.25,27 933.04,21.72 L950.33,8.28 Q957.13,3 963.92,8.28 L988,27 L988,48 Z" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<gradient
|
|
||||||
android:startColor="#2E7D32"
|
|
||||||
android:centerColor="#43A047"
|
|
||||||
android:endColor="#66BB6A"
|
|
||||||
android:angle="315"
|
|
||||||
android:type="linear" />
|
|
||||||
</shape>
|
|
||||||
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<?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:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M15,4 L7,12 L15,20" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?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="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.61 -4.7,-4.2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- MIB full-screen receipt close button: tinted-black disc, themed ring, white X -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:fillColor="#4D000000"
|
||||||
|
android:strokeColor="@color/mib_receipt_close_ring"
|
||||||
|
android:strokeWidth="1.5"
|
||||||
|
android:pathData="M16,1.25 A14.75,14.75 0 1,1 16,30.75 A14.75,14.75 0 1,1 16,1.25 Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M11,11 L21,21 M21,11 L11,21" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?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="M3,11h8V3H3V11zM5,5h4v4H5V5zM3,21h8v-8H3V21zM5,15h4v4H5V15zM13,3v8h8V3H13zM19,9h-4V5h4V9zM19,19h2v2h-2V19zM13,13h2v2h-2V13zM15,15h2v2h-2V15zM13,17h2v2h-2V17zM15,19h2v2h-2V19zM17,17h2v2h-2V17zM17,13h2v2h-2V13zM19,15h2v2h-2V15z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?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="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Full MIB logo (mark + "MALDIVES ISLAMIC BANK" wordmark); wordmark follows the receipt footer text color -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="187.6dp"
|
||||||
|
android:height="22dp"
|
||||||
|
android:viewportWidth="779.5"
|
||||||
|
android:viewportHeight="91.4">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#A8AAAC"
|
||||||
|
android:strokeWidth="3.8833"
|
||||||
|
android:strokeMiterLimit="10"
|
||||||
|
android:pathData="M64.6,89.2H26.7c-13.5,0-24.5-11-24.5-24.5v-38c0-13.5,11-24.5,24.5-24.5h37.9c13.5,0,24.5,11,24.5,24.5v37.9 C89.1,78.2,78.2,89.2,64.6,89.2z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E2859"
|
||||||
|
android:pathData="M49.4,28.7c1.7-1.7,3.5-3.3,5.4-4.8c1.9,1.5,3.7,3.1,5.4,4.8c9,9.2,14.4,21.7,14.4,35.3c0,0.7,0,1.5-0.1,2.2 H57.6l10.9-6.8c-0.5-6-2.4-11.8-5.6-17.2c-2.2-3.7-5-7-8.2-9.7c-3.2,2.7-5.9,6-8.2,9.7c-3.2,5.3-5,11.2-5.6,17.2l4.6,6.8H35 c0-0.7-0.1-1.5-0.1-2.2C34.9,50.3,40.4,37.9,49.4,28.7z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#006A4D"
|
||||||
|
android:pathData="M41.9,28.7c-1.7-1.7-3.5-3.3-5.4-4.8c-1.9,1.5-3.7,3.1-5.4,4.8c-9,9.2-14.4,21.7-14.4,35.3 c0,0.7,0,1.5,0.1,2.2h16.9l-10.9-6.8c0.5-6,2.4-11.8,5.6-17.2c2.2-3.7,5-7,8.2-9.7c3.2,2.7,5.9,6,8.2,9.7c3.2,5.3,5,11.2,5.6,17.2 l-4.6,6.8h10.6c0-0.7,0.1-1.5,0.1-2.2C56.3,50.3,50.8,37.9,41.9,28.7z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M146.4,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L146.4,49.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M170,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8L183,54.6H170z M181.7,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L171.2,49H181.7z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M200.5,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M230.5,25.8c3.3-0.5,7.5-0.9,11.9-0.9c7.7,0,13,1.6,16.7,4.7c4,3.2,6.4,8.1,6.4,15.1c0,7.3-2.5,12.8-6.4,16.3 c-4.1,3.7-10.6,5.6-18.6,5.6c-4.4,0-7.7-0.2-10.1-0.5V25.8z M237.9,60.5c1,0.2,2.6,0.2,4.1,0.2c9.7,0.1,15.5-5.3,15.5-15.7 c0.1-9.1-5.2-14.2-14.5-14.2c-2.4,0-4.1,0.2-5.1,0.4V60.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M279.5,25.2v41H272v-41H279.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M297.4,66.2l-13.3-41h8.2l5.6,18.6c1.6,5.2,2.9,10,4,15h0.1c1.1-4.9,2.6-9.9,4.2-14.8l6-18.7h8l-14.2,41 H297.4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M347.6,48.1h-15.5v12h17.3v6.1h-24.8v-41h23.8v6.1h-16.4V42h15.5V48.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M355.8,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L355.8,58.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M407.6,25.2v41h-7.5v-41H407.6z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M416,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L416,58.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M447.8,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M486.2,54.6l-3.5,11.6H475l13.1-41h9.6l13.3,41h-8l-3.7-11.6H486.2z M498,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L487.4,49H498z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M551.1,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L551.1,49.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M574.3,25.2v41h-7.5v-41H574.3z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M612.3,65c-1.8,0.9-5.7,1.8-10.6,1.8c-13,0-20.9-8.2-20.9-20.6c0-13.5,9.4-21.7,21.9-21.7c4.9,0,8.5,1,10,1.8 l-1.6,6c-1.9-0.9-4.6-1.6-8-1.6c-8.3,0-14.4,5.2-14.4,15.1c0,9,5.3,14.8,14.3,14.8c3,0,6.2-0.6,8.2-1.5L612.3,65z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M631.3,25.8c2.4-0.5,6.7-0.9,10.9-0.9c5.5,0,8.9,0.7,11.7,2.6c2.6,1.5,4.3,4.2,4.3,7.7c0,3.8-2.4,7.2-6.8,8.9 v0.1c4.3,1.1,8.3,4.5,8.3,10.2c0,3.7-1.6,6.5-4,8.5c-2.9,2.6-7.7,3.8-15.2,3.8c-4.1,0-7.3-0.3-9.2-0.5V25.8z M638.7,42h3.8 c5.2,0,8.1-2.4,8.1-5.9c0-3.8-2.9-5.6-7.7-5.6c-2.2,0-3.5,0.1-4.3,0.3V42z M638.7,60.8c1,0.1,2.3,0.2,4,0.2c4.8,0,9.1-1.8,9.1-6.9 c0-4.7-4.1-6.7-9.3-6.7h-3.7V60.8z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M674.3,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8l-3.7-11.6H674.3z M686.1,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L675.5,49H686.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M704.8,66.2v-41h8.5l10.6,17.6c2.7,4.6,5.1,9.3,7,13.7h0.1c-0.5-5.5-0.7-10.8-0.7-17V25.2h6.9v41h-7.7 l-10.7-18c-2.6-4.5-5.4-9.6-7.4-14.2l-0.2,0.1c0.3,5.3,0.4,10.7,0.4,17.5v14.7H704.8z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M745.9,25.2h7.4v18.9h0.2c1-1.6,2-3,3-4.4l10.7-14.4h9.2l-14.1,17.5l15,23.5h-8.8L757,47.5l-3.7,4.4v14.4 h-7.4V25.2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- MIB receipt header: solid green under the official app's translucent palm texture -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/mib_receipt_amount" />
|
||||||
|
<item>
|
||||||
|
<bitmap
|
||||||
|
android:src="@drawable/mib_receipt_texture"
|
||||||
|
android:gravity="fill" />
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 196 KiB |
@@ -48,6 +48,7 @@
|
|||||||
android:id="@+id/languageToggle"
|
android:id="@+id/languageToggle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:baselineAligned="false"
|
||||||
app:singleSelection="true"
|
app:singleSelection="true"
|
||||||
app:selectionRequired="true">
|
app:selectionRequired="true">
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
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="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingTop="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:text="Scan this with your authenticator app, or copy the seed. Please keep it private, since anyone who has it can generate your OTP codes."
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<!-- Always black-on-white so scanners read it in dark mode too -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardBackgroundColor="@android:color/white"
|
||||||
|
app:cardCornerRadius="16dp"
|
||||||
|
app:strokeWidth="0dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivSeedQr"
|
||||||
|
android:layout_width="220dp"
|
||||||
|
android:layout_height="220dp"
|
||||||
|
android:layout_margin="12dp"
|
||||||
|
android:contentDescription="OTP seed QR code" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSeed"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="true" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnCopySeed"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Copy seed"
|
||||||
|
app:icon="@drawable/ic_copy" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
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="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingTop="8dp">
|
||||||
|
|
||||||
|
<!-- Warning: the old seed is overwritten -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
style="@style/Widget.Material3.CardView.Filled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_info"
|
||||||
|
app:tint="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSeedWarning"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilNewSeed"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="New OTP seed"
|
||||||
|
app:helperText="Base32 secret or otpauth:// link">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etNewSeed"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:inputType="textNoSuggestions|textVisiblePassword"
|
||||||
|
android:singleLine="true" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnScanNewSeed"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:contentDescription="@string/scan_otp_qr"
|
||||||
|
android:tooltipText="@string/scan_otp_qr"
|
||||||
|
app:icon="@drawable/ic_qr_scan" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Live preview of the new seed's code, same card as the sign-in screen -->
|
||||||
|
<include
|
||||||
|
android:id="@+id/cardOtp"
|
||||||
|
layout="@layout/view_otp_preview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Full-screen BML receipt, modelled on docs/bmlapi/tmp/recipt_full_*.jpg.
|
||||||
|
The receipt card is inserted at the top of cardHolder, directly followed by
|
||||||
|
the Save/Share buttons; the rest of the screen shows the footer colour. -->
|
||||||
|
<LinearLayout
|
||||||
|
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:orientation="vertical"
|
||||||
|
android:background="@color/bml_receipt_footer">
|
||||||
|
|
||||||
|
<!-- Top bar — status bar inset is added as top padding in code -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/topBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/bml_receipt_bg">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnBack"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_gravity="start|center_vertical"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_chevron_back"
|
||||||
|
app:tint="@color/bml_receipt_amount"
|
||||||
|
android:contentDescription="Back" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:text="Transfer successful"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="@color/bml_receipt_amount"
|
||||||
|
android:fontFamily="@font/sofia_pro" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/scroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardHolder"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- receipt card goes here (index 0) -->
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/bml_receipt_bottom_divider" />
|
||||||
|
|
||||||
|
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/bottomBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@color/bml_receipt_footer"
|
||||||
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSaveFull"
|
||||||
|
style="@style/Widget.Material3.Button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Save receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:fontFamily="@font/sofia_pro"
|
||||||
|
app:backgroundTint="@color/bml_red"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnShareFull"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Share receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="@color/bml_receipt_message"
|
||||||
|
android:fontFamily="@font/sofia_pro"
|
||||||
|
app:rippleColor="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Full-screen MIB receipt. The receipt card is inserted into cardHolder and its green
|
||||||
|
header is extended under the status bar in code; the close button floats over the
|
||||||
|
header just below the status bar. Share/Save sit pinned at the bottom of the screen. -->
|
||||||
|
<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="@color/mib_receipt_bg">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/scroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/cardHolder"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/mib_receipt_divider" />
|
||||||
|
|
||||||
|
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/bottomBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnShareFull"
|
||||||
|
style="@style/Widget.Material3.Button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Share Receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
app:backgroundTint="@color/mib_blue"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSaveFull"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Save Receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="@color/mib_blue"
|
||||||
|
app:backgroundTint="@color/mib_receipt_bg"
|
||||||
|
app:strokeColor="@color/mib_blue"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Close (back) button — status bar inset is added to top margin in code -->
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_mib_receipt_close"
|
||||||
|
android:contentDescription="Close" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -130,63 +130,13 @@
|
|||||||
android:maxLength="6" />
|
android:maxLength="6" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
<include
|
||||||
android:id="@+id/cardOtp"
|
android:id="@+id/cardOtp"
|
||||||
|
layout="@layout/view_otp_preview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp" />
|
||||||
android:visibility="invisible"
|
|
||||||
android:clickable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="0dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingHorizontal="16dp"
|
|
||||||
android:paddingVertical="12dp"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Current OTP"
|
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
|
||||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvOtpCode"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
|
||||||
android:textColor="?attr/colorOnSecondaryContainer"
|
|
||||||
android:letterSpacing="0.15"
|
|
||||||
android:fontFamily="monospace" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
|
||||||
android:id="@+id/otpTimer"
|
|
||||||
android:layout_width="32dp"
|
|
||||||
android:layout_height="32dp"
|
|
||||||
app:indicatorSize="32dp"
|
|
||||||
app:trackThickness="3dp"
|
|
||||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
|
||||||
app:trackColor="?attr/colorSecondaryContainer"
|
|
||||||
android:indeterminate="false" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvError"
|
android:id="@+id/tvError"
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<FrameLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerView"
|
android:id="@+id/recyclerView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:paddingTop="16dp"
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="16dp"
|
||||||
android:clipToPadding="false" />
|
android:clipToPadding="false" />
|
||||||
|
|
||||||
</LinearLayout>
|
<LinearLayout
|
||||||
|
android:id="@+id/emptyState"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="32dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:alpha="0.6"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_nav_otp"
|
||||||
|
android:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="No OTP codes yet.\nAdd an MIB or BML login to see its codes here."
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
|
|
||||||
<!-- Include phone number toggle -->
|
<!-- Include phone number toggle -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutIncludePhone"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="#FFFFFF">
|
android:background="@color/bml_receipt_bg">
|
||||||
|
|
||||||
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
|
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
android:layout_gravity="center_horizontal"
|
android:layout_gravity="center_horizontal"
|
||||||
android:layout_marginTop="20dp"
|
android:layout_marginTop="20dp"
|
||||||
android:src="@drawable/bml_icon"
|
android:src="@drawable/bml_logo_vector"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:adjustViewBounds="true"
|
android:adjustViewBounds="true"
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="@null" />
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
android:layout_marginBottom="20dp"
|
android:layout_marginBottom="20dp"
|
||||||
android:id="@+id/tvMessage"
|
android:id="@+id/tvMessage"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="#2D2D2D"
|
android:textColor="@color/bml_receipt_message"
|
||||||
android:fontFamily="@font/nunito_sans"
|
android:fontFamily="@font/nunito_sans"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_marginBottom="13dp"
|
android:layout_marginBottom="13dp"
|
||||||
android:textSize="42sp"
|
android:textSize="42sp"
|
||||||
android:textColor="#242424"
|
android:textColor="@color/bml_receipt_amount"
|
||||||
android:fontFamily="@font/sofia_pro"
|
android:fontFamily="@font/sofia_pro"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@@ -109,66 +109,66 @@
|
|||||||
|
|
||||||
<!-- Status (value = green #8BC155) -->
|
<!-- Status (value = green #8BC155) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Message (value wraps if long) -->
|
<!-- Message (value wraps if long) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Reference -->
|
<!-- Reference -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Transaction date -->
|
<!-- Transaction date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- From (value uppercase) -->
|
<!-- From (value uppercase) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
|
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- To (stacked name + account) -->
|
<!-- To (stacked name + account) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
|
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
|
||||||
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Amount (value = green #8BC155) -->
|
<!-- Amount (value = green #8BC155) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Remarks (hidden when empty) -->
|
<!-- Remarks (hidden when empty) -->
|
||||||
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" android:visibility="gone" />
|
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" android:visibility="gone" />
|
||||||
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
|
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:background="#F5F5F5"
|
android:background="@color/bml_receipt_footer"
|
||||||
android:paddingVertical="22dp">
|
android:paddingVertical="22dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -246,18 +246,10 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:text="Save"
|
android:text="Save"
|
||||||
app:icon="@drawable/ic_save" />
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnDone"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="4dp"
|
|
||||||
android:text="Done" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -376,18 +376,10 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:text="Save"
|
android:text="Save"
|
||||||
app:icon="@drawable/ic_save" />
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnDone"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="4dp"
|
|
||||||
android:text="Done" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
android:overScrollMode="never"
|
android:overScrollMode="never"
|
||||||
android:scrollbars="none">
|
android:scrollbars="none">
|
||||||
|
|
||||||
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
<!-- Renderable receipt card -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/receiptCard"
|
android:id="@+id/receiptCard"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -23,76 +23,35 @@
|
|||||||
android:layout_marginHorizontal="40dp"
|
android:layout_marginHorizontal="40dp"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<!-- Green header — fills all space not taken by body -->
|
<!-- Green header — from/to avatars and names -->
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
android:id="@+id/receiptHeader"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="200dp"
|
android:layout_height="160dp"
|
||||||
android:background="@drawable/trx_success_bg">
|
android:background="@drawable/mib_receipt_header_bg">
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="TRANSACTION RECEIPT"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:letterSpacing="0.08"
|
|
||||||
android:layout_marginBottom="22dp" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="64dp"
|
|
||||||
android:layout_height="64dp"
|
|
||||||
android:src="@drawable/ic_receipt_check"
|
|
||||||
android:contentDescription="@null" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="SUCCESSFUL"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:letterSpacing="0.05"
|
|
||||||
android:layout_marginTop="18dp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<!-- White body — fixed size content -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="#FFFFFF"
|
|
||||||
android:paddingTop="16dp">
|
|
||||||
|
|
||||||
<!-- Avatars row -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingHorizontal="24dp"
|
android:paddingHorizontal="24dp">
|
||||||
android:paddingBottom="14dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<com.google.android.material.imageview.ShapeableImageView
|
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
||||||
|
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
||||||
|
<ImageView
|
||||||
android:id="@+id/ivFromAvatar"
|
android:id="@+id/ivFromAvatar"
|
||||||
android:layout_width="52dp"
|
android:layout_width="52dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvFromLabel"
|
android:id="@+id/tvFromLabel"
|
||||||
@@ -100,7 +59,9 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:textColor="#565656"
|
android:maxWidth="110dp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:maxLines="2" />
|
android:maxLines="2" />
|
||||||
|
|
||||||
@@ -109,22 +70,25 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:layout_marginHorizontal="12dp"
|
android:layout_marginHorizontal="10dp"
|
||||||
android:src="@drawable/ic_arrow_right"
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:tint="#FFFFFF"
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<com.google.android.material.imageview.ShapeableImageView
|
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
||||||
|
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
||||||
|
<ImageView
|
||||||
android:id="@+id/ivToAvatar"
|
android:id="@+id/ivToAvatar"
|
||||||
android:layout_width="52dp"
|
android:layout_width="52dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvToLabel"
|
android:id="@+id/tvToLabel"
|
||||||
@@ -132,7 +96,9 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:textColor="#565656"
|
android:maxWidth="110dp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:maxLines="2" />
|
android:maxLines="2" />
|
||||||
|
|
||||||
@@ -140,81 +106,92 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
</FrameLayout>
|
||||||
|
|
||||||
<!-- Total Amount -->
|
<!-- Body -->
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="TOTAL AMOUNT"
|
android:orientation="vertical"
|
||||||
android:textSize="13sp"
|
android:background="@color/mib_receipt_bg">
|
||||||
android:textColor="#a0a2a1"
|
|
||||||
android:letterSpacing="0.08"
|
|
||||||
android:gravity="center"
|
|
||||||
android:paddingTop="10dp"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvAmount"
|
android:id="@+id/tvAmount"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="34sp"
|
android:textSize="24sp"
|
||||||
android:textColor="#f14f0f"
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/mib_receipt_amount"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingBottom="10dp" />
|
android:paddingTop="14dp" />
|
||||||
|
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<TextView
|
||||||
|
android:id="@+id/tvStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Success"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/mib_receipt_amount"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingBottom="6dp" />
|
||||||
|
|
||||||
<!-- Reference # -->
|
<!-- Transaction # -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction#" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvReferenceNo" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- To Account -->
|
<!-- From (sender profile name) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="From" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvFromName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- To Bank -->
|
<!-- To Account (recipient name + account number) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="To Account" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvToAccount" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
|
<!-- Bank -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Bank" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
|
<TextView android:id="@+id/tvToBank" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
|
</LinearLayout>
|
||||||
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
|
<!-- Transaction Type -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Type" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
|
<TextView android:id="@+id/tvTransactionType" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
|
</LinearLayout>
|
||||||
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Transaction Date -->
|
<!-- Transaction Date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvTransactionDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Value Date -->
|
<!-- Processed Date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Processed Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvValueDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Purpose -->
|
<!-- Remarks -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Remarks" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvPurpose" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- MIB footer -->
|
<!-- MIB footer -->
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -224,19 +201,10 @@
|
|||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="28dp"
|
android:layout_height="22dp"
|
||||||
android:src="@drawable/mib_logo"
|
android:src="@drawable/mib_logo_full"
|
||||||
android:adjustViewBounds="true"
|
android:adjustViewBounds="true"
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="Maldives Islamic Bank" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:text="MALDIVES ISLAMIC BANK"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textColor="#000000"
|
|
||||||
android:letterSpacing="0.05" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -273,18 +241,10 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:text="Save"
|
android:text="Save"
|
||||||
app:icon="@drawable/ic_save" />
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnDone"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_marginStart="4dp"
|
|
||||||
android:text="Done" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
android:layout_width="32dp"
|
android:layout_width="32dp"
|
||||||
android:layout_height="32dp"
|
android:layout_height="32dp"
|
||||||
android:layout_marginEnd="16dp"
|
android:layout_marginEnd="16dp"
|
||||||
android:src="@drawable/bml_icon"
|
android:src="@drawable/bml_logo_vector"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:contentDescription="BML" />
|
android:contentDescription="BML" />
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,7 @@
|
|||||||
android:id="@+id/languageToggle"
|
android:id="@+id/languageToggle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:baselineAligned="false"
|
||||||
app:singleSelection="true"
|
app:singleSelection="true"
|
||||||
app:selectionRequired="true">
|
app:selectionRequired="true">
|
||||||
|
|
||||||
@@ -303,6 +304,34 @@
|
|||||||
|
|
||||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_receipts"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/settings_always_fullscreen_receipt"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchFullscreenReceipt"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp"
|
||||||
|
android:clipToPadding="false">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvLoginsTitle"
|
android:id="@+id/tvLoginsTitle"
|
||||||
@@ -20,10 +21,12 @@
|
|||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/loginsContainer"
|
android:id="@+id/loginsContainer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="-12dp"
|
||||||
android:orientation="vertical" />
|
android:orientation="vertical" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
|
|||||||
@@ -148,12 +148,16 @@
|
|||||||
app:endIconDrawable="@android:drawable/ic_menu_search"
|
app:endIconDrawable="@android:drawable/ic_menu_search"
|
||||||
app:endIconContentDescription="@string/transfer_lookup_account">
|
app:endIconContentDescription="@string/transfer_lookup_account">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||||
android:id="@+id/etTo"
|
android:id="@+id/etTo"
|
||||||
|
style="@style/Widget.Material3.AutoCompleteTextView.OutlinedBox"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textNoSuggestions"
|
android:inputType="textNoSuggestions"
|
||||||
android:maxLines="1" />
|
android:maxLines="1"
|
||||||
|
android:completionThreshold="3"
|
||||||
|
android:dropDownHeight="wrap_content"
|
||||||
|
android:imeOptions="actionSearch" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
android:paddingHorizontal="16dp"
|
android:paddingHorizontal="16dp"
|
||||||
android:paddingVertical="10dp">
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
@@ -26,25 +27,6 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent">
|
app:layout_constraintBottom_toBottomOf="parent">
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnEditContact"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_edit"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:contentDescription="@string/contact_edit" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnDeleteContact"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_delete"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:tint="?attr/colorError"
|
|
||||||
android:contentDescription="@string/contact_delete" />
|
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btnTransferContact"
|
android:id="@+id/btnTransferContact"
|
||||||
android:layout_width="36dp"
|
android:layout_width="36dp"
|
||||||
|
|||||||
@@ -2,55 +2,163 @@
|
|||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
style="@style/Widget.Material3.CardView.Filled"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
app:cardCornerRadius="16dp"
|
app:cardCornerRadius="24dp">
|
||||||
app:cardElevation="2dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="20dp">
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
<TextView
|
<!-- Header: logo, bank + holder name, countdown ring -->
|
||||||
android:id="@+id/tvOtpLabel"
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
|
||||||
android:textColor="?attr/colorOnSurfaceVariant"
|
|
||||||
android:layout_marginBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvOtpCode"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
|
||||||
android:textColor="?attr/colorPrimary"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:letterSpacing="0.15"
|
|
||||||
android:layout_marginBottom="16dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
|
||||||
android:id="@+id/otpProgress"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:max="30"
|
android:orientation="horizontal"
|
||||||
app:trackCornerRadius="4dp"
|
android:gravity="center_vertical">
|
||||||
app:indicatorColor="?attr/colorPrimary"
|
|
||||||
app:trackColor="?attr/colorSurfaceVariant" />
|
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
android:id="@+id/tvOtpCountdown"
|
android:id="@+id/ivBankLogo"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
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/tvOtpBank"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/otpProgress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:indeterminate="false"
|
||||||
|
android:max="30"
|
||||||
|
app:indicatorSize="40dp"
|
||||||
|
app:trackThickness="3dp"
|
||||||
|
app:trackCornerRadius="2dp"
|
||||||
|
app:indicatorColor="?attr/colorPrimary"
|
||||||
|
app:trackColor="?attr/colorOutlineVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCountdown"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Current code + copy -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="12dp"
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
android:orientation="horizontal"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCode"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:letterSpacing="0.12"
|
||||||
|
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
||||||
|
android:textColor="?attr/colorPrimary"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCopyOtp"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="Copy OTP"
|
||||||
|
android:tooltipText="Copy OTP"
|
||||||
|
app:icon="@drawable/ic_copy" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<!-- Next code + copy -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="Next"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNextOtpCode"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCopyNextOtp"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="Copy next OTP"
|
||||||
|
android:tooltipText="Copy next OTP"
|
||||||
|
app:icon="@drawable/ic_copy"
|
||||||
|
app:iconTint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<?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="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivPhoto"
|
||||||
|
android:layout_width="112dp"
|
||||||
|
android:layout_height="112dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionTransfer"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnTransfer"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/transfer"
|
||||||
|
app:icon="@drawable/ic_send"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/transfer"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionQr"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnQr"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_qr"
|
||||||
|
app:icon="@drawable/ic_qr_code"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvQrLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_qr"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionEdit"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnEdit"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_edit"
|
||||||
|
app:icon="@drawable/ic_edit"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_edit"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionDelete"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnDelete"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_delete"
|
||||||
|
app:backgroundTint="?attr/colorErrorContainer"
|
||||||
|
app:icon="@drawable/ic_delete"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp"
|
||||||
|
app:iconTint="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_delete"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorError" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_manage_card"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_account_number"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnShareAccount"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/contact_share_account"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:src="@drawable/ic_share"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnCopyAccount"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/contact_copy_account"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:src="@drawable/ic_copy"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowRealName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_contacts"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRealName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_account_name"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowCurrency"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_currency"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCurrency"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_currency"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivBankIcon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_nav_finances"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_bank"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sourceSection"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginVertical="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="56dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivSourceIcon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSource"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSourceBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Live TOTP preview card, shared by the sign-in screen and the OTP screen's "Update seed" dialog -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
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="wrap_content"
|
||||||
|
android:visibility="invisible"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Current OTP"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCode"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer"
|
||||||
|
android:letterSpacing="0.15"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="end"
|
||||||
|
android:alpha="0.7">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Next"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNextOtpCode"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/otpTimer"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
app:indicatorSize="32dp"
|
||||||
|
app:trackThickness="3dp"
|
||||||
|
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||||
|
app:trackColor="?attr/colorSecondaryContainer"
|
||||||
|
android:indeterminate="false" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -145,4 +145,5 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
||||||
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
||||||
|
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- BML receipt — dark mode -->
|
||||||
|
<color name="bml_receipt_bg">#191A1C</color>
|
||||||
|
<color name="bml_receipt_message">#DEDEE0</color>
|
||||||
|
<color name="bml_receipt_amount">#DEDEE0</color>
|
||||||
|
<color name="bml_receipt_label">#DEDEE0</color>
|
||||||
|
<color name="bml_receipt_footer">#000000</color>
|
||||||
|
<color name="bml_receipt_divider">#3D3E40</color>
|
||||||
|
<color name="bml_receipt_bottom_divider">#1E1F21</color>
|
||||||
|
|
||||||
|
<!-- MIB receipt — dark mode -->
|
||||||
|
<color name="mib_receipt_bg">#1A1A3C</color>
|
||||||
|
<color name="mib_receipt_label">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_value">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_divider">#33335C</color>
|
||||||
|
<color name="mib_receipt_footer_text">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_close_ring">#000000</color>
|
||||||
|
</resources>
|
||||||
@@ -5,4 +5,24 @@
|
|||||||
<color name="seed_secondary">#9AD141</color>
|
<color name="seed_secondary">#9AD141</color>
|
||||||
<color name="color_unpaid">#E85D04</color>
|
<color name="color_unpaid">#E85D04</color>
|
||||||
<color name="ic_logo_background">#E8B547</color>
|
<color name="ic_logo_background">#E8B547</color>
|
||||||
|
|
||||||
|
<!-- BML receipt (dark variants in values-night/colors.xml) -->
|
||||||
|
<color name="bml_receipt_bg">#FFFFFF</color>
|
||||||
|
<color name="bml_receipt_message">#2D2D2D</color>
|
||||||
|
<color name="bml_receipt_amount">#242424</color>
|
||||||
|
<color name="bml_receipt_label">#000000</color>
|
||||||
|
<color name="bml_receipt_footer">#F5F5F5</color>
|
||||||
|
<color name="bml_receipt_divider">#E9E9E9</color>
|
||||||
|
<color name="bml_receipt_bottom_divider">#EBEBEB</color>
|
||||||
|
<color name="bml_red">#E21B23</color>
|
||||||
|
|
||||||
|
<!-- MIB receipt (dark variants in values-night/colors.xml) -->
|
||||||
|
<color name="mib_receipt_bg">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_label">#000000</color>
|
||||||
|
<color name="mib_receipt_value">#000000</color>
|
||||||
|
<color name="mib_receipt_divider">#CAC4D0</color>
|
||||||
|
<color name="mib_receipt_footer_text">#000000</color>
|
||||||
|
<color name="mib_receipt_amount">#1EA833</color>
|
||||||
|
<color name="mib_receipt_close_ring">#FFFFFF</color>
|
||||||
|
<color name="mib_blue">#006FFC</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -127,7 +127,6 @@
|
|||||||
<string name="paymvqr_save_failed">Failed to save image</string>
|
<string name="paymvqr_save_failed">Failed to save image</string>
|
||||||
<string name="paymvqr_include_phone">Include phone number</string>
|
<string name="paymvqr_include_phone">Include phone number</string>
|
||||||
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
||||||
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<string name="action_lock">Lock app</string>
|
<string name="action_lock">Lock app</string>
|
||||||
@@ -158,6 +157,8 @@
|
|||||||
<string name="theme_dark">Dark</string>
|
<string name="theme_dark">Dark</string>
|
||||||
<string name="settings_pitch_black">Pitch Black</string>
|
<string name="settings_pitch_black">Pitch Black</string>
|
||||||
<string name="settings_accent_color">Accent Color</string>
|
<string name="settings_accent_color">Accent Color</string>
|
||||||
|
<string name="settings_receipts">Receipts</string>
|
||||||
|
<string name="settings_always_fullscreen_receipt">Always show full screen receipt</string>
|
||||||
<string name="accent_blue">Blue</string>
|
<string name="accent_blue">Blue</string>
|
||||||
<string name="accent_orange">Red</string>
|
<string name="accent_orange">Red</string>
|
||||||
<string name="accent_green">Green</string>
|
<string name="accent_green">Green</string>
|
||||||
@@ -295,6 +296,7 @@
|
|||||||
<!-- BML QR Pay -->
|
<!-- BML QR Pay -->
|
||||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
<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_lookup_failed">Could not load merchant details</string>
|
||||||
|
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
|
||||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||||
|
|
||||||
@@ -332,6 +334,14 @@
|
|||||||
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
||||||
<string name="contact_deleted">Contact deleted</string>
|
<string name="contact_deleted">Contact deleted</string>
|
||||||
<string name="contact_delete_failed">Could not delete contact</string>
|
<string name="contact_delete_failed">Could not delete contact</string>
|
||||||
|
<string name="contact_account_number">Account number</string>
|
||||||
|
<string name="contact_account_name">Account name</string>
|
||||||
|
<string name="contact_bank">Bank</string>
|
||||||
|
<string name="contact_qr">QR</string>
|
||||||
|
<string name="contact_delete_warning">This can\'t be undone.</string>
|
||||||
|
<string name="contact_copy_account">Copy account number</string>
|
||||||
|
<string name="contact_share_account">Share account details</string>
|
||||||
|
<string name="contact_account_copied">Account number copied</string>
|
||||||
|
|
||||||
<!-- Financing -->
|
<!-- Financing -->
|
||||||
<string name="financing_empty">No financing deals found</string>
|
<string name="financing_empty">No financing deals found</string>
|
||||||
@@ -391,4 +401,5 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
||||||
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
||||||
|
<string name="drag_to_reorder">Drag to reorder</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
|
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
|
||||||
|
|
||||||
|
<!-- Destructive confirmation dialog: centered icon/title, filled red confirm button -->
|
||||||
|
<style name="ThemeOverlay.BasedBank.DestructiveDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
|
||||||
|
<item name="buttonBarPositiveButtonStyle">@style/Widget.BasedBank.DestructiveDialogButton</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.BasedBank.DestructiveDialogButton" parent="Widget.Material3.Button">
|
||||||
|
<item name="backgroundTint">?attr/colorError</item>
|
||||||
|
<item name="android:textColor">?attr/colorOnError</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<item name="colorPrimary">@color/seed_primary</item>
|
<item name="colorPrimary">@color/seed_primary</item>
|
||||||
<item name="colorSecondary">@color/seed_secondary</item>
|
<item name="colorSecondary">@color/seed_secondary</item>
|
||||||
|
|||||||
@@ -190,9 +190,9 @@ Known asset mappings:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
||||||
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
||||||
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
|
| `C8010`, `C8011`, `C8033` | Mastercard Platinum | `master_platinum` |
|
||||||
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
||||||
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
|
| `C8030` | Mastercard Business Debit | `master_business_debit` |
|
||||||
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
||||||
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
||||||
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
|
|||||||
|
|
||||||
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
||||||
|
|
||||||
|
### 3. POS QR (`mv.com.bml.qtr`)
|
||||||
|
|
||||||
|
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
|
||||||
|
`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as
|
||||||
|
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
|
||||||
|
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
|
||||||
|
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
|
||||||
|
understands it. See
|
||||||
|
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PayMV QR Format (TLV)
|
## PayMV QR Format (TLV)
|
||||||
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
|
|||||||
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
||||||
```
|
```
|
||||||
|
|
||||||
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
|
`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
|
||||||
|
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference.
|
||||||
|
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
|
||||||
|
rather than relying on either being required.
|
||||||
|
|
||||||
### Headers
|
### Headers
|
||||||
|
|
||||||
| Header | Value |
|
| Header | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `accept` | `application/json` — **required in practice**, see below |
|
||||||
| `Authorization` | `Bearer <access_token>` |
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
| `x-app-version` | `2.1.44.348` |
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
|
||||||
|
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
|
||||||
|
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
|
||||||
|
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --request GET \
|
curl --request GET \
|
||||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
||||||
|
--header 'accept: application/json' \
|
||||||
--header 'Authorization: Bearer <access_token>' \
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
--header 'x-app-version: 2.1.44.348'
|
--header 'x-app-version: 2.1.44.348'
|
||||||
@@ -116,6 +136,16 @@ curl --request GET \
|
|||||||
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
||||||
| `currency` | Currency code (typically `"MVR"`) |
|
| `currency` | Currency code (typically `"MVR"`) |
|
||||||
|
|
||||||
|
### Failure Responses
|
||||||
|
|
||||||
|
`success: false` comes back with a code and a user-facing message; the client shows BML's own
|
||||||
|
wording and keeps the user on the Transfer screen.
|
||||||
|
|
||||||
|
| Code | Message |
|
||||||
|
|---|---|
|
||||||
|
| `103` | The payment request has expired |
|
||||||
|
| `112` | Unsupported payment link |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Pay (3-Step TOTP Flow)
|
## Step 2 — Pay (3-Step TOTP Flow)
|
||||||
|
|||||||
@@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[← Profile Picture](06-profile-picture.md)
|
[← Profile Picture](06-profile-picture.md) **Next →** [PayMV QR](08-paymv-qr.md)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# PayMV QR (Receive)
|
||||||
|
|
||||||
|
> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress).
|
||||||
|
|
||||||
|
Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET api/app/qr/?lang=<lang>&type=p2p&amount=<amount>
|
||||||
|
```
|
||||||
|
|
||||||
|
The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`.
|
||||||
|
|
||||||
|
## The card image
|
||||||
|
|
||||||
|
A 1240 × 1322 PNG:
|
||||||
|
- Blue `#005DA3` card with a 6 px border.
|
||||||
|
- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right.
|
||||||
|
- A blue panel holding the holder's name and the QR.
|
||||||
|
- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it.
|
||||||
|
- A "MALDIVES NATIONAL QR" footer.
|
||||||
|
|
||||||
|
Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard).
|
||||||
|
|
||||||
|
Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Saved Favourites](07-contacts.md)
|
||||||
@@ -127,6 +127,7 @@ Client Server
|
|||||||
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
||||||
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
||||||
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
||||||
|
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" baseProfile="basic" id="Layer_1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
||||||
|
xml:space="preserve">
|
||||||
|
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
|
||||||
|
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
|
||||||
|
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"/>
|
||||||
|
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
|
||||||
|
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
|
||||||
|
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
|
||||||
|
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"/>
|
||||||
|
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
|
||||||
|
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
|
||||||
|
L256,193.98999z"/>
|
||||||
|
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
|
||||||
|
C72,238.87917,85.87916,225,102.99997,225H248z"/>
|
||||||
|
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 "/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -279,4 +279,4 @@ python tmp/mfaisa_transfer.py <myMsisdn> <myMpin> <recipientMsisdn>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **← Back to** [Transaction History](03-history.md) | [README](README.md)
|
> **← Back to** [Transaction History](03-history.md) | [README](README.md) | **Next →** [QR Merchant Payment](05-qr-pay.md)
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# QR Merchant Payment ("Smart Pay")
|
||||||
|
|
||||||
|
Pay an Ooredoo M-Faisa merchant by scanning their QR. The QR encodes only a numeric `qrCodeId` (e.g. `1594103440350`) — no URL, no EMV TLV envelope. The flow is three calls and **does not require OTP** (`2FARequired=NONE`).
|
||||||
|
|
||||||
|
> **Currency / pocket constraints:** captures cover MVR→MVR purchases from the user's EMONEY pocket only. The `transactionCurrency` field is taken from the QR lookup; we have not seen a non-MVR variant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
| |
|
||||||
|
| POST /QRCodeUtility/fetchQRCodeById | ← user scanned a QR
|
||||||
|
| formData = { qrCodeId, tenantCode } |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| [{ success, response:[{ commercialName, |
|
||||||
|
| customerId, mobileNumber, currencyCode, |
|
||||||
|
| txnAmount, status, ... }] }] |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| (show merchant, accept amount + remarks)
|
||||||
|
| |
|
||||||
|
| POST /initiateNewBuy |
|
||||||
|
| formData = { merchantId, mobileNumber, |
|
||||||
|
| sourceDetails, transactionAmount, |
|
||||||
|
| transactionType:"PURCHASE", … } |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| [{ 2FARequired:"NONE", |
|
||||||
|
| authenticationType:"NONE", |
|
||||||
|
| success:true, |
|
||||||
|
| response:[{ responseObject:{ referenceId, |
|
||||||
|
| chargeDetails, … } }] }] |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| (no OTP — go straight to confirm)
|
||||||
|
| |
|
||||||
|
| POST /confirmNewBuy |
|
||||||
|
| formData = { referenceId } |
|
||||||
|
| transactionAuthDetails = "null" ← literal |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| [{ success:true, |
|
||||||
|
| message:"Payment Completed Successfully", |
|
||||||
|
| response:[{ responseObject:{ isCompleted, |
|
||||||
|
| balanceInquiryDTO, ... } }] }] |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
```
|
||||||
|
|
||||||
|
All three endpoints carry the standard anti-replay pair (`rndValue` + `csValue`) derived from each request's `formData` JSON — see [01-encryption.md → rndValue / csValue](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||||
|
|
||||||
|
Unlike the transfer flow, **every endpoint here returns its envelope as a JSON array** `[{...}]` for both success and error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: `QRCodeUtility/fetchQRCodeById` — resolve merchant
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/QRCodeUtility/fetchQRCodeById
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `role` | **`R01`** (the other two endpoints use `RETAIL_SUBSCRIBER` — this one does not) |
|
||||||
|
| `channel` | `C03` |
|
||||||
|
| `loginExchangeKey` | From login |
|
||||||
|
| `rndValue` / `csValue` | [Standard anti-replay](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||||
|
| `formData` | JSON below ([html-safe `=` escaping](01-encryption.md#html-safe-gson--escape)) |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"qrCodeId": "<numeric id from QR>",
|
||||||
|
"tenantCode": "ooredoo"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response — happy path
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "QRCode fetched Successfully.",
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"mobileNumber": "9609569506", /* merchant's '960' + msisdn */
|
||||||
|
"customerId": "72518", /* used as merchantId in step 2 */
|
||||||
|
"commercialName": "Family Room", /* merchant display name */
|
||||||
|
"qrCodeId": "1594103440350",
|
||||||
|
"qrImageString": "<base64 PNG>", /* unused */
|
||||||
|
"accountNumber": null,
|
||||||
|
"txnAmount": null, /* static QR; dynamic QRs put a number here */
|
||||||
|
"currencyCode": "MVR",
|
||||||
|
"status": "Active",
|
||||||
|
"role": "AGENT",
|
||||||
|
"tenantCode": "ooredoo",
|
||||||
|
"...": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`accountNumber` / `txnAmount` are JSON `null` for **static QRs** (the user chooses the amount). For **dynamic QRs** the server returns the fixed amount in `txnAmount` and the client should lock the amount field.
|
||||||
|
|
||||||
|
The `mobileNumber` field already includes the `960` country prefix.
|
||||||
|
|
||||||
|
### Response — QR not found / inactive
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "success": false, "message": "QRCode not found." }]
|
||||||
|
```
|
||||||
|
|
||||||
|
The client also rejects entries with `status != "Active"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: `initiateNewBuy` — initiate purchase (no OTP triggered)
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/initiateNewBuy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `role` | `RETAIL_SUBSCRIBER` |
|
||||||
|
| `channel` | `C03` (top-level differs from inner `formData.channel`, which is `SubscriberApp`) |
|
||||||
|
| `loginExchangeKey` | From login |
|
||||||
|
| `rndValue` / `csValue` | Standard anti-replay (derived from the `formData` below) |
|
||||||
|
| `formData` | JSON below |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel": "SubscriberApp",
|
||||||
|
"commodityType": "WALLET",
|
||||||
|
"description": "<remarks>", /* free text, may be empty */
|
||||||
|
"merchantId": "<customerId from step 1>",
|
||||||
|
"mobileNumber": "<merchant '960' msisdn from step 1>",
|
||||||
|
"sourceDetails": {
|
||||||
|
"MDNId": "960<myMsisdn>", /* PLAINTEXT — '960' + my phone */
|
||||||
|
"actorRoleType": "RETAIL_SUBSCRIBER",
|
||||||
|
"pocketId": "<my source pocket id>" /* EMONEY pocket from login */
|
||||||
|
},
|
||||||
|
"transactionAmount": "<amount>", /* string, e.g. "7.56" */
|
||||||
|
"transactionCurrency": "MVR",
|
||||||
|
"transactionType": "PURCHASE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike the transfer flow's `initiateFTRequest`, this endpoint does **not** take an `identifier` header field or `tPin`.
|
||||||
|
|
||||||
|
### Response — happy path
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"2FARequired": "NONE", /* ← the key difference */
|
||||||
|
"authenticationType": "NONE",
|
||||||
|
"success": true,
|
||||||
|
"message": "Purchase Initiated Successfully",
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"requestObject": { "...": "..." },
|
||||||
|
"responseObject": {
|
||||||
|
"referenceId": "685011023630",
|
||||||
|
"transactionAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||||
|
"netAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||||
|
"chargeDetailsDTO": { "totalFeesInTenantCurrency": { "amount": 0.0, "...": "..." }, "...": "..." },
|
||||||
|
"isCompleted": false,
|
||||||
|
"authenticationType":"NONE",
|
||||||
|
"...": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache `referenceId` for step 3. No SMS is sent — proceed straight to confirm.
|
||||||
|
|
||||||
|
> **Defensive check:** the Thijooree client throws if it ever sees `2FARequired != "NONE"` on this endpoint so a no-op confirm can't silently complete. If Ooredoo ever turns 2FA on for QR pay, you'll see a clear error instead of a partial transaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: `confirmNewBuy` — settle purchase
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/confirmNewBuy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `role` | `RETAIL_SUBSCRIBER` |
|
||||||
|
| `channel` | `C03` |
|
||||||
|
| `loginExchangeKey` | From login |
|
||||||
|
| `rndValue` / `csValue` | Anti-replay derived from `formData` below |
|
||||||
|
| `formData` | `{"referenceId": "<from step 2>"}` |
|
||||||
|
| `transactionAuthDetails` | **literal string `"null"`** (not a JSON null, not an empty string — the captured request sends the four-character string `null`) |
|
||||||
|
|
||||||
|
### Response — happy path
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Payment Completed Successfully",
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"responseObject": {
|
||||||
|
"isCompleted": true,
|
||||||
|
"balanceInquiryDTO": {
|
||||||
|
"currencyCode": "MVR",
|
||||||
|
"pocketAmount": 92.85,
|
||||||
|
"pocketId": "<source pocket id>",
|
||||||
|
"pocketBalanceMap": { "...": "..." }
|
||||||
|
},
|
||||||
|
"status": { "replyCode": 0.0 },
|
||||||
|
"...": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session expiry
|
||||||
|
|
||||||
|
Same envelope as elsewhere — `attributeValue: "SESSION_EXPIRED"` with HTTP 200; the client throws `MfaisaSessionExpiredException`. See [03-history.md → Session expiry](03-history.md#session-expiry).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional: `save/smart-pay-recipient` — bookkeeping
|
||||||
|
|
||||||
|
After a successful confirm the official Ooredoo app saves the merchant to a server-side "recent recipients" list:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/save/smart-pay-recipient
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **not** required for the payment itself — the transfer is final once `confirmNewBuy` succeeds. Thijooree skips it; the merchant is kept in the local picker `RecentsCache` under an `mfaisaqr:<qrCodeId>` synthetic accountNumber (mirroring the BML QR `bmlqr:` scheme).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **← Back to** [Transfer Money](04-transfer.md) | [README](README.md)
|
||||||
@@ -88,6 +88,7 @@ Client Server
|
|||||||
| 2 | [Login](02-login.md) | Subscriber lookup + mPIN login |
|
| 2 | [Login](02-login.md) | Subscriber lookup + mPIN login |
|
||||||
| 3 | [Transaction History](03-history.md) | Paginated history per session |
|
| 3 | [Transaction History](03-history.md) | Paginated history per session |
|
||||||
| 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm |
|
| 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm |
|
||||||
|
| 5 | [QR Merchant Payment](05-qr-pay.md) | Three-step "smart pay" scan-to-merchant: QR lookup → initiate → confirm. **No OTP** (`2FARequired=NONE`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
|
|||||||
|
|
||||||
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
|
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
|
||||||
|
|
||||||
|
### Login Order
|
||||||
|
|
||||||
|
The user sets the order of their logins in [Settings → Logins](14-settings.md#login-order). It applies everywhere accounts are listed:
|
||||||
|
|
||||||
|
- `HomeViewModel.accounts` and `HomeViewModel.mibCards` sort themselves by `CredentialStore.loginRank()` on every `setValue` / `postValue`, so every observer (accounts list, transfer dropdown, contact picker "My accounts", PayMV QR, BML QR pay, transfer history, BML loans in Financing) gets the same order with no per-screen sorting. The sort is stable, so accounts from the same login keep the order the bank returned them in.
|
||||||
|
- Combined MIB + BML card lists (dashboard stack, [Cards](22-cards.md)) are sorted by `CardItem.loginTag`.
|
||||||
|
- [Financing](13-financing.md) places the MIB and BML sections by whichever bank's first login ranks higher.
|
||||||
|
- The [OTP Screen](10-otp-screen.md) sorts its entries by login.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MIB Session KeepAlive
|
## MIB Session KeepAlive
|
||||||
|
|||||||
@@ -13,10 +13,36 @@ Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed a
|
|||||||
## TOTP Display
|
## TOTP Display
|
||||||
|
|
||||||
Each card shows:
|
Each card shows:
|
||||||
- Bank logo and name
|
- Bank logo, bank name and account holder name
|
||||||
- The current 6-digit TOTP code (large text)
|
- A circular countdown ring with the seconds left in the current 30-second window; the ring and code turn red in the last 5 seconds
|
||||||
- A circular countdown ring showing time remaining in the current 30-second window
|
- The current 6-digit TOTP code (large text) with a copy button
|
||||||
- The code refreshes automatically when the window expires — no user interaction needed
|
- The next window's code (smaller, below a divider) with its own copy button — handy when the current code is about to expire
|
||||||
|
- The codes refresh automatically when the window expires — no user interaction needed
|
||||||
|
|
||||||
|
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seed Actions
|
||||||
|
|
||||||
|
Long-pressing a card opens a menu with:
|
||||||
|
|
||||||
|
### Export seed
|
||||||
|
|
||||||
|
A dialog titled `{bank} · {name}` showing:
|
||||||
|
- A QR code of a minimal `otpauth://totp/{BANK}?secret=…` link (e.g. `otpauth://totp/BML?secret=…`), always drawn black-on-white so it scans in dark mode. No username or issuer is included, and algorithm, digits and period are left out because SHA1, 6 and 30s are the spec defaults. Export is single-account only; `otpauth-migration://` is supported for import but never produced.
|
||||||
|
- The Base32 seed in groups of 4 (selectable text)
|
||||||
|
- A **Copy seed** button. The copy is flagged `EXTRA_IS_SENSITIVE`, so Android 13+ hides the value in the clipboard preview.
|
||||||
|
|
||||||
|
### Update seed
|
||||||
|
|
||||||
|
Replaces the stored seed for that login, e.g. after re-enrolling the authenticator with the bank.
|
||||||
|
- A red warning banner explains that the old seed is deleted permanently.
|
||||||
|
- The new seed can be typed or pasted (raw Base32 or an `otpauth://` link) or scanned with the QR button. Scans that contain several accounts (`otpauth-migration://`) ask which one to use.
|
||||||
|
- Once the input is a valid seed, a live preview shows its current and next code with a countdown so the user can check it against the bank before saving. The preview is the same card as the sign-in screen (`view_otp_preview.xml`, shared by both), and tapping it copies the code. Input that isn't valid Base32, is shorter than 8 characters (such as a pasted 6-digit code), or matches the current seed disables **Replace**.
|
||||||
|
- **Replace** asks for confirmation ("Delete old seed?"). Confirming calls `CredentialStore.updateMibOtpSeed()` / `updateBmlOtpSeed()`, which overwrite only the encrypted seed. For MIB it also calls `MibLoginFlow.updateOtpSeed()` so silent re-login uses the new seed.
|
||||||
|
|
||||||
|
The username, password and sessions are not touched. Other places that need an OTP (transfers, QR pay, pay with card) read the seed from `CredentialStore` each time, so they pick up the new seed immediately.
|
||||||
|
|
||||||
### Algorithm
|
### Algorithm
|
||||||
|
|
||||||
@@ -30,14 +56,14 @@ Standard RFC 6238 TOTP:
|
|||||||
|
|
||||||
## Supported Banks
|
## Supported Banks
|
||||||
|
|
||||||
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt:93-98`). Seeds are per-`loginId` in `CredentialStore`.
|
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
|
||||||
|
|
||||||
| Bank | Seed source | Card label |
|
| Bank | Seed source | Card title / subtitle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `"MIB · {fullName}"` |
|
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{fullName}` |
|
||||||
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {fullName}"` |
|
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `BML` / `{fullName}` |
|
||||||
|
|
||||||
If no full name has been cached the label falls back to plain `"MIB"` / `"BML"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
|
If no full name has been cached the subtitle falls back to `"Authenticator"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,7 +81,7 @@ The OTP screen is informational — the user copies the displayed code manually
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports.
|
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. They leave the app only when the user chooses **Export seed**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,37 +2,102 @@
|
|||||||
|
|
||||||
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
|
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
|
||||||
|
|
||||||
|
> **Fahipay QRs are a work in progress.** Thijooree's Fahipay card matches Fahipay's design, but the Fahipay app currently rejects the QRs it generates as **"Invalid QR"**. BML QRs scan fine. See [PayMV QR Format → Fahipay](18-paymv-qr-format.md#fahipay-work-in-progress).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fragment — `PayMvQrFragment`
|
## Fragment — `PayMvQrFragment`
|
||||||
|
|
||||||
A single screen (no tabs). Re-renders the QR live as the user edits the form.
|
A single screen (no tabs). Re-renders the QR live (300 ms debounce) as the user edits the form. It can also be opened for a saved contact with `PayMvQrFragment.forContact(accountNumber, name, bank)`. The QR then pays into the contact's account, and the account picker and phone toggle are hidden.
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
| Field | Source / behaviour |
|
| Field | Source / behaviour |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB and BML USD currently excluded — both flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
|
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB, M-Faisa and BML USD currently excluded — flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
|
||||||
| Amount (`etAmount`) | Optional. Blank → open-amount QR |
|
| Amount (`etAmount`) | Optional. Blank / zero / unparseable → open-amount QR. Commas are stripped |
|
||||||
| Reference (`etReference`) | Free-text purpose; defaults to `paymvqr_reference_default` if blank — written to tag 62→08 |
|
| Reference (`etReference`) | Free-text purpose, written to tag 62→08. Blank → tag omitted (BML), or `PAYMENT` (Fahipay) |
|
||||||
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05 (auto-prefixed `+960` if 7-digit local) |
|
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05, normalised to `+960XXXXXXX` |
|
||||||
|
|
||||||
### Generation
|
### What goes on the card
|
||||||
|
|
||||||
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md):
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Name | BML / MIB: `accountBriefName`. Fahipay: the holder's full name (`profileName`, falling back to the saved Fahipay profile's `fullName`) — **not** the generic "Fahipay Wallet" brief name. Contacts: the contact's name. Always uppercased |
|
||||||
|
| QR | The payload below, white modules on the card blue, error correction M, no quiet zone |
|
||||||
|
| Vertical text | The QR's reference (tag 62→05) — see below |
|
||||||
|
| Amount | **Not printed** on the card (neither bank does); it only appears inside the QR, and in BML's vertical text |
|
||||||
|
|
||||||
1. Tag 26 container: GUI (`mv.favara.mpqr`), acquirer BIC, account number, optional mobile, `IPAY`
|
### Vertical text (reference)
|
||||||
2. Acquirer BIC is derived from the source account's bank: `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay)
|
|
||||||
3. Tag 62 container: random 9-char reference + the purpose text
|
|
||||||
4. Tag 80 container: GUI + ISO timestamp
|
|
||||||
5. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
|
|
||||||
|
|
||||||
The rendered card image (bank-styled background plus QR) is shown in-place.
|
Both banks print a short code vertically beside the QR, reading bottom-to-top. It is the same string as the payload's reference, **tag 62→05**, so Thijooree calculates the reference first and uses it for both.
|
||||||
|
|
||||||
|
| Bank | Reference / vertical text | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| BML | Account number converted to **base-32** (digits `0-9A-V`, uppercase), followed by the **amount exactly as typed** (commas removed, no forced decimals), capped at 25 chars | `7730000188362` → `70V3UKKUA`; with amount `100` → `70V3UKKUA100` |
|
||||||
|
| Fahipay | `P` + 9 random uppercase alphanumeric chars. **No amount** | `P2KVTPYL4E` |
|
||||||
|
| Other (MIB contacts) | 9 random uppercase alphanumeric chars | `WHQS0SX5O` |
|
||||||
|
|
||||||
|
BML's base-32 is `AccountNumbertoBase32` from the BML app: `BigInt(account)`, repeatedly `% 32` into the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`. If the account number isn't numeric, Thijooree falls back to a random 9-char reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card Rendering
|
||||||
|
|
||||||
|
Two renderers, chosen by the target's bank. Both return a `Bitmap` shown in `ivQrCard` (`fitCenter`) and used for Share / Save.
|
||||||
|
|
||||||
|
### BML (and MIB) — `renderQrCard()`
|
||||||
|
|
||||||
|
A 1:1 copy of BML app v2.1.47's `ReceiveCard` React Native component (decompiled from the Hermes bundle). All values are BML's StyleSheet values in dp, laid out for a 560 dp reference screen width (`SCREEN_WIDTH_DP`) and drawn at 2 px/dp (`PX_PER_DP`), so the card comes out about 1024 px wide.
|
||||||
|
|
||||||
|
| Element | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Colour | `mmaBlue` `#0E5CA4` everywhere |
|
||||||
|
| Card | Width `sw − 48`. Blue background, radius 20. The white top section is inset 2 dp (top corners 18), which shows as a thin blue border |
|
||||||
|
| Header row | 32 dp from top, 40 dp side margins. Left: `bml_logo_paymv` ("BANK OF MALDIVES" wordmark, from BML's assets) contained in `0.38·sw × 0.38·sw·0.1117`. Right: "PayMV QR", Sofia Pro Bold, `#0E5CA4`, sized to BML's `0.2·sw × 0.2·sw·0.1733` image box, shifted down 1 dp. MIB uses `mib_faisanet_logo` in the same box |
|
||||||
|
| QR panel | 24 dp below the header, 40 dp side margins, radius 16, 24 dp bottom margin |
|
||||||
|
| Name | Roboto (system default) regular, 14 sp, white; 8 + 12 dp above, 16 dp below |
|
||||||
|
| QR | `0.5·sw` square; 37 dp padding below |
|
||||||
|
| Vertical text | Roboto 10 sp, **black at 80 % opacity**, rotated −90°. Centred `railW/1.37 − railW/2` right of the QR's right edge and `(qr + railW/1.5)/2` down from the QR top, where `railW = sw/1.85`. Ellipsized to `railW` |
|
||||||
|
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold (`res/font/sofia_pro_bold.ttf`), `0.046·sw`, letter spacing 1.2 dp, 12 dp vertical padding |
|
||||||
|
|
||||||
|
### Fahipay — `renderFahipayQrCard()`
|
||||||
|
|
||||||
|
Fahipay's app doesn't draw its card; it shows an image generated by Fahipay's server (`api/app/qr/`). Thijooree copies that image's layout, measured in pixels on its **1240 × 1322** canvas. Text is sized so capital letters match the measured cap heights.
|
||||||
|
|
||||||
|
| Element | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Colour | `#005DA3` |
|
||||||
|
| Card | Blue, radius 50. White area inset 6 px (top corners 44) down to y 1174. The blue below it is the footer |
|
||||||
|
| Header row | Square app icon `fahipay_logo` at (94, 94)–(163, 163), then the "FahiPay" wordmark `fahipay_logo_long` at x 178, y 104, 48 px tall — **both, side by side** |
|
||||||
|
| "PayMV QR" | Sofia Pro Bold, blue, right-aligned at x 1147, cap height 30 (cap top y 101) |
|
||||||
|
| QR panel | (166, 218)–(1074, 1126), radius 55 |
|
||||||
|
| Name | Roboto regular, white, centred at x 619, cap height 30 (cap top y 314). Shrinks to fit the panel minus 80 px |
|
||||||
|
| QR | 562 px at (338, 417) |
|
||||||
|
| Vertical text | Montserrat Regular (`res/font/montserrat_regular.ttf`), **blue**, cap height 27, rotated −90°. It sits in the **white margin right of the panel**: text starts at y 1087, baseline at x 1171 |
|
||||||
|
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold, white, cap height 55.5 (cap top y 1220), BML's letter spacing (1.2/25.76 em) |
|
||||||
|
|
||||||
|
Fonts follow the BML card (Sofia Pro Bold, Roboto), except the vertical text, which keeps Fahipay's Montserrat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generation
|
||||||
|
|
||||||
|
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md#generating-a-receive-payment-qr):
|
||||||
|
|
||||||
|
1. Tag 01: `11` (static). Fahipay QRs with an amount use `12` (dynamic)
|
||||||
|
2. Tag 26: GUI (`mv.favara.mpqr`), acquirer BIC ×2 — `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay), account number, optional mobile, `IPAY`
|
||||||
|
3. Tag 54: amount as `%.2f`. If there's no amount: omitted (BML), `***` (Fahipay)
|
||||||
|
4. Tag 59: name, uppercased, max 25 chars
|
||||||
|
5. Tag 60: Fahipay only — `LD` + 4 random digits
|
||||||
|
6. Tag 62: the reference (above) + purpose
|
||||||
|
7. Tag 80: GUI + timestamp `yyyy-MM-dd'T'HH:mm:ss.00000`
|
||||||
|
8. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
|
||||||
|
|
||||||
### Actions
|
### Actions
|
||||||
|
|
||||||
- **Share** (`btnShare`) — exports the rendered card via `FileProvider` + `ACTION_SEND`
|
- **Share** (`btnShare`) — writes `<name>_paymv_qr.png` to the cache and shares it via `FileProvider` + `ACTION_SEND`
|
||||||
- **Save** (`btnSave`, `PayMvQrFragment.kt:78`) — writes the PNG to `MediaStore.Images` / `Pictures/`
|
- **Save** (`btnSave`) — writes `<name>_PayMV_QR.png` to `MediaStore.Images` / `Pictures/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
|
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
|
||||||
|
|
||||||
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`).
|
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
|
||||||
|
|
||||||
|
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Aggregates financing products across banks — MIB promotional deals and BML loa
|
|||||||
|
|
||||||
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
|
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
|
||||||
|
|
||||||
|
MIB deals carry no login tag, so the two sections are ordered as blocks: MIB first if the first MIB login ranks above the first BML login in the user's [login order](00-app-overview.md#login-order), otherwise BML first. BML loans within their section follow `HomeViewModel.accounts`, which is already in login order.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MIB Deals Section
|
## MIB Deals Section
|
||||||
|
|||||||