Compare commits

..
71 Commits
Author SHA1 Message Date
azuwlaaandClaude Opus 5.5 d8e7cf2a24 Merge upstream main (v1.0.31) into feat/chats
Resolve TransferFragment conflicts: keep the new ARG_BML_TXN_ID next to
the chat flags, and pass upstream's capturedToPhoto through showReceipt().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-27 17:00:59 +05:00
azuwlaaandClaude Opus 5.5 97fe63025d chats: throttle BML name lookups
Real-name lookups use BML's account validation, the same search as the
Transfer page, which BML rate-limits per user ("searched too many
times"). Look up only names the saved data can't answer (accounts sent
to that aren't contacts, contacts without a separate real name), at most
3 per run, one run per 6 hours, 15 per day, and back off for a day at the
first refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-27 17:00:11 +05:00
shihaam 0e7f329a4b release v1.0.31
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 3m15s
2026-09-27 00:31:12 +05:00
shihaam 2082e8fd0c Pay with BML Transaction ID 2026-09-27 00:28:12 +05:00
shihaam 97a0cb218f release v1.0.30
Auto Tag on Version Change / check-version (push) Successful in 7s
Build and Release APK / build (push) Successful in 3m50s
2026-09-26 21:29:16 +05:00
shihaam 0158d6dcd8 Always show fullscreen recipt toggle 2026-09-26 21:28:29 +05:00
shihaam 320eaa2ffb release v1.0.29
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m6s
2026-09-26 20:43:34 +05:00
shihaam 1886113ae7 new feature: long press OTP card in OTP codes page to export or update seed 2026-09-26 20:43:14 +05:00
shihaam 41e7bc70e9 update docs
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 19:56:26 +05:00
shihaam d786609bd1 update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-09-26 19:51:33 +05:00
shihaam 2246e5929f update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-09-26 19:50:57 +05:00
shihaam d0eee817ec update docs
Auto Tag on Version Change / check-version (push) Successful in 9s
2026-09-26 19:34:24 +05:00
shihaam af414914c7 update docs
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 17:53:08 +05:00
shihaam ec5b791a45 update docs
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 17:51:01 +05:00
shihaam dd4aed0f94 update docs
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 17:48:14 +05:00
shihaam fd4cdfecac update docs
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 17:47:38 +05:00
shihaam 92f5af76e6 update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-09-26 17:39:32 +05:00
shihaam bc81255b31 update docs
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-09-26 17:35:28 +05:00
shihaam acd11ef3eb update docs
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-09-26 17:22:11 +05:00
shihaam fc778f2a90 update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-26 17:20:30 +05:00
shihaam 778bcc4d75 update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-26 17:14:46 +05:00
shihaam 97c8033014 update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-26 17:12:00 +05:00
shihaam 58d43f33d6 update docs
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-09-26 17:10:04 +05:00
shihaam af791fc5ad update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-26 17:00:58 +05:00
shihaam d8ef3a63c6 add totp docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-26 16:56:35 +05:00
shihaam fbbfdd8537 release v1.0.28
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 3m48s
2026-09-26 07:00:38 +05:00
shihaam 169c3c144c redesigned full screen for MIB recipt 2026-09-26 06:58:27 +05:00
shihaam 71ef7a5b55 MIB recipt redesign to mimick new MIB app and dark mode support
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 06:46:19 +05:00
shihaam 32563ea398 redesigned full screen for BML recipt
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 04:46:42 +05:00
shihaam 41ce8a65ad bml recipt dark theme support
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-26 04:28:19 +05:00
azuwlaaandClaude Opus 5.5 83ca37eade chats: business payments, new bubbles, pinning, Scan to Pay
- Card / Scan to Pay payments show as business chats (from BML
  "Payment Completed" alerts and "Purchase" history), kept apart from
  people; static merchant QRs are kept so a business can be paid again
  from its chat, with a card picker
- Scan to Pay button on the chat list; returns and re-checks after paying
- Compact bubbles with ticks (alert seen / booked in history), receipt
  button, day totals, monthly summary, long-press actions
- Personal accounts confirm with just the dialog over the chat; business
  profiles keep the sheet for the OTP step
- From chip shows account number and balance; MVR -> USD account is
  blocked, USD -> MVR warns about conversion
- Long-press a chat to pin it to the top

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-26 04:10:06 +05:00
shihaam c7d3efa64e update docs: update payMV QR Design
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-26 04:06:06 +05:00
shihaam 2688118f52 update payMV QR Design 2026-09-26 04:05:36 +05:00
shihaam e717360d00 Merge pull request 'fix language toggle alignment' from azuwlaa/android:fix/language-toggle-alignment
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-25 17:53:03 +05:00
shihaam 2f26599931 fix language toggle misaligned on onboarding screen #58
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-09-25 17:50:17 +05:00
azuwlaaandClaude Opus 5.5 f8f9d82440 fix language toggle buttons misaligned
The Dhivehi button sat a few pixels lower than English because the
toggle group aligns children by text baseline and Thaana has different
font metrics. Turn off baseline alignment for the language toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-25 17:35:45 +05:00
azuwlaaandClaude Opus 5.5 da5cca79c6 add chats: transfers grouped per person as a chat
Replaces Contacts in the default bottom nav slot with a Chats page
(Contacts moves to More and opens from the Chats toolbar).

- Chats start when the page is first opened; no old history is loaded
- Transfers come from BML notifications (instant), account history
  (booked later) and in-app receipts, merged into one bubble each
- Chats are keyed by the account holder's real name, looked up via
  BML account validation, so nickname contacts and their MVR/USD
  accounts share one chat; the To bar picks the account to send to
- Sending opens the Transfer page in a bottom sheet over the chat and
  returns to it on success; sent bubbles link to their receipt
- Chat and name data are stored encrypted with CacheEncryption

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-25 17:29:01 +05:00
shihaam a37354fc82 release version 1.0.27
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m5s
2026-09-25 07:03:40 +05:00
shihaam 66568a464a share contact account numbers and PayMV QR for contacts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:53:15 +05:00
shihaam 5210e6207f redegsigned contacts page: removed delete and edit buttons from contacts list, made a new contacts sheet and moved buttons there
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-25 06:35:39 +05:00
shihaam 369c6e4a37 redegsigned OTP page and show next TOTP code
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:18:09 +05:00
shihaam fbedc631c5 prep for fdroid
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:53:53 +05:00
shihaam 7d6c192a63 update ci to use owner-repo name as title and apk name
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:25:09 +05:00
shihaam e7f7590654 release version 1.0.25
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m17s
2026-09-25 04:45:25 +05:00
shihaam a048be939c Merge branch 'fix/show-currency-of-other-account-in-transfer-page'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:42:36 +05:00
shihaam 102a986201 Merge branch 'fix/contact-add-page-account-number-enter-behaviour'
Auto Tag on Version Change / check-version (push) Failing after 3s
# Conflicts:
#	app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt
2026-09-25 04:37:22 +05:00
shihaam 440db13a0f also add search button to keyboard in transfer page to feild and move focus to amount feild on search search or to field fill 2026-09-25 04:35:31 +05:00
shihaam 0dc81fef62 Merge branch 'feat/hide-accounts'
Auto Tag on Version Change / check-version (push) Failing after 3s
2026-09-25 04:21:08 +05:00
shihaam 7e52b510a1 disable unnessary api requests on saving profile/account disable and also disable all accounts for a profile when that profile is disabled 2026-09-25 04:20:40 +05:00
shihaam 0d7833af5c Merge branch 'feat/search-saved-contacts-in-account-number-field'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:05:05 +05:00
shihaam 9e45e36c0a Merge branch 'feat/login-sort-order'
Auto Tag on Version Change / check-version (push) Failing after 4s
2026-09-25 04:02:12 +05:00
shihaam c883b6b521 added margin between icon and edge of selection in login list 2026-09-25 03:52:07 +05:00
shihaam 5dc37aadf0 update docs 2026-09-25 03:46:35 +05:00
shihaam 1e3cf3420b login sorting fixed 2026-09-25 03:43:58 +05:00
shihaam d98dcfd407 auto select from account after to account is selected 2026-09-25 03:19:38 +05:00
flamexode 3b55fa30a8 show currency after searching for account
fixes #40
2026-09-21 21:56:23 +05:00
flamexode 7f0f2707a1 default name to title case of API response name 2026-09-21 18:02:56 +05:00
flamexode 117733b766 change keyboard enter behaviour of add contacts account number field
fixes #43
2026-09-21 17:58:48 +05:00
flamexode d769df6f6b add ability to hide accounts
implements #50
2026-09-21 17:47:26 +05:00
flamexode caf0db60da autocomplete with saved contacts in transfer page
implements #49
2026-09-21 17:47:08 +05:00
flamexode b48022d249 add ability to reorder logins
fixes #47
2026-09-21 17:14:06 +05:00
shihaam 7574a5e8b0 release v1.0.25 - idk numbers
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m33s
2026-09-21 16:30:19 +05:00
shihaam 992c8d4364 release v1.0.24
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m19s
2026-09-21 15:26:40 +05:00
shihaam 80fd238195 fix BML POS QRs 2026-09-21 15:26:18 +05:00
shihaam 0357d7e0bc release v1.0.23
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m28s
2026-09-21 08:16:39 +05:00
shihaam 71dd654edc optimize mfaisa QR, fix bug that fails to switch to default account after ooredoo QR is cancelled and new BML QR is scanned
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 08:15:23 +05:00
shihaam a75d8e420a split mfaisa qr pay
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-21 07:56:46 +05:00
shihaam ca6ecc4283 split fahipay transfer
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 07:41:36 +05:00
shihaam f59c2be6c4 split mib transfer
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 07:26:54 +05:00
shihaam eb019a20a0 attempt to fix QR scanner
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-09-21 07:16:47 +05:00
shihaam c356762baa split do bml tranfers 2026-09-21 07:13:59 +05:00
192 changed files with 8998 additions and 1855 deletions
+13 -16
View File
@@ -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: |
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
if [ -f "$CHANGELOG" ]; then
cp "$CHANGELOG" release_notes.md
else
echo "No changelog found at $CHANGELOG"
echo "No release notes" > release_notes.md echo "No release notes" > release_notes.md
# VERSION="${{ gitea.ref_name }}" fi
# VERSION="${VERSION#v}"
#
# awk -v ver="$VERSION" '
# BEGIN { found=0 }
# /^## \[/ {
# if (found) exit
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
# }
# 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}"
+5 -1
View File
@@ -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.
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 32
versionName = "1.0.22" versionName = "1.0.31"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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,686 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.delay
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.models.BankAccount
import sh.sar.basedbank.databinding.FragmentChatBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ReceiptStore
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ChatAccount
import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread
/**
* One person's transfers as a chat, or one business's card / Scan to Pay payments.
*
* A person can have several accounts (e.g. MVR and USD); the "To" bar picks which one to send to.
* Sending opens [TransferFragment] in a sheet, prefilled with the recipient, source account,
* amount and note, so the usual confirm/OTP flow still applies. A monthly summary sits on top,
* and long-pressing a bubble offers Send again / receipt / copy.
*/
class ChatFragment : Fragment(), ContactSheetHost {
private var _binding: FragmentChatBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val app get() = requireActivity().application as BasedBankApp
private var peerKey = ""
private var header: ViewChatHeaderBinding? = null
private val adapter = ChatMessagesAdapter(
onReceiptClick = { openReceipt(it.receiptKey) },
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
)
private var thread: ChatThread? = null
private var fromAccounts: List<BankAccount> = emptyList()
private var selectedFrom: BankAccount? = null
private var selectedTo: ChatAccount? = null
private var merchantQr: String? = null
private var cardAccounts: List<BankAccount> = emptyList()
private var selectedCard: BankAccount? = null
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
private var payStartedAt = 0L
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentChatBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
binding.rvMessages.adapter = adapter
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) {
adapter.setHideAmounts(it)
thread?.let { t -> bindSummary(t) }
bindFromChip()
}
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
cardAccounts = accounts.filter {
it.bank == "BML" && it.profileType in CARD_TYPES && it.statusDesc.equals("Active", ignoreCase = true)
}
bindFromAccounts()
}
binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
binding.btnSaveContact.setOnClickListener { openContact() }
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
binding.btnSend.setOnClickListener { send() }
binding.etNote.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
}
binding.btnPayAgain.setOnClickListener { payBusiness() }
// Transfers started from this chat come straight back here instead of opening the receipt.
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
onTransferDone()
}
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(ARG_PEER_KEY, peerKey)
}
private fun loadThread() {
val ctx = requireContext().applicationContext
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
val t = threads.firstOrNull { it.peerKey == peerKey }
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
if (_binding == null) return@launch
thread = t
if (t == null) return@launch
peerKey = t.peerKey
adapter.showDestination = t.accounts.size > 1
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
bindSummary(t)
bindFromAccounts()
val previous = selectedTo
val to = t.accounts.firstOrNull { it.account == previous?.account }
?: t.accounts.firstOrNull { it.account == t.peerAccount }
selectTo(to, matchFromCurrency = previous == null)
updateToolbar()
}
}
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
val b = _binding ?: return
val t = thread ?: return
selectedTo = to
if (t.isMerchant) {
// Businesses paid by card / Scan to Pay: history only, nothing to send to or save.
b.toAccountBar.visibility = View.GONE
b.saveContactBar.visibility = View.GONE
b.composer.visibility = View.GONE
b.tvCannotSend.visibility = View.GONE
b.btnPayAgain.visibility = View.VISIBLE
bindMerchantQr(t)
return
}
b.btnPayAgain.visibility = View.GONE
// One account: it's shown in the header. Several: this bar picks between them.
b.toAccountBar.visibility = if (to == null || t.accounts.size < 2) View.GONE else View.VISIBLE
if (to != null) {
b.tvToAccount.text = to.account
b.tvToCurrency.text = to.currency
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
b.tvToNickname.text = nickname
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
}
// Offer to save the selected account (or the person, when no account is known yet).
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
val canSend = to != null
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
// Sending to a USD account: default to a USD source account, and likewise for MVR.
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
}
updateCurrencyWarning()
}
private fun showRecipientPicker() {
val t = thread ?: return
if (t.accounts.size < 2) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (account in t.accounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
?: getString(R.string.chat_not_in_contacts)
row.tvDropdownAccountNumber.text = account.account
row.tvDropdownBalance.text = account.currency
row.ivDropdownCardLogo.visibility = View.GONE
// A USD account can only be paid from a USD account.
val unavailable = account.currency.equals("USD", ignoreCase = true) && !hasUsdSource()
row.tvDropdownAccountType.text = getString(R.string.chat_needs_usd_source)
row.tvDropdownAccountType.visibility = if (unavailable) View.VISIBLE else View.GONE
row.root.alpha = if (unavailable) 0.4f else 1f
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
if (!unavailable) row.root.setOnClickListener {
selectTo(account, matchFromCurrency = true)
updateToolbar()
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
this.text = text
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, pad / 2)
}
private fun bindFromAccounts() {
val b = _binding ?: return
if (fromAccounts.isEmpty()) return
// Default to the account last used with this person.
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
selectFrom(selectedFrom!!)
}
private fun onTransferDone() {
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
}
binding.etAmount.text = null
binding.etNote.text = null
// The receipt shows the new bubble right away; history catches up a little later and
// replaces it with the bank's own entry.
sync()
viewLifecycleOwner.lifecycleScope.launch {
delay(HISTORY_CATCH_UP_MS)
sync()
}
// Only offer the receipt this payment made; QR payments don't save one.
val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
val merchant = thread?.isMerchant == true
Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
.setAnchorView(if (binding.composer.visibility == View.VISIBLE) binding.composer else binding.btnPayAgain)
.apply { if (receipt != null) setAction(R.string.chat_view_receipt) { openReceipt(receipt.savedAt.toString()) } }
.show()
}
private fun openReceipt(receiptKey: String) {
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
if (entry == null) {
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
return
}
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
}
private fun sync() {
val ctx = requireContext().applicationContext
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
if (_binding != null) loadThread()
}
}
private fun selectFrom(account: BankAccount) {
val b = _binding ?: return
selectedFrom = account
bindFromChip()
b.tilAmount.prefixText = account.currencyName
updateCurrencyWarning()
}
/** The chip shows the account (or, in a business chat, the card) paid from, with its balance. */
private fun bindFromChip() {
val b = _binding ?: return
val account = (if (thread?.isMerchant == true) selectedCard else selectedFrom) ?: return
val balance = AccountListParser.from(account)?.balance.orEmpty()
val hide = viewModel.hideAmounts.value ?: false
val money = when {
balance.isBlank() -> account.currencyName
hide -> AccountHistoryAdapter.maskAmount(balance)
else -> balance
}
b.btnFrom.text = getString(R.string.chat_from_chip, account.accountBriefName, account.accountNumber, money)
b.btnFrom.contentDescription = getString(R.string.chat_from_account) + ": " + b.btnFrom.text
// A card shows its network logo up front, in full colour; an account keeps the dropdown arrow.
val logo = if (thread?.isMerchant == true) sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(account) else null
val density = resources.displayMetrics.density
if (logo != null) {
b.btnFrom.setIconResource(logo)
b.btnFrom.iconTint = null
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_START
b.btnFrom.iconSize = (28 * density).toInt()
} else {
b.btnFrom.setIconResource(R.drawable.ic_arrow_right)
b.btnFrom.iconTint = android.content.res.ColorStateList.valueOf(
MaterialColors.getColor(b.btnFrom, com.google.android.material.R.attr.colorOnSecondaryContainer))
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_END
b.btnFrom.iconSize = (16 * density).toInt()
}
}
private fun showCardPicker() {
if (cardAccounts.isEmpty()) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_card)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (card in cardAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, card)
sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(card)?.let {
row.ivDropdownCardLogo.setImageResource(it)
row.ivDropdownCardLogo.visibility = View.VISIBLE
}
if (card.accountNumber == selectedCard?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
row.root.setOnClickListener {
selectedCard = card
bindFromChip()
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
/**
* MVR can't be sent into a USD account: say so and disable Send. USD into an MVR account is
* converted by BML at its rate: allowed, with a warning.
*/
private fun updateCurrencyWarning() {
val b = _binding ?: return
val from = selectedFrom?.currencyName.orEmpty()
val to = selectedTo?.currency.orEmpty()
val blocked = isBlockedPair(from, to)
val converts = from.equals("USD", ignoreCase = true) && to.equals("MVR", ignoreCase = true)
b.tvCurrencyWarning.text = when {
blocked -> getString(R.string.chat_currency_blocked)
converts -> getString(R.string.chat_currency_warning)
else -> null
}
b.tvCurrencyWarning.visibility = if (blocked || converts) View.VISIBLE else View.GONE
b.btnSend.isEnabled = !blocked
}
/** MVR → USD account: BML doesn't allow it. */
private fun isBlockedPair(fromCurrency: String, toCurrency: String) =
fromCurrency.equals("MVR", ignoreCase = true) && toCurrency.equals("USD", ignoreCase = true)
private fun hasUsdSource() = fromAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
/** Looks up a saved static QR for this business: with one, the button pays it directly. */
private fun bindMerchantQr(t: ChatThread) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val qr = withContext(Dispatchers.IO) { ChatStore.merchantQr(ctx, t.peerName) }
val b = _binding ?: return@launch
merchantQr = qr
b.btnPayAgain.text = if (qr != null) getString(R.string.chat_pay_business, t.peerName)
else getString(R.string.chat_pay_again)
// A saved QR is paid from a card picked here: show just the card chip above the button.
b.composer.visibility = if (qr != null && cardAccounts.isNotEmpty()) View.VISIBLE else View.GONE
b.amountRow.visibility = View.GONE
b.tvCurrencyWarning.visibility = View.GONE
if (qr != null && selectedCard == null) {
val lastCard = t.messages.lastOrNull { m -> cardAccounts.any { it.accountNumber == m.accountNumber } }?.accountNumber
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
selectedCard = cardAccounts.firstOrNull { it.accountNumber == lastCard }
?: cardAccounts.firstOrNull { it.accountNumber == defaultCard }
?: cardAccounts.firstOrNull()
}
bindFromChip()
}
}
/** Saved static QR: pay it in the sheet over the chat. Otherwise: open the scanner. */
private fun payBusiness() {
val qr = merchantQr
if (qr == null) {
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment.newInstanceWithAutoScan())
return
}
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
val transfer = TransferFragment.newInstanceFromBmlQr(qr, selectedCard?.accountNumber, returnOnSuccess = true)
payStartedAt = System.currentTimeMillis()
hideKeyboard()
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
}
/** This month with this person (sent / received / net) or at this business (spent / payments). */
private fun bindSummary(t: ChatThread) {
val b = _binding ?: return
val monthStart = java.util.Calendar.getInstance().apply {
set(java.util.Calendar.DAY_OF_MONTH, 1)
set(java.util.Calendar.HOUR_OF_DAY, 0); set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0); set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis
val thisMonth = t.messages.filter { it.timeMillis >= monthStart }
if (thisMonth.isEmpty()) { b.summaryCard.visibility = View.GONE; return }
// One currency per card: the selected account's, else the one used most this month.
val currency = selectedTo?.currency?.takeIf { c -> thisMonth.any { it.currency == c } }
?: thisMonth.groupingBy { it.currency }.eachCount().maxByOrNull { it.value }!!.key
val ms = thisMonth.filter { it.currency == currency }
val hide = viewModel.hideAmounts.value ?: false
fun money(v: Double) = if (hide) "$currency ••••" else "$currency ${"%,.2f".format(v)}"
val month = java.text.SimpleDateFormat("MMMM", java.util.Locale.getDefault()).format(java.util.Date())
b.summaryCard.visibility = View.VISIBLE
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
if (t.isMerchant) {
b.tvSumLabel1.text = getString(R.string.chat_sum_spent, month)
b.tvSumValue1.text = money(sent)
b.sumColumn2.visibility = View.GONE
b.tvSumLabel3.text = getString(R.string.chat_sum_payments)
b.tvSumValue3.text = ms.size.toString()
return
}
val received = ms.filter { !it.isSent }.sumOf { it.amount }
val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
b.sumColumn2.visibility = View.VISIBLE
b.tvSumLabel1.text = getString(R.string.chat_sum_sent, month)
b.tvSumValue1.text = money(sent)
b.tvSumValue1.setTextColor(MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorError))
b.tvSumLabel2.text = getString(R.string.chat_sum_received)
b.tvSumValue2.text = money(received)
b.tvSumValue2.setTextColor(android.graphics.Color.parseColor(if (night) "#81C784" else "#2E7D32"))
b.tvSumLabel3.text = getString(R.string.chat_sum_net)
val net = received - sent
b.tvSumValue3.text = if (hide) "••••" else (if (net < 0) "−" else "+") + "%,.2f".format(kotlin.math.abs(net))
}
/** Long-press on a bubble: send the same again, open its receipt, or copy its reference. */
private fun showMessageMenu(m: sh.sar.basedbank.util.ChatMessage, anchor: View) {
val t = thread ?: return
val menu = android.widget.PopupMenu(requireContext(), anchor, android.view.Gravity.END)
val amount = "%.2f".format(kotlin.math.abs(m.amount))
val canSendAgain = m.isSent && !t.isMerchant && selectedTo != null
if (canSendAgain) menu.menu.add(0, 1, 0, getString(R.string.chat_send_again, "${m.currency} $amount"))
if (m.receiptKey.isNotBlank()) menu.menu.add(0, 2, 1, R.string.chat_view_receipt_full)
if (m.reference.isNotBlank()) menu.menu.add(0, 3, 2, R.string.chat_copy_reference)
if (menu.menu.size() == 0) return
menu.setOnMenuItemClickListener { item ->
when (item.itemId) {
1 -> {
// Same account it went to, when that account is still one of theirs.
t.accounts.firstOrNull { it.account == m.peerAccount }?.let { selectTo(it, matchFromCurrency = true) }
binding.etAmount.setText(amount)
binding.etNote.setText(m.note)
send()
}
2 -> openReceipt(m.receiptKey)
3 -> {
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("reference", m.reference))
Snackbar.make(binding.root, R.string.chat_reference_copied, Snackbar.LENGTH_SHORT).show()
}
}
true
}
menu.show()
}
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
val display = AccountListParser.from(account)
row.tvDropdownAccountName.text = account.accountBriefName
row.tvDropdownAccountNumber.text = account.accountNumber
val balance = display?.balance.orEmpty()
val hide = viewModel.hideAmounts.value ?: false
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
val type = display?.typeLabel.orEmpty()
row.tvDropdownAccountType.text = type
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
row.ivDropdownCardLogo.visibility = View.GONE
}
private fun showAccountPicker() {
if (fromAccounts.isEmpty()) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (account in fromAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, account)
// MVR can't go into a USD account: show those accounts, but not selectable.
val blocked = isBlockedPair(account.currencyName, selectedTo?.currency.orEmpty())
if (blocked) {
row.tvDropdownAccountType.text = getString(R.string.chat_cant_send_mvr_to_usd)
row.tvDropdownAccountType.visibility = View.VISIBLE
row.root.alpha = 0.4f
}
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
if (!blocked) row.root.setOnClickListener {
selectFrom(account)
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
private fun send() {
val t = thread ?: return
val from = selectedFrom ?: return
val to = selectedTo ?: return
if (isBlockedPair(from.currencyName, to.currency)) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
if (amount == null || amount <= 0.0) {
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
return
}
binding.tilAmount.error = null
val transfer = TransferFragment.newInstanceFromQr(
accountNumber = to.account,
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
amount = "%.2f".format(amount),
remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
fromAccountNumber = from.accountNumber,
returnOnSuccess = true,
autoConfirm = true
)
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
payStartedAt = System.currentTimeMillis()
hideKeyboard()
if (isBusinessProfile(from)) {
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
} else {
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
// confirm dialog appears over the chat. A new send replaces any earlier one.
childFragmentManager.beginTransaction()
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
.commit()
}
}
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
private fun isBusinessProfile(account: BankAccount): Boolean {
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
}
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
private fun hideKeyboard() {
val b = _binding ?: return
val focused = b.root.findFocus() ?: return
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
?.hideSoftInputFromWindow(focused.windowToken, 0)
focused.clearFocus()
}
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
private fun updateToolbar() {
val t = thread ?: return
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
header = h
h.root.setOnClickListener { openContact() }
}
h.tvHeaderName.text = t.peerName
// Real name (when titled with a nickname), plus the account and currency when there's only one.
val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
).joinToString(" · ")
h.tvHeaderAccount.text = subtitle
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
val sizePx = (40 * resources.displayMetrics.density).toInt()
if (t.isMerchant) {
h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
} else {
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
}
requireActivity().title = t.peerName
bar.setDisplayShowTitleEnabled(false)
bar.setDisplayShowCustomEnabled(true)
bar.customView = h.root
}
private fun openContact() {
val t = thread ?: return
if (t.isMerchant) return
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
if (display != null) {
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
} else {
// Not a saved contact yet: offer to save them, prefilled with what we know.
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
AddContactSheetFragment.newInstance(
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
accountNumber = selectedTo?.account,
recipientName = t.realName.ifBlank { t.peerName },
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
).show(childFragmentManager, "add_contact")
}
}
override fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber,
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.imageHash
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
override fun confirmDelete(contact: ContactDisplay) {
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
}
override fun onResume() {
super.onResume()
updateToolbar()
}
override fun onPause() {
super.onPause()
(activity as? AppCompatActivity)?.supportActionBar?.apply {
setDisplayShowCustomEnabled(false)
setDisplayShowTitleEnabled(true)
customView = null
}
}
override fun onDestroyView() {
super.onDestroyView()
header = null
_binding = null
}
companion object {
private const val ARG_PEER_KEY = "peer_key"
private const val HISTORY_CATCH_UP_MS = 10_000L
private const val TAG_HIDDEN_TRANSFER = "transfer_hidden"
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
private val CARD_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
fun newInstance(peerKey: String) = ChatFragment().apply {
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
}
}
}
@@ -0,0 +1,205 @@
package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.format.DateUtils
import android.text.style.StyleSpan
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
import sh.sar.basedbank.databinding.ItemChatDateBinding
import sh.sar.basedbank.util.ChatMessage
/**
* One chat's transfers or payments as compact bubbles — sent / paid on the right, received on the
* left — with ✓ (seen in a BML alert) or ✓✓ (booked in history). Day separators carry the day's
* totals.
*/
class ChatMessagesAdapter(
private val onReceiptClick: (ChatMessage) -> Unit,
private val onLongPress: (ChatMessage, View) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/** Rows of the list: a day separator or a bubble. */
sealed class Item {
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
data class Bubble(val message: ChatMessage) : Item()
}
private val items = mutableListOf<Item>()
private var hideAmounts = false
private var todayLabel = ""
private var yesterdayLabel = ""
/** When the person has several accounts, sent bubbles say which one the money went to. */
var showDestination = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
/** [messages] must be oldest first. */
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
this.todayLabel = todayLabel
this.yesterdayLabel = yesterdayLabel
items.clear()
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
items.add(Item.DateChip(day, dayMessages))
dayMessages.forEach { items.add(Item.Bubble(it)) }
}
notifyDataSetChanged()
}
override fun getItemCount() = items.size
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.DateChip -> TYPE_DATE
is Item.Bubble -> TYPE_BUBBLE
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is Item.DateChip -> (holder as DateVH).bind(item)
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
}
}
private fun formatAmount(m: ChatMessage) =
if (hideAmounts) "${m.currency} ••••••" else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
private fun isNight(view: View) =
(view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
private fun ticksFor(m: ChatMessage) = if (m.isBooked) "✓✓" else "✓"
private fun tickColor(view: View, m: ChatMessage, fallback: Int) =
if (m.isBooked) Color.parseColor(if (isNight(view)) "#90CAF9" else "#1565C0") else fallback
inner class DateVH(private val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(item: Item.DateChip) {
val first = item.messages.first()
val label = when {
DateUtils.isToday(first.timeMillis) -> todayLabel
DateUtils.isToday(first.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
else -> AccountHistoryAdapter.formatDateHeader(first.date)
}
val text = SpannableStringBuilder(label)
text.setSpan(StyleSpan(Typeface.BOLD), 0, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (!hideAmounts) {
// Day totals per currency: ↑ sent, ↓ received.
val parts = item.messages.groupBy { it.currency }.map { (ccy, ms) ->
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
val received = ms.filter { !it.isSent }.sumOf { it.amount }
listOfNotNull(
"↑ %.2f".format(sent).takeIf { sent > 0 },
"↓ %.2f".format(received).takeIf { received > 0 }
).joinToString(" · ").let { if (item.messages.map { m -> m.currency }.toSet().size > 1) "$ccy $it" else it }
}.filter { it.isNotBlank() }
if (parts.isNotEmpty()) text.append(" ").append(parts.joinToString(" "))
}
b.tvDate.text = text
}
}
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(m: ChatMessage) {
val ctx = b.root.context
val sent = m.isSent
val night = isNight(b.root)
val density = ctx.resources.displayMetrics.density
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
// Rounded, with a small "tail" corner on the sender's side at the bottom.
val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
val big = 18 * density
val tail = 4 * density
val tailOnRight = sent != rtl
b.cardBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCornerSize(big).setTopRightCornerSize(big)
.setBottomLeftCornerSize(if (tailOnRight) big else tail)
.setBottomRightCornerSize(if (tailOnRight) tail else big)
.build()
// Sent: tinted. Received: neutral with an outline, so the two read apart on any theme.
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
else Color.parseColor(if (night) "#2A2C2E" else "#FFFFFF")
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
val green = Color.parseColor(if (night) "#81C784" else "#2E7D32")
b.cardBubble.setCardBackgroundColor(bg)
b.cardBubble.strokeWidth = if (sent) 0 else density.toInt()
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
b.tvAmount.setTextColor(if (sent) fg else green)
b.tvNote.text = m.note
b.tvNote.setTextColor(fg)
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
b.btnReceipt.setColorFilter(fg)
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
b.tvAccount.text = when {
!sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
showDestination && m.peerAccount.isNotBlank() ->
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
else -> m.accountDisplayName
}
b.tvAccount.setTextColor(fg)
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
b.tvTime.setTextColor(fg)
b.tvTicks.text = ticksFor(m)
b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
}
}
private fun showDetail(anchor: View, m: ChatMessage) {
val ctx = anchor.context
val details = buildString {
val sign = if (m.isSent) "-" else "+"
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
append("Account\n${m.accountDisplayName}\n\n")
append("Status\n${ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)}")
}
MaterialAlertDialogBuilder(ctx)
.setTitle(m.peerName)
.setMessage(details)
.setPositiveButton("OK", null)
.show()
}
companion object {
private const val TYPE_DATE = 0
private const val TYPE_BUBBLE = 1
}
}
@@ -0,0 +1,172 @@
package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBinding
import sh.sar.basedbank.util.ChatThread
import sh.sar.basedbank.util.ContactImageCache
/**
* Telegram-style chat list: one row per person or business, latest transfer as the preview.
* Pinned chats come first; long-press selects a chat (for pinning).
*/
class ChatsAdapter(
private val onChatClick: (ChatThread) -> Unit,
private val onChatLongPress: (ChatThread) -> Unit
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
private var allThreads: List<ChatThread> = emptyList()
private var displayed: List<ChatThread> = emptyList()
private var pinned: List<String> = emptyList()
private var searchQuery = ""
private var hideAmounts = false
/** Chat key shown as selected (long-pressed), or null. */
var selectedKey: String? = null
set(value) { field = value; notifyDataSetChanged() }
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
allThreads = threads
pinned = pinnedKeys
applyFilter()
}
fun setSearch(query: String) {
searchQuery = query
applyFilter()
}
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
fun isPinned(key: String) = key in pinned
val isEmpty get() = displayed.isEmpty()
private fun applyFilter() {
val matching = if (searchQuery.isBlank()) allThreads else allThreads.filter {
it.peerName.contains(searchQuery, ignoreCase = true) ||
it.realName.contains(searchQuery, ignoreCase = true) ||
it.peerAccount.contains(searchQuery) ||
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
}
// Pinned first, most recently pinned on top; the rest stay newest first.
val (pinnedThreads, others) = matching.partition { it.peerKey in pinned }
displayed = pinnedThreads.sortedBy { pinned.indexOf(it.peerKey) } + others
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
holder.binding.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
}
holder.binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
pos != RecyclerView.NO_POSITION
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
override fun getItemCount() = displayed.size
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(thread: ChatThread) {
val ctx = binding.root.context
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val last = thread.last
binding.tvName.text = thread.peerName
val amount = if (hideAmounts) "${last.currency} ••••••"
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
val ticks = if (last.isBooked) " ✓✓" else " ✓"
binding.tvPreview.text = when {
last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
last.isSent -> {
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
ctx.getString(R.string.chat_preview_sent, amount) + note + ticks
}
else -> ctx.getString(R.string.chat_preview_received, amount)
}
binding.tvPreview.setTextColor(
if (!last.isSent) Color.parseColor(if (night) "#81C784" else "#2E7D32")
else MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
)
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
else AccountHistoryAdapter.formatDateOnly(last.date)
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
if (thread.isMerchant) {
// Businesses get a rounded-square shop icon; people keep round initials or a photo.
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(14 * ctx.resources.displayMetrics.density).build()
binding.ivAvatar.setImageBitmap(shopBitmap(binding.root, merchantColor(thread.peerKey), sizePx))
} else {
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(sizePx / 2f).build()
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
}
val isPinned = thread.peerKey in pinned
binding.ivPin.visibility = if (isPinned) View.VISIBLE else View.GONE
// Selected (long-pressed): tinted; pinned: a soft surface; otherwise the normal ripple.
when {
thread.peerKey == selectedKey -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorErrorContainer))
isPinned -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorSurfaceContainerHigh))
else -> binding.root.setBackgroundResource(selectableBackground(binding.root))
}
}
}
private fun selectableBackground(view: View): Int {
val out = android.util.TypedValue()
view.context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true)
return out.resourceId
}
companion object {
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
private val MERCHANT_COLORS = listOf("#6D4C41", "#546E7A", "#5D4037", "#455A64", "#8D6E63", "#37474F")
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
fun merchantColor(peerKey: String) = MERCHANT_COLORS[(peerKey.hashCode() and 0x7fffffff) % MERCHANT_COLORS.size]
/** Shop icon on a coloured square; the image view's shape rounds the corners. */
fun shopBitmap(view: View, colorHex: String, sizePx: Int): Bitmap {
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
canvas.drawRect(RectF(0f, 0f, sizePx.toFloat(), sizePx.toFloat()),
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(colorHex) })
val icon = ContextCompat.getDrawable(view.context, R.drawable.ic_store)?.mutate() ?: return bm
val inset = sizePx / 4
icon.setBounds(inset, inset, sizePx - inset, sizePx - inset)
icon.draw(canvas)
return bm
}
}
}
@@ -0,0 +1,261 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentChatsBinding
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread
import java.text.DateFormat
import java.util.Date
/**
* Transfers grouped by person or business, shown as a chat list. Contacts is reachable from the
* toolbar; long-press a chat to pin it to the top.
*/
class ChatsFragment : Fragment() {
private var _binding: FragmentChatsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val app get() = requireActivity().application as BasedBankApp
private lateinit var adapter: ChatsAdapter
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
onQrScanned(raw)
}
private var actionMode: ActionMode? = null
private var syncedThisView = false
private var syncedWithContacts = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentChatsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ChatsAdapter(
onChatClick = { thread ->
if (actionMode != null) actionMode?.finish()
else (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
},
onChatLongPress = { thread -> startSelection(thread) }
)
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
binding.rvChats.adapter = adapter
binding.etSearch.addTextChangedListener { text ->
adapter.setSearch(text?.toString() ?: "")
updateEmptyView()
}
binding.swipeRefresh.setOnRefreshListener { sync() }
// Scan to Pay: opens the QR scanner; after paying it comes back here and refreshes.
binding.fabScanPay.setOnClickListener {
scanLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
sync()
// BML's alert often lands a few seconds after the payment: check again shortly.
viewLifecycleOwner.lifecycleScope.launch {
delay(RECHECK_AFTER_PAYMENT_MS)
if (_binding != null) sync()
}
}
// Keep the button above the system navigation bar when there's no bottom bar.
val fabMargin = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabScanPay) { v, insets ->
val bottomNav = NavCustomization.getNavMode(
requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)
) == NavCustomization.NAV_MODE_BOTTOM
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
(v.layoutParams as android.widget.FrameLayout.LayoutParams).bottomMargin =
fabMargin + if (bottomNav) 0 else navBar
v.requestLayout()
insets
}
requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
.setIcon(R.drawable.ic_contacts)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.itemId != R.id.action_open_contacts) return false
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
return true
}
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
// Contacts give chats a saved name and an account number to send to.
(activity as? HomeActivity)?.loadAllContacts()
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
loadThreads()
// Contacts usually load after the first sync; sync again so their real names get looked up.
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
}
// Accounts arrive after login; sync once they are available.
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
}
loadThreads()
}
private fun sync() {
syncedThisView = true
binding.swipeRefresh.isRefreshing = true
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
if (contacts.isNotEmpty()) syncedWithContacts = true
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
_binding?.swipeRefresh?.isRefreshing = false
loadThreads()
}
}
private fun loadThreads() {
val ctx = requireContext().applicationContext
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
val (threads, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
if (_binding == null) return@launch
adapter.updateThreads(threads, pins)
updateEmptyView()
}
}
/**
* A scanned QR: BML Scan to Pay (static or card-machine) opens the payment, a PayMV QR opens a
* transfer. Either way, success comes back to this list, which then refreshes.
*/
private fun onQrScanned(raw: String) {
val activity = requireActivity() as HomeActivity
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
activity.showWithBackStack(TransferFragment.newInstanceFromBmlQr(bmlTarget, null, returnOnSuccess = true))
return
}
val qr = PaymvQrParser.parse(raw)
val account = qr?.accountNumber
if (account == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return
}
activity.showWithBackStack(TransferFragment.newInstanceFromQr(
accountNumber = account,
displayName = qr.merchantName ?: account,
amount = qr.amount,
remarks = qr.purpose,
returnOnSuccess = true
))
}
/** Long-press: select the chat and offer Pin / Unpin in the top bar. */
private fun startSelection(thread: ChatThread) {
adapter.selectedKey = thread.peerKey
val pinned = adapter.isPinned(thread.peerKey)
val callback = object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = getString(R.string.chat_selected_one)
menu.add(Menu.NONE, R.id.action_pin_chat, 0, if (pinned) R.string.chat_unpin else R.string.chat_pin)
.setIcon(R.drawable.ic_pin)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (item.itemId != R.id.action_pin_chat) return false
setPinned(thread, !pinned, offerUndo = true)
mode.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
actionMode = null
adapter.selectedKey = null
}
}
actionMode?.finish()
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
}
private fun setPinned(thread: ChatThread, pin: Boolean, offerUndo: Boolean) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.setPinned(ctx, thread.peerKey, pin) }
loadThreads()
val b = _binding ?: return@launch
if (!offerUndo) return@launch
Snackbar.make(b.root, getString(if (pin) R.string.chat_pinned_msg else R.string.chat_unpinned_msg, thread.peerName), Snackbar.LENGTH_LONG)
.setAction(R.string.chat_undo) { setPinned(thread, !pin, offerUndo = false) }
.show()
}
}
private fun updateEmptyView() {
val b = _binding ?: return
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
if (!adapter.isEmpty) return
val since = ChatStore.sinceMillis(requireContext())
b.emptyView.text = when {
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
since > 0L -> getString(R.string.chats_empty_since,
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
else -> getString(R.string.chats_empty)
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_chats)
}
override fun onDestroyView() {
actionMode?.finish()
super.onDestroyView()
_binding = null
}
companion object {
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
}
}
@@ -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 contact is tapped in [ContactsFragment] or a chat header. */
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? ContactSheetHost
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)
}
}
}
@@ -0,0 +1,9 @@
package sh.sar.basedbank.ui.home
import sh.sar.basedbank.util.ContactDisplay
/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */
interface ContactSheetHost {
fun openTransfer(contact: ContactDisplay)
fun confirmDelete(contact: ContactDisplay)
}
@@ -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,24 +103,22 @@ 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 {
binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.name, contact.bankColor)
)
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = binding.ivContactPhoto.context.resources val sizePx = binding.ivContactPhoto.context.resources
.getDimensionPixelSize(android.R.dimen.app_icon_size) .getDimensionPixelSize(android.R.dimen.app_icon_size)
.coerceAtLeast(96) .coerceAtLeast(96)
binding.ivContactPhoto.setImageBitmap(
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
)
}
}
}
}
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY } val bgColor = try { Color.parseColor(colorHex) } catch (e: 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)
@@ -139,5 +133,3 @@ class ContactsAdapter(
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm 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
@@ -33,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() { class ContactsFragment : Fragment(), ContactSheetHost {
private var _binding: FragmentContactsBinding? = null private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
@@ -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")
}
override 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) { override 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
@@ -176,6 +176,7 @@ class HomeActivity : AppCompatActivity() {
val frag = when (item.itemId) { val frag = when (item.itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_chats -> ChatsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
@@ -244,9 +245,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)
@@ -473,6 +474,7 @@ fun applyNavLabelVisibility() {
val dest = fragment ?: when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_chats -> ChatsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
@@ -528,9 +530,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 +780,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 +957,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 +996,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 +1363,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 +1388,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)
@@ -32,6 +32,7 @@ object NavCustomization {
/** All items that can occupy either a bottom nav slot or the "More" screen. */ /** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf( val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts), NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats),
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts), NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer), NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr), NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
@@ -51,14 +52,14 @@ object NavCustomization {
fun getSlots(prefs: SharedPreferences): List<Int> = listOf( fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts), keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts), keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats),
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer), keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
) )
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) { fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit() prefs.edit()
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts") .putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts") .putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats")
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer") .putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
.apply() .apply()
} }
@@ -79,7 +80,7 @@ object NavCustomization {
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf( fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer), keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card), keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts), keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_chats),
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts), keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
) )
@@ -87,7 +88,7 @@ object NavCustomization {
prefs.edit() prefs.edit()
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer") .putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card") .putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts") .putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_chats")
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts") .putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
.apply() .apply()
} }
@@ -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
} }
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() 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() }
@@ -119,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 mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag) val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val store = CredentialStore(ctx) val store = CredentialStore(ctx)
val mobile = if (includePhone) {
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
@@ -145,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
@@ -174,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
@@ -191,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 {
@@ -222,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
val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) {
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
} }
val nameBaseline = boxT + 68f canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
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,
@@ -313,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
} }
@@ -338,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(
@@ -366,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 {
@@ -412,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)
@@ -904,7 +904,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 }
@@ -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()
}
)
} }
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) { binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
}
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)
setNeutralButton(R.string.close, null)
setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } 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()
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -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 = "",
@@ -24,6 +24,7 @@ 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
@@ -33,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
@@ -61,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"
@@ -89,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)
@@ -161,6 +168,15 @@ class TransferReceiptFragment : Fragment() {
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 ──────────────────────────────────────────────────────────
@@ -168,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
@@ -375,8 +412,13 @@ class TransferReceiptFragment : Fragment() {
* applied to fit small viewports and doesn't pick up overlapping siblings. * 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 canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
@@ -385,6 +427,27 @@ class TransferReceiptFragment : Fragment() {
callback(bitmap) callback(bitmap)
} }
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
private fun inflateLightBmlCard(widthPx: Int): View {
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 {
if (raw.isBlank()) return "" if (raw.isBlank()) return ""
return try { return try {
@@ -394,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 {
@@ -455,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 ->
@@ -466,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 {
@@ -0,0 +1,62 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.databinding.SheetTransferBinding
/**
* Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible.
* The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet.
*/
class TransferSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetTransferBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetTransferBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// Leave the top of the chat visible above the sheet.
binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt()
if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) {
val transferArgs = requireArguments()
childFragmentManager.beginTransaction()
.replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs })
.commitNow()
}
}
override fun onStart() {
super.onStart()
(dialog as? BottomSheetDialog)?.let { d ->
// Keep the amount/remarks fields above the keyboard, but don't open it by itself.
@Suppress("DEPRECATION")
d.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
d.behavior.skipCollapsed = true
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val SHEET_HEIGHT_FRACTION = 0.8f
/** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */
fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs }
}
}
@@ -0,0 +1,740 @@
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,
/** 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
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 payment mode (set when navigated from a card/gateway QR scan). */
var qrInfo: BmlQrPayInfo? = null
private set
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
private var gatewayQr = false
/** Prevents re-running the lookup after the user clears the merchant. */
var qrLookupAttempted = false
private set
// ─── 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() {
if (qrInfo == null) return
qrInfo = null
gatewayQr = false
binding.tilAmount.isEnabled = true
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 ─────────────────────────────────────────────────────────
fun lookupQrMerchant(qrUrl: String) {
qrLookupAttempted = true
// 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.
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
val session = app.anyBmlSession() ?: return
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
}
host?.setRefreshing(false)
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 restored, 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()
fragment.resetToFieldVisibility()
onStateChanged()
return@launch
}
qrInfo = info
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
))
}
// Auto-select the user's default BML card if no card was pre-selected
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)
}
}
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(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))
binding.tilAmount.isEnabled = false
}
// Remarks not applicable for merchant QR payments
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
onStateChanged()
}
}
/**
* 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()
fragment.returnToCallerIfRequested()
}
} 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,199 @@
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? = null
private set
/** 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,11 +66,16 @@ 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
/** 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? = null
private set private set
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
private set
private var lookupInFlight = false private var lookupInFlight = false
// ─── Public API the fragment calls ─────────────────────────────────────── // ─── Public API the fragment calls ───────────────────────────────────────
@@ -80,7 +96,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 +121,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 +139,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
@@ -153,20 +170,232 @@ 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
binding.tilAmount.isEnabled = true
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)
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
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 (merchant == null) {
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
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"
))
}
// Show merchant in the "To" card — clear button is the only way to back out
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))
binding.tilAmount.isEnabled = false
}
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 sourceAccountNumberFromCard(): String = */
binding.tvFromAccountNumber.text?.toString().orEmpty() private fun mfaisaSource(): BankAccount? =
currentSource()?.takeIf { it.bank == "MFAISA" }
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) { 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
@@ -241,7 +470,6 @@ class MfaisaTransferHandler(
refId: String, refId: String,
errorMsg: String? errorMsg: String?
) { ) {
val tf = fragment as? TransferFragment ?: return
val view = fragment.view ?: 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(
@@ -250,7 +478,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,
@@ -415,11 +643,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)
}
}
}
@@ -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
} }
} }
@@ -0,0 +1,119 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlValidateClient
import java.io.File
/**
* Encrypted directory of account number → real account-holder name and currency, looked up through BML's
* account validation. Saved contacts often carry a nickname, while bank history only shows the
* real name; this lets a name-only transfer be matched to the right account.
*/
object AccountNameStore {
private const val FILE_NAME = "account_names.json"
private val lock = Any()
/** Names are re-checked after this long, in case an account changes hands. */
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
/** Failed lookups (closed or foreign accounts) are retried after this long. */
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
/*
* BML's account validation is the same search the Transfer page uses, and BML rate-limits it
* per user ("searched too many times"). Lookups are a background nicety, so they get a small
* budget and back off at the first refusal, leaving the user's own searches alone.
*/
private const val MAX_LOOKUPS_PER_RUN = 3
private const val MIN_RUN_INTERVAL_MS = 6L * 60 * 60 * 1000
private const val MAX_LOOKUPS_PER_DAY = 15
private const val BACKOFF_MS = 24L * 60 * 60 * 1000
private const val BUDGET_PREFS = "chat_name_lookup_budget"
data class Info(val name: String, val currency: String)
private data class Entry(val name: String, val currency: String, val checkedAt: Long)
/** Account number → real name and currency, for every account resolved so far. */
fun all(context: Context): Map<String, Info> = synchronized(lock) {
load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) }
}
/**
* Looks up names for [accounts] (most important first) that are unknown or stale, within the
* budget above. Network I/O — call on Dispatchers.IO.
*/
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
val now = System.currentTimeMillis()
val budget = context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE)
if (now < budget.getLong("blockedUntil", 0L)) return
if (now - budget.getLong("lastRunAt", 0L) < MIN_RUN_INTERVAL_MS) return
val today = now / (24L * 60 * 60 * 1000)
val usedToday = if (budget.getLong("day", -1L) == today) budget.getInt("count", 0) else 0
val allowed = minOf(MAX_LOOKUPS_PER_RUN, MAX_LOOKUPS_PER_DAY - usedToday)
if (allowed <= 0) return
val known = synchronized(lock) { load(context) }
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
val e = known[account] ?: return@filter true
val age = now - e.checkedAt
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
}.take(allowed)
if (due.isEmpty()) return
val client = BmlValidateClient()
val found = mutableMapOf<String, Entry>()
var used = 0
for (account in due) {
used++
val v = try { client.validateAccount(session, account) } catch (_: Exception) { null }
if (v == null || v.name.isBlank()) {
// Refused, limited, or unknown: stop for the day rather than keep asking. The
// account is marked failed so it's retried later, not first in line tomorrow.
found[account] = Entry("", "", now)
budget.edit().putLong("blockedUntil", now + BACKOFF_MS).apply()
break
}
found[account] = Entry(v.name.trim(), v.currency, now)
}
budget.edit()
.putLong("lastRunAt", now)
.putLong("day", today)
.putInt("count", usedToday + used)
.apply()
synchronized(lock) {
val current = load(context).toMutableMap()
current.putAll(found)
save(context, current)
}
}
fun clearAll(context: Context) = synchronized(lock) {
File(context.filesDir, FILE_NAME).delete()
context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
private fun load(context: Context): Map<String, Entry> {
val file = File(context.filesDir, FILE_NAME)
if (!file.exists()) return emptyMap()
return try {
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
root.keys().asSequence().associateWith { key ->
val o = root.getJSONObject(key)
Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L))
}
} catch (_: Exception) { emptyMap() }
}
private fun save(context: Context, entries: Map<String, Entry>) {
try {
val root = JSONObject()
entries.forEach { (account, e) ->
root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt))
}
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
} catch (_: Exception) {}
}
}
@@ -0,0 +1,532 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.bml.BmlNotificationsClient
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.ui.home.AppNotification
import sh.sar.basedbank.ui.home.AccountHistoryAdapter
import java.io.File
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* One transfer shown as a chat bubble. BML history only carries the counterparty's name, so
* [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name.
*/
data class ChatMessage(
val id: String,
val peerKey: String,
val peerName: String,
val amount: Double, // negative = sent, positive = received
val currency: String,
val timeMillis: Long,
val accountNumber: String, // own account the money moved in/out of
val accountDisplayName: String,
val reference: String = "",
val note: String = "", // remarks — only known for transfers sent from this app
val peerAccount: String = "", // only known for transfers sent from this app
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
val dateOnly: Boolean = false, // bank gave only a date (Favara); time of day unknown
val merchant: Boolean = false // card / Scan to Pay payment to a business, not a person
) {
val isSent get() = amount < 0
/** In BML's account history (✓✓); otherwise only seen in a notification or receipt so far (✓). */
val isBooked get() = id.startsWith("bml_")
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
val date: String get() = DATE_FMT.format(Date(timeMillis))
companion object {
private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
}
}
/** One of a person's accounts that can be sent to from their chat. */
data class ChatAccount(
val account: String,
val currency: String, // "MVR", "USD"; blank if not known yet
val contact: BankContact? // saved contact for this account, if any
)
data class ChatThread(
val peerKey: String, // "name:<normalised real name>" or "shop:<normalised business name>"
val peerName: String, // contact nickname, else the real name
val realName: String, // account holder's name as the bank knows it; may be blank
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
val messages: List<ChatMessage>, // oldest first
val isMerchant: Boolean = false // a business paid by card / Scan to Pay; nothing to send to
) {
val last get() = messages.last()
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
/** The account last sent to, else the first known one; blank when none is known. */
val peerAccount: String get() =
messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount
?.takeIf { a -> accounts.any { it.account == a } }
?: accounts.firstOrNull()?.account.orEmpty()
}
/**
* Encrypted local record of BML transfers and card / Scan to Pay payments, shown as chats.
*
* Old history is never loaded: the first call to [sync] only stamps the start time, and every
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
* pages back through the latest history of every BML current/savings account (booked later), and
* merges in receipts of transfers sent from this app, keeping one message per transfer.
*/
object ChatStore {
private const val FILE_NAME = "chats.json"
private const val PINS_FILE_NAME = "chat_pins.json"
private const val QR_FILE_NAME = "chat_merchant_qr.json"
/** Prefix [RecentsCache] uses for static BML merchant QRs the user has paid. */
private const val RECENT_QR_PREFIX = "bmlqr:"
private val lock = Any()
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
/** Card and Scan to Pay payments; narrative2 is the business name. */
private val MERCHANT_TYPES = setOf("Purchase")
/** "You have received MVR 5.00 from NAME to 7730*****1234" */
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
/** "You have paid MVR 5.00 from 4xxx********xxxx to BUSINESS" (Scan to Pay / card); the name may be cut short. */
private val NOTIF_PAID = Regex("""You have paid ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
/** Masked own account or card in notifications, e.g. "7730*****1234". */
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
/** How far apart two records of the same transfer can be timestamped. */
private const val MATCH_WINDOW_MS = 15 * 60 * 1000L
/** Upper bound on history pages fetched per account in one sync. */
private const val MAX_PAGES_PER_SYNC = 5
private const val NAME_KEY_PREFIX = "name:"
private const val MERCHANT_KEY_PREFIX = "shop:"
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US)
fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis }
fun isStarted(context: Context) = sinceMillis(context) > 0L
/**
* Groups stored messages into chats, most recent chat first. Call off the main thread.
*
* Chats are per person, keyed by the account holder's real name: bank history only names the
* sender, never their account, so money can't be told apart per account anyway. Every known
* account with that real name (saved contacts, accounts sent to) belongs to the chat, and the
* user picks which one to send to — typically their MVR or USD account.
*
* Real names come from [AccountNameStore], so a contact saved as "Ali" joins the
* "Mohamed Ali" chat.
*/
fun threads(context: Context, contacts: List<BankContact>): List<ChatThread> {
val messages = synchronized(lock) { load(context).messages }
val lookedUp = AccountNameStore.all(context)
val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount }
fun realNameOf(account: String): String? =
lookedUp[account]?.name?.takeIf { it.isNotBlank() }
?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() }
// Notifications cut business names short ("FAMILY ROOM" for "FAMILY ROOM COFFEE"); file a
// short name under the longest known name it starts.
val merchantNames = messages.filter { it.merchant }.map { it.peerKey }.toSet()
fun merchantKey(name: String) =
merchantNames.filter { it.startsWith(name) }.maxByOrNull { it.length } ?: name
fun keyOf(m: ChatMessage): String {
if (m.merchant) return MERCHANT_KEY_PREFIX + merchantKey(m.peerKey)
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
return NAME_KEY_PREFIX + normalise(name)
}
// Every known account, grouped under its holder's real name.
val accountsByKey = HashMap<String, LinkedHashMap<String, ChatAccount>>()
fun addAccount(account: String, fallbackCurrency: String) {
val name = realNameOf(account) ?: return
val contact = contactsByAccount[account]
val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() }
?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency
accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() }
.getOrPut(account) { ChatAccount(account, currency, contact) }
}
contactsByAccount.keys.forEach { addAccount(it, "") }
messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) }
return messages.groupBy(::keyOf).map { (key, msgs) ->
val sorted = msgs.sortedBy { it.timeMillis }
if (key.startsWith(MERCHANT_KEY_PREFIX)) {
// History carries the full business name; notifications may not.
val name = sorted.lastOrNull { it.id.startsWith("bml_") }?.peerName
?: sorted.maxByOrNull { it.peerName.length }!!.peerName
return@map ChatThread(key, name, "", emptyList(), sorted, isMerchant = true)
}
val accounts = accountsByKey[key]?.values?.toList().orEmpty()
// Bank history and the account lookup carry the real name; a receipt only has our label.
val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName
?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } }
?: ""
val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } }
ChatThread(
peerKey = key,
peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName,
realName = realName,
accounts = accounts,
messages = sorted
)
}.sortedByDescending { it.last.timeMillis }
}
/** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */
suspend fun sync(context: Context, app: BasedBankApp, accounts: List<BankAccount>, contacts: List<BankContact>) {
val state = synchronized(lock) { load(context) }
if (state.sinceMillis == 0L) {
synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) }
return
}
val since = state.sinceMillis
val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id })
val notifications = fetchNotifications(context, app, accounts, since)
val receipts = receiptMessages(context, accounts, since)
synchronized(lock) {
val current = load(context)
save(context, current.copy(messages = merge(current.messages, history + notifications + receipts)))
}
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
val session = app.anyBmlSession() ?: return
// Only what the saved data can't answer, most useful first: accounts sent to that aren't
// contacts, then contacts saved without a separate real name. SWIFT (foreign) beneficiaries
// can't be looked up through BML.
val contactAccounts = contacts.mapTo(HashSet()) { it.benefAccount }
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
.filter { it.isNotBlank() && it !in contactAccounts }
val nameless = contacts.filter {
it.benefType != "S" && it.benefAccount.isNotBlank() &&
(it.benefName.isBlank() || normalise(it.benefName) == normalise(it.benefNickName))
}.map { it.benefAccount }
val toResolve = sentTo + nameless
AccountNameStore.resolve(context, session, toResolve)
}
/**
* Transfers from account history. BML only adds a transaction here once it is booked (often
* the next day), so recent ones come from [fetchNotifications] first.
*/
private suspend fun fetchHistory(
app: BasedBankApp,
accounts: List<BankAccount>,
since: Long,
knownIds: Set<String>
): List<ChatMessage> {
val fetched = mutableListOf<ChatMessage>()
for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) {
val fetcher = HistoryFetcher(account)
var pages = 0
// Page back until we reach transfers we already have or ones older than the start time.
while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) {
val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break }
pages++
if (page.isEmpty()) break
var reachedKnown = false
for (trx in page) {
val time = AccountHistoryAdapter.parseDateMillis(trx.date)
if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true
trx.toChatMessage(time, since)?.let { fetched += it }
}
if (reachedKnown) break
}
}
return fetched
}
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
val name = counterpartyName?.trim().orEmpty()
val merchant = description in MERCHANT_TYPES
if ((description !in TRANSFER_TYPES && !merchant) || name.isBlank()) return null
// Favara (other-bank) entries carry only a date; keep those from the start day onwards.
val dateOnly = date.contains("T00:00:00")
if (if (dateOnly) time < startOfDay(since) else time < since) return null
return ChatMessage(
id = "bml_$id",
peerKey = normalise(name),
peerName = name,
amount = amount,
currency = currency,
timeMillis = time,
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
reference = reference.orEmpty(),
dateOnly = dateOnly,
merchant = merchant
)
}
/**
* Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive
* instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page
* is not written back to the cache, which the notification poller uses to spot new alerts.
*/
private fun fetchNotifications(
context: Context,
app: BasedBankApp,
accounts: List<BankAccount>,
since: Long
): List<ChatMessage> {
val client = BmlNotificationsClient()
val notifications = LinkedHashMap<String, AppNotification>()
for ((loginId, session) in app.bmlSessions.toMap()) {
NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it }
try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } }
catch (_: Exception) {}
}
return notifications.values
.filter { it.timestampMs >= since }
.mapNotNull { notificationToMessage(it, accounts) }
}
private fun notificationToMessage(n: AppNotification, accounts: List<BankAccount>): ChatMessage? {
val text = n.message.trim()
val received = NOTIF_RECEIVED.matchEntire(text)
val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null
val paid = if (received == null && sent == null) NOTIF_PAID.matchEntire(text) else null
val (currency, amountText, name, ownMasked) = when {
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) }
sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
paid != null -> paid.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
else -> return null
}
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null
val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m ->
val (head, tail) = m.destructured
accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) }
}
return ChatMessage(
id = "ntf_${n.id}",
peerKey = normalise(name),
peerName = name.trim(),
amount = if (received != null) amount else -amount,
currency = currency,
timeMillis = n.timestampMs,
accountNumber = own?.accountNumber.orEmpty(),
accountDisplayName = own?.accountBriefName ?: "•••• ${ownMasked.takeLast(4)}",
merchant = paid != null
)
}
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
private fun receiptMessages(context: Context, accounts: List<BankAccount>, since: Long): List<ChatMessage> =
ReceiptStore.loadAll(context).mapNotNull { entry ->
val r = entry.data
if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null
val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null
val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt
val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel }
ChatMessage(
id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}",
peerKey = normalise(r.toLabel),
peerName = r.toLabel,
amount = -amount,
currency = r.currency,
timeMillis = time,
accountNumber = from?.accountNumber.orEmpty(),
accountDisplayName = r.fromLabel,
reference = r.bmlReference,
note = r.remarks,
peerAccount = r.toAccount,
receiptKey = entry.savedAt.toString()
)
}
/**
* Merges stored and newly fetched messages so each transfer appears once. The same transfer can
* come from history, a notification and a receipt; the highest-ranked source is kept (history,
* then notification, then receipt) and picks up what the others add: the exact time, the note,
* the recipient account and the receipt.
*/
private fun merge(existing: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
val byId = LinkedHashMap<String, ChatMessage>()
incoming.forEach { byId[it.id] = it }
existing.forEach { byId[it.id] = it } // stored copies already carry merged details
val kept = mutableListOf<ChatMessage>()
val absorbed = HashMap<String, MutableSet<Int>>() // kept id -> source ranks merged into it
for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) {
val r = rank(candidate)
val index = kept.indexOfFirst { k ->
rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate)
}
if (index >= 0) {
val k = kept[index]
kept[index] = k.copy(
timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis,
dateOnly = k.dateOnly && candidate.dateOnly,
accountNumber = k.accountNumber.ifBlank { candidate.accountNumber },
reference = k.reference.ifBlank { candidate.reference },
note = k.note.ifBlank { candidate.note },
peerAccount = k.peerAccount.ifBlank { candidate.peerAccount },
receiptKey = k.receiptKey.ifBlank { candidate.receiptKey },
merchant = k.merchant || candidate.merchant
)
absorbed.getOrPut(k.id) { mutableSetOf() } += r
} else {
kept += candidate
}
}
return kept.sortedBy { it.timeMillis }
}
private fun rank(m: ChatMessage) = when {
m.id.startsWith("bml_") -> 0
m.id.startsWith("ntf_") -> 1
else -> 2
}
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
if (a.reference.isNotBlank() && a.reference == b.reference) return true
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
// A payment to a business is never the same as a transfer to a person (receipts aren't tagged).
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
if (!receipt && a.merchant != b.merchant) return false
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false
if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
if (a.dateOnly || b.dateOnly) {
// A date-only entry could be any transfer that day, so the names must agree too.
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
}
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
}
private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply {
timeInMillis = millis
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis
fun clearAll(context: Context) = synchronized(lock) {
File(context.filesDir, FILE_NAME).delete()
File(context.filesDir, PINS_FILE_NAME).delete()
File(context.filesDir, QR_FILE_NAME).delete()
}
// ─── Saved merchant QRs ───────────────────────────────────────────────────
/**
* The static QR (no preset amount) last paid at the business named [merchantName], or null.
* The app already remembers such QRs among its recents, but only the last few; they are
* copied here so a business's QR is kept for good. Call off the main thread.
*/
fun merchantQr(context: Context, merchantName: String): String? = synchronized(lock) {
val file = File(context.filesDir, QR_FILE_NAME)
val saved = try {
if (!file.exists()) JSONObject() else JSONObject(CacheEncryption.decrypt(file.readText()))
} catch (_: Exception) { JSONObject() }
var changed = false
for (recent in RecentsCache.load(context)) {
if (!recent.accountNumber.startsWith(RECENT_QR_PREFIX)) continue
val key = normalise(recent.displayName)
val url = recent.accountNumber.removePrefix(RECENT_QR_PREFIX)
if (key.isNotBlank() && saved.optString(key) != url) { saved.put(key, url); changed = true }
}
if (changed) try { file.writeText(CacheEncryption.encrypt(saved.toString())) } catch (_: Exception) {}
// Names differ slightly between the QR, history and alerts; accept one starting the other.
val name = normalise(merchantName)
val keys = saved.keys().asSequence().toList()
val match = keys.firstOrNull { it == name }
?: keys.filter { it.startsWith(name) || name.startsWith(it) }.maxByOrNull { it.length }
match?.let { saved.optString(it).takeIf { url -> url.isNotBlank() } }
}
// ─── Pinned chats ─────────────────────────────────────────────────────────
/** Pinned chat keys, most recently pinned first. */
fun pins(context: Context): List<String> = synchronized(lock) {
val file = File(context.filesDir, PINS_FILE_NAME)
if (!file.exists()) return emptyList()
try {
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun setPinned(context: Context, peerKey: String, pinned: Boolean) {
val current = pins(context).filter { it != peerKey }
val updated = if (pinned) listOf(peerKey) + current else current
synchronized(lock) {
try {
File(context.filesDir, PINS_FILE_NAME)
.writeText(CacheEncryption.encrypt(JSONArray(updated).toString()))
} catch (_: Exception) {}
}
}
private fun load(context: Context): State {
val file = File(context.filesDir, FILE_NAME)
if (!file.exists()) return State(0L, emptyList())
return try {
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
val arr = root.optJSONArray("messages") ?: JSONArray()
State(
sinceMillis = root.optLong("since", 0L),
messages = (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
ChatMessage(
id = o.optString("id"),
peerKey = o.optString("peerKey"),
peerName = o.optString("peerName"),
amount = o.optDouble("amount", 0.0),
currency = o.optString("currency"),
timeMillis = o.optLong("time", 0L),
accountNumber = o.optString("account"),
accountDisplayName = o.optString("accountName"),
reference = o.optString("reference"),
note = o.optString("note"),
peerAccount = o.optString("peerAccount"),
receiptKey = o.optString("receiptKey"),
dateOnly = o.optBoolean("dateOnly", false),
merchant = o.optBoolean("merchant", false)
)
}
)
} catch (_: Exception) { State(0L, emptyList()) }
}
private fun save(context: Context, state: State) {
try {
val arr = JSONArray()
for (m in state.messages) arr.put(JSONObject().apply {
put("id", m.id)
put("peerKey", m.peerKey)
put("peerName", m.peerName)
put("amount", m.amount)
put("currency", m.currency)
put("time", m.timeMillis)
put("account", m.accountNumber)
put("accountName", m.accountDisplayName)
put("reference", m.reference)
put("note", m.note)
put("peerAccount", m.peerAccount)
put("receiptKey", m.receiptKey)
put("dateOnly", m.dateOnly)
put("merchant", m.merchant)
})
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
} catch (_: Exception) {}
}
}
@@ -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 }
}
/** 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) { } catch (_: Exception) {
null 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')
} }
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="?attr/colorOutline" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="14dp" />
<solid android:color="?attr/colorSurfaceContainerHigh" />
</shape>
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

+9
View File
@@ -0,0 +1,9 @@
<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="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2H6V9zM14,14H6v-2h8v2zM18,8H6V6h12v2z"/>
</vector>
@@ -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>
+10
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<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="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
</vector>
+10
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<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="M19.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2 7.5,3.5 6,2 4.5,3.5 3,2v20l1.5,-1.5L6,22l1.5,-1.5L9,22l1.5,-1.5L12,22l1.5,-1.5L15,22l1.5,-1.5L18,22l1.5,-1.5L21,22V2l-1.5,1.5zM19,19.09H5V4.91h14v14.18zM6,15h12v2H6zM6,11h12v2H6zM6,7h12v2H6z"/>
</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>
+12
View File
@@ -0,0 +1,12 @@
<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/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineJoin="round"
android:pathData="M4,9l1.5,-5h13L20,9M4,9v11h16V9M4,9h16M9,20v-6h6v6" />
</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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.
Binary file not shown.
@@ -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>
+330
View File
@@ -0,0 +1,330 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<LinearLayout
android:id="@+id/toAccountBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSecondaryContainer"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/chat_to"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvToAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvToNickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvToCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_chat_chip"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:id="@+id/ivToSwitch"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="8dp"
android:rotation="90"
android:src="@drawable/ic_arrow_right"
android:importantForAccessibility="no" />
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:id="@+id/saveContactBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceContainerHigh"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/chat_not_in_contacts"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveContact"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/chat_save_contact" />
</LinearLayout>
<LinearLayout
android:id="@+id/summaryCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_chat_summary"
android:orientation="horizontal"
android:paddingHorizontal="14dp"
android:paddingVertical="10dp"
android:visibility="gone">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/tvCannotSend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:gravity="center"
android:text="@string/chat_cannot_send"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<LinearLayout
android:id="@+id/composer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/tvCurrencyWarning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFrom"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:ellipsize="middle"
android:maxLines="1"
android:textColor="?attr/colorOnSecondaryContainer"
app:icon="@drawable/ic_arrow_right"
app:iconGravity="end"
app:iconSize="16dp"
app:iconTint="?attr/colorOnSecondaryContainer" />
<LinearLayout
android:id="@+id/amountRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.2"
android:hint="@string/chat_amount"
app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="numberDecimal"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilNote"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="@string/chat_note"
app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionSend"
android:inputType="textCapSentences"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSend"
style="@style/Widget.Material3.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:text="@string/chat_send"
app:cornerRadius="28dp"
app:icon="@drawable/ic_send"
app:iconGravity="textEnd" />
</LinearLayout>
</LinearLayout>
<!-- Hosts the Transfer page off-screen when only its confirm dialog is wanted. -->
<FrameLayout
android:id="@+id/hiddenTransferHost"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPayAgain"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="12dp"
android:text="@string/chat_pay_again"
android:visibility="gone"
app:cornerRadius="28dp" />
</LinearLayout>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/chats_search_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvChats"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="65dp" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="32dp"
android:gravity="center"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabScanPay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_margin="16dp"
android:contentDescription="@string/chat_scan_to_pay"
app:srcCompat="@drawable/ic_qr_scan" />
</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"
+32 -3
View File
@@ -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
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> </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
+85 -117
View File
@@ -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 <LinearLayout
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" 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
android:layout_width="match_parent"
android:layout_height="wrap_content"
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>
@@ -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>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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:background="?attr/selectableItemBackground"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAvatar"
android:layout_width="52dp"
android:layout_height="52dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/tvName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivAvatar"
app:layout_constraintEnd_toStartOf="@id/tvTime"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tvPreview"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBaseline_toBaselineOf="@id/tvName" />
<TextView
android:id="@+id/tvPreview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivAvatar"
app:layout_constraintEnd_toStartOf="@id/ivPin"
app:layout_constraintTop_toBottomOf="@id/tvName"
app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/ivPin"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/chat_pinned"
android:src="@drawable/ic_pin"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tvPreview"
app:layout_constraintBottom_toBottomOf="@id/tvPreview" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/bubbleRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="3dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="150dp"
android:orientation="vertical"
android:paddingStart="14dp"
android:paddingTop="10dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="240dp"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textStyle="bold" />
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton
android:id="@+id/btnReceipt"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="-8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/chat_view_receipt"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_receipt"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvNote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxWidth="240dp"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="150dp"
android:layout_marginEnd="8dp"
android:alpha="0.8"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.8"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTicks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="10dp">
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout>
+1 -19
View File
@@ -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"
+130 -22
View File
@@ -2,56 +2,164 @@
<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">
<!-- Header: logo, bank + holder name, countdown ring -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivBankLogo"
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 <TextView
android:id="@+id/tvOtpLabel" android:id="@+id/tvOtpLabel"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelMedium" android:ellipsize="end"
android:textColor="?attr/colorOnSurfaceVariant" android:maxLines="1"
android:layout_marginBottom="8dp" /> android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView </LinearLayout>
android:id="@+id/tvOtpCode"
<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_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceDisplaySmall" android:layout_gravity="center"
android:textColor="?attr/colorPrimary" android:indeterminate="false"
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_height="wrap_content"
android:max="30" android:max="30"
app:trackCornerRadius="4dp" app:indicatorSize="40dp"
app:trackThickness="3dp"
app:trackCornerRadius="2dp"
app:indicatorColor="?attr/colorPrimary" app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" /> app:trackColor="?attr/colorOutlineVariant" />
<TextView <TextView
android:id="@+id/tvOtpCountdown" android:id="@+id/tvOtpCountdown"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_gravity="center"
android:textAppearance="?attr/textAppearanceLabelSmall" android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" /> android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout>
</LinearLayout>
<!-- Current code + copy -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal"
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>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -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,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@+id/transferContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="12dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivHeaderAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/tvHeaderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvHeaderAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
@@ -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>
+3 -3
View File
@@ -6,9 +6,9 @@
<item android:id="@+id/nav_accounts" <item android:id="@+id/nav_accounts"
android:icon="@drawable/ic_nav_accounts" android:icon="@drawable/ic_nav_accounts"
android:title="@string/nav_accounts" /> android:title="@string/nav_accounts" />
<item android:id="@+id/nav_contacts" <item android:id="@+id/nav_chats"
android:icon="@drawable/ic_contacts" android:icon="@drawable/ic_chat"
android:title="@string/nav_contacts" /> android:title="@string/nav_chats" />
<item android:id="@+id/nav_transfer" <item android:id="@+id/nav_transfer"
android:icon="@drawable/ic_send" android:icon="@drawable/ic_send"
android:title="@string/transfer" /> android:title="@string/transfer" />
+3
View File
@@ -17,6 +17,9 @@
<item android:id="@+id/nav_pay_mv_qr" <item android:id="@+id/nav_pay_mv_qr"
android:icon="@drawable/ic_qr_scan" android:icon="@drawable/ic_qr_scan"
android:title="@string/pay_mv_qr" /> android:title="@string/pay_mv_qr" />
<item android:id="@+id/nav_chats"
android:icon="@drawable/ic_chat"
android:title="@string/nav_chats" />
<item android:id="@+id/nav_contacts" <item android:id="@+id/nav_contacts"
android:icon="@drawable/ic_contacts" android:icon="@drawable/ic_contacts"
android:title="@string/nav_contacts" /> android:title="@string/nav_contacts" />
+1
View File
@@ -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>
+19
View File
@@ -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>
+20
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More