Compare commits

..

5 Commits

Author SHA1 Message Date
ahusan 045eb1b1a2 ui: pass 3 — skeleton loading, a11y, snackbars, dark mode
Skeleton loading state:
  * Wire item_skeleton_transaction (previously dormant in res) into
    AccountHistoryFragment and TransferHistoryFragment. When a load
    starts with no cached transactions, 8 skeleton rows appear under
    the search bar instead of a blank screen. Skeleton hides as soon
    as transactions arrive or the load completes.
  * Decision: ActivitiesFragment loads from local storage synchronously,
    so no skeleton there — would only flash for a frame.

Accessibility:
  * Decorative icons in item_more_nav, item_nav_slot, item_picker_row,
    and item_account_dropdown now declare importantForAccessibility="no"
    so TalkBack skips them (the adjacent text label conveys meaning).
  * Also added tnum to item_picker_row.tvBalance and
    item_account_dropdown.tvDropdownBalance — missed amounts from Pass 2.

Snackbar:
  * ContactsFragment.deleteContact's success/failure feedback is now
    a Snackbar instead of Toast — the fragment stays visible, so a
    bottom-anchored Snackbar fits better than a floating Toast.
  * Other Toasts are either followed by navigation away (Snackbar
    couldn't display), or purely informational ("copied", "press back
    to exit") where Toast is the right primitive. Left as-is.

Dark mode:
  * Connectivity banner in activity_home no longer hardcodes
    #C62828 / #FFFFFF — uses colorErrorContainer / colorOnErrorContainer
    so it adapts to the user's theme.
  * Other hex literals (card-artwork text, receipt screens that
    intentionally emulate paper) are intentional and stay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:02:50 +05:00
ahusan 22806da1b3 ui: pass 2 — pill style, tabular figures, ripple feedback, empty strings
Status pills:
  * New Widget.App.StatusChip style bundling the 10dp/4dp padding +
    chip_background + label-small text used in 3 places (item_bml_loan,
    item_finance_deal, item_profile_header). The 3 layouts now apply
    the style instead of repeating 4 attributes each.
  * Extract the bindCardStatus card-overlay pill background from inline
    GradientDrawable code into a real drawable resource
    (card_overlay_pill_bg.xml). PayWithCardFragment.bindCardStatus just
    sets a background resource now.

Tabular figures (android:fontFeatureSettings="tnum"):
  * Applied to amount TextViews on the highest-traffic surfaces —
    transactions list, accounts list, card list, dashboard balance /
    credit / blocked / overdue / pending finances totals, and the
    account history header (available / balance / blocked).
  * Digits become same-width, so right-aligned amounts in a column
    line up.

Ripple feedback:
  * item_finance_deal, item_bml_loan, item_foreign_limit get
    android:clickable="true" android:focusable="true" so their
    MaterialCardView roots show the M3 ripple when the adapter
    attaches a click listener. All three are clickable from code
    but were missing the ripple cue.

Empty-state strings:
  * Hardcoded "No recent transfers" / "No transactions found" in
    fragment_activities, fragment_transfer_history, and
    fragment_account_history moved to strings.xml as
    activities_empty / transactions_empty, matching the existing
    accounts_empty / contacts_empty / financing_empty pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:48:23 +05:00
ahusan 29ac4352ff ui: swap list-item and screen-margin paddings onto tokens
Replaces hardcoded paddings in the three layouts that share the
standard 16dp/14dp list-row pattern (item_account, item_card,
item_more_nav) with @dimen/list_item_padding_h / list_item_padding_v.

Dashboard root content padding becomes @dimen/screen_margin. Card
content paddings inside the dashboard are left as raw 16dp — they're
a separate semantic (card-interior padding, not screen edge) and
will be addressed once we either need a distinct token or have a
reason to change them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:32:18 +05:00
ahusan df892ec8d5 ui: tokenize card surfaces in dimens.xml and sweep layouts
Introduces a small set of design tokens for card surfaces so the same
look is enforced everywhere instead of being hand-tuned per layout:

  card_radius_lg / card_radius_md       — 16dp / 12dp two-tier radius
  card_elevation_flat / *_raised        — 0dp outlined / 1dp subtle raise

Sweeps the existing layouts onto these tokens. Two-tier policy:

  lg → hero/standalone cards (account history header, bml loan,
       finance deal, otp card, bank selection tiles, security setup
       options)
  md → repeating row/summary cards (dashboard summary cards,
       credentials inset, foreign limit, add-contact sheet)

Card-visual representations (item_card_dashboard / _stack / _wallet
/ _settings_entry) deliberately keep their bespoke radius/elevation
so they continue to read as credit cards — the tokens are for
content cards only. Tight inset frames inside fragment_bml_qr_pay
and fragment_transfer also remain on 4dp/0dp as form details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:20:16 +05:00
shihaam 5cba468781 debug builds has debug suffix and different launcher icon color
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 17:11:55 +05:00
136 changed files with 682 additions and 7749 deletions
-21
View File
@@ -87,24 +87,3 @@ jobs:
--data-binary "@${ASSET_PATH}"
echo "Uploaded asset: $ASSET_NAME"
- name: Send APK to Telegram
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
run: |
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
exit 0
fi
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
TAG="${{ gitea.ref_name }}"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
CAPTION="${APP_NAME} ${TAG}"
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
-F "chat_id=${TG_CHAT_ID}" \
-F "document=@${ASSET_PATH}" \
-F "caption=${CAPTION}"
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-03T08:28:30.389803148Z">
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=10.0.1.245:5555;connection=d182cf37" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
</handle>
</Target>
</DropdownSelection>
+3 -8
View File
@@ -14,17 +14,12 @@ A native Android client for Maldivian banking services. It is a pure client: req
- Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
## Download APK
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
[Telegram Channel](https://t.me/s/thijooreeapks)
## Download
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
## 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.
## Documentation
API reverse-engineering notes and app internals are in [`docs/`](docs/README.md).
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
## Disclaimer
+2 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 16
versionName = "1.0.17"
versionCode = 8
versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -91,9 +91,6 @@ dependencies {
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
// Encrypted SharedPreferences (HCE token store)
implementation("androidx.security:security-crypto:1.1.0-alpha06")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_logo_background">#CC0000</color>
</resources>
-43
View File
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="transfer"
android:enabled="true"
android:icon="@drawable/ic_shortcut_transfer"
android:shortcutShortLabel="@string/transfer"
android:shortcutLongLabel="@string/transfer">
<intent
android:action="sh.sar.basedbank.OPEN_TRANSFER"
android:targetPackage="sh.sar.basedbank.debug"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="scan_qr"
android:enabled="true"
android:icon="@drawable/ic_shortcut_scan_qr"
android:shortcutShortLabel="@string/transfer_scan_qr"
android:shortcutLongLabel="@string/transfer_scan_qr">
<intent
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
android:targetPackage="sh.sar.basedbank.debug"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="tap_to_pay"
android:enabled="true"
android:icon="@drawable/ic_shortcut_pay_card"
android:shortcutShortLabel="@string/card_pay_nfc"
android:shortcutLongLabel="@string/card_pay_nfc">
<intent
android:action="sh.sar.basedbank.TAP_TO_PAY"
android:targetPackage="sh.sar.basedbank.debug"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>
-37
View File
@@ -7,10 +7,6 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<application
android:name=".BasedBankApp"
@@ -63,39 +59,6 @@
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".nfc.BmlTapToPayActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" />
<service
android:name=".nfc.BmlHostCardEmulatorService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/bml_aid_list" />
</service>
<!-- Share-sheet alias: "Scan to Pay" receives shared images and decodes their QR code -->
<activity-alias
android:name=".ScanToPayActivity"
android:targetActivity=".MainActivity"
android:exported="true"
android:label="@string/transfer_scan_qr"
android:icon="@drawable/ic_qr_scan">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -115,11 +115,7 @@ class BasedBankApp : Application() {
override fun onCreate() {
super.onCreate()
// Only apply wallpaper-based dynamic colors in system theme mode.
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
}
DynamicColors.applyToActivitiesIfAvailable(this)
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
AppCompatDelegate.setDefaultNightMode(when (theme) {
@@ -21,7 +21,6 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
import sh.sar.basedbank.BasedBankApp
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
@@ -47,7 +46,6 @@ class LockActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater)
@@ -57,9 +55,6 @@ class LockActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
@@ -124,17 +119,8 @@ class LockActivity : AppCompatActivity() {
else
com.google.android.material.R.attr.materialButtonOutlinedStyle
val btn = MaterialButton(this, null, style).apply {
if (key == "" || key == "") {
text = ""
icon = ContextCompat.getDrawable(this@LockActivity,
if (key == "") R.drawable.ic_backspace else R.drawable.ic_check)
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
iconPadding = 0
iconSize = (28 * dp).toInt()
} else {
text = key
textSize = 24f
}
text = key
textSize = 24f
insetTop = 0; insetBottom = 0
minimumWidth = 0; minimumHeight = 0
cornerRadius = btnSize / 2
@@ -287,13 +273,9 @@ class LockActivity : AppCompatActivity() {
}
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
val shareQrText = intent.getStringExtra("share_qr_text")
startActivity(Intent(this, HomeActivity::class.java).apply {
if (navDest != -1) putExtra("nav_destination", navDest)
if (autoScan) putExtra("auto_scan", true)
if (autoTapMode) putExtra("auto_tap_mode", true)
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
})
finish()
}
@@ -12,28 +12,6 @@ import sh.sar.basedbank.R
class MainActivity : AppCompatActivity() {
private fun decodeQrFromSharedImage(uri: android.net.Uri): String? {
return try {
val bitmap = contentResolver.openInputStream(uri)?.use {
android.graphics.BitmapFactory.decodeStream(it)
} ?: return null
val opts = de.markusfisch.android.zxingcpp.ZxingCpp.ReaderOptions(
tryHarder = true, tryRotate = true, tryInvert = true,
tryDownscale = true, maxNumberOfSymbols = 1,
textMode = de.markusfisch.android.zxingcpp.ZxingCpp.TextMode.PLAIN
)
val result = (de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.LOCAL_AVERAGE }
) ?: de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.GLOBAL_HISTOGRAM }
))?.firstOrNull()?.text
bitmap.recycle()
result
} catch (_: Exception) { null }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -43,26 +21,13 @@ class MainActivity : AppCompatActivity() {
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
intent.type?.startsWith("image/") == true) {
val uri: android.net.Uri? =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU)
intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
else
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
if (uri != null) decodeQrFromSharedImage(uri) else null
} else null
val navDestination = when (intent?.action) {
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
else -> -1
}
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
val target = when {
!onboardingDone -> OnboardingActivity::class.java
@@ -78,8 +43,6 @@ class MainActivity : AppCompatActivity() {
startActivity(Intent(this, target).apply {
if (navDestination != -1) putExtra("nav_destination", navDestination)
if (autoScan) putExtra("auto_scan", true)
if (autoTapMode) putExtra("auto_tap_mode", true)
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
})
finish()
}
@@ -153,46 +153,6 @@ class BmlHistoryClient {
} catch (_: Exception) { emptyList() }
}
fun fetchPendingHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String
): List<BankTransaction> {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/history/pending/$accountId")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONArray("payload") ?: return emptyList()
(0 until payload.length()).map { i ->
val item = payload.getJSONObject(i)
BankTransaction(
id = item.optString("LockedID"),
date = item.optString("FromDate"),
description = "Pending",
amount = -item.optDouble("LockedAmount", 0.0),
currency = "MVR",
counterpartyName = item.optString("Description").trim().takeIf { it.isNotBlank() },
reference = null,
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML"
)
}
} catch (_: Exception) { emptyList() }
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
@@ -82,15 +82,6 @@ data class BmlQrPayResult(
val errorMessage: String = ""
)
data class BmlWalletToken(
val token: String,
val expiry: String,
val appCode: String, // AID hex, e.g. "A0000000031010"
val serviceCode: String,
val data: String,
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
)
data class BmlForeignLimit(
val type: String,
val used: Double,
@@ -1,95 +0,0 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.ui.home.AppNotification
import java.text.SimpleDateFormat
import java.util.Locale
private const val BML_NOTIF_BASE = "https://app.bankofmaldives.com.mv"
class BmlNotificationsClient {
private val client = newBmlApiClient()
private val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
data class FetchResult(
val items: List<AppNotification>,
val total: Int
)
fun fetchNotifications(
session: BmlSession,
loginId: String,
group: String = "ALL",
page: Int = 1
): FetchResult {
val url = "$BML_NOTIF_BASE/api/v2/notifications?group=$group&page=$page"
return try {
val resp = client.newCall(bmlApiRequest(session, url)).execute()
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0) }
val body = resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0)
parseResponse(body, loginId)
} catch (_: Exception) { FetchResult(emptyList(), 0) }
}
fun markAllRead(session: BmlSession): Boolean {
val url = "$BML_NOTIF_BASE/api/v2/notifications/read"
val reqBody = """{"all":true}""".toRequestBody("application/json".toMediaType())
val req = Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.put(reqBody)
.build()
return try {
val resp = client.newCall(req).execute()
val ok = resp.isSuccessful
resp.close()
ok
} catch (_: Exception) { false }
}
private fun parseResponse(body: String, loginId: String): FetchResult {
val json = JSONObject(body)
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0)
val total = json.optInt("total", 0)
val payload = json.optJSONArray("payload") ?: return FetchResult(emptyList(), total)
val items = (0 until payload.length()).map { i ->
val obj = payload.getJSONObject(i)
val dataObj = obj.optJSONObject("data")
val detailFields = mutableListOf<Pair<String, String>>()
detailFields.add("Bank" to "BML")
detailFields.add("Group" to obj.optString("group"))
detailFields.add("Type" to obj.optString("type"))
if (dataObj != null) {
dataObj.keys().forEach { key ->
val v = dataObj.opt(key)?.toString()?.takeIf { it.isNotBlank() } ?: return@forEach
detailFields.add(formatKey(key) to v)
}
}
val createdAt = obj.optString("created_at")
val tsMs = try { sdf.parse(createdAt)?.time ?: System.currentTimeMillis() }
catch (_: Exception) { System.currentTimeMillis() }
AppNotification(
id = obj.optString("id"),
bank = "BML",
loginId = loginId,
group = obj.optString("group", "ALERTS"),
title = obj.optString("title"),
message = obj.optString("message"),
timestampMs = tsMs,
isRead = obj.optBoolean("is_read", true),
detailFields = detailFields
)
}
return FetchResult(items, total)
}
private fun formatKey(key: String): String =
key.replace('_', ' ').split(' ').joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
}
@@ -1,79 +0,0 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
class BmlTapToPayClient {
private val client = newBmlApiClient()
/**
* Fetches up to [quantity] single-use payment tokens for [cardId].
* [otp] is a TOTP code generated from the stored BML OTP seed.
*
* Flow:
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
* 3. POST with otp=TOTP → code 0, payload = token list
*/
fun fetchTokens(
session: BmlSession,
cardId: String,
otp: String,
quantity: Int = 3
): List<BmlWalletToken> {
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
// Step 1: initiate
val base = JSONObject().apply {
put("type", "track2")
put("cardid", cardId)
put("quantity", quantity)
}
val step1 = post(session, url, base)
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
val step2 = post(session, url, body2)
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
// Step 3: submit TOTP
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
val step3 = post(session, url, body3)
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
return parseTokens(step3.optJSONArray("payload"))
}
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
val req = okhttp3.Request.Builder()
.url(url)
.post(body.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
return client.newCall(req).execute().use { resp ->
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
}
}
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
arr ?: return emptyList()
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BmlWalletToken(
token = o.getString("token"),
expiry = o.getString("expiry"),
appCode = o.getString("app_code"),
serviceCode = o.getString("service_code"),
data = o.optString("data", ""),
validUntil = o.optString("valid_until", "")
)
}
}
}
@@ -84,8 +84,7 @@ class BmlTransferClient {
try {
val json = JSONObject(bodyStr)
if (!json.optBoolean("success")) {
val payloadStr = json.optString("payload").takeIf { it.isNotBlank() && it != "null" }
BmlTransferResult(false, errorMessage = payloadStr ?: json.optString("message").ifBlank { "Transfer failed" })
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
} else {
val payload = json.optJSONObject("payload")
BmlTransferResult(
@@ -1,135 +0,0 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.ui.home.AppNotification
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
private val SKIP_TYPES = setOf("Switch Profile")
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
class MibActivityHistoryClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val sdf = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.US)
data class FetchResult(
val items: List<AppNotification>, // already filtered (no Switch Profile)
val rawCount: Int, // raw items returned by API before filtering
val totalCount: Int,
val nextStart: Int
)
fun fetchActivity(
session: MibSession,
loginId: String,
start: Int,
end: Int
): FetchResult {
val cookieHeader = "mbmodel=IOS-1.0; " +
"xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " +
"time-tracker=597"
val formBody = FormBody.Builder()
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val req = Request.Builder()
.url("$MIB_WV_URL/aProfile/getPagedActivityHistory")
.header("Cookie", cookieHeader)
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
.header("X-Requested-With", "XMLHttpRequest")
.post(formBody)
.build()
val body = try {
val resp = client.newCall(req).execute()
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0, 0, end + 1) }
resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0, 0, end + 1)
} catch (_: Exception) { return FetchResult(emptyList(), 0, 0, end + 1) }
return try {
val json = JSONObject(body)
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0, 0, end + 1)
val totalCount = json.optString("total_count", "0").toIntOrNull() ?: 0
val dataArr = json.optJSONArray("data") ?: return FetchResult(emptyList(), 0, totalCount, end + 1)
val items = mutableListOf<AppNotification>()
val rawCount = dataArr.length()
for (i in 0 until rawCount) {
val obj = dataArr.getJSONObject(i)
val activityType = obj.optString("activityType")
if (activityType in SKIP_TYPES) continue
val pa = obj.optString("pa")
val activity = obj.optString("activity")
val pb = obj.optString("pb")
val dateStr = obj.optString("date")
val message = buildString {
append(pa)
if (activity.isNotBlank()) { append(" "); append(activity) }
if (pb.isNotBlank()) { append(" "); append(pb) }
}
val tsMs = try { sdf.parse(dateStr)?.time ?: System.currentTimeMillis() }
catch (_: Exception) { System.currentTimeMillis() }
val detailFields = mutableListOf<Pair<String, String>>().apply {
add("Bank" to "MIB")
add("Type" to activityType)
if (pa.isNotBlank()) add("By" to pa)
if (activity.isNotBlank() && pb.isNotBlank()) add("Action" to "$activity $pb")
if (dateStr.isNotBlank()) add("Date" to dateStr)
}
items.add(AppNotification(
id = obj.optString("aid"),
bank = "MIB",
loginId = loginId,
group = "ALERTS",
title = activityType,
message = message,
timestampMs = tsMs,
isRead = false, // resolved from cache in the sheet
detailFields = detailFields
))
}
FetchResult(items, rawCount, totalCount, end + 1)
} catch (_: Exception) { FetchResult(emptyList(), 0, 0, end + 1) }
}
// Keeps fetching pages until at least `minCount` non-Switch-Profile items found or all pages exhausted.
fun fetchUntilEnough(
session: MibSession,
loginId: String,
minCount: Int = 5,
pageSize: Int = 30
): FetchResult {
val accumulated = mutableListOf<AppNotification>()
var start = 1
var totalCount = 0
while (accumulated.size < minCount) {
val result = fetchActivity(session, loginId, start, start + pageSize - 1)
totalCount = result.totalCount
accumulated.addAll(result.items)
if (result.rawCount == 0 || start + pageSize - 1 >= totalCount) break
start = result.nextStart
}
return FetchResult(accumulated, accumulated.size, totalCount, start)
}
}
@@ -1,185 +0,0 @@
package sh.sar.basedbank.nfc
import android.content.Intent
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import android.util.Log
import sh.sar.basedbank.api.bml.BmlWalletToken
/**
* HCE service that emulates a BML contactless payment card.
*
* Implements the minimal EMV mag-stripe contactless flow:
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
*
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
* After READ RECORD is sent the [onTransactionComplete] callback fires.
*/
class BmlHostCardEmulatorService : HostApduService() {
private var gpoSent = false
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
if (commandApdu == null) return SW_UNKNOWN_ERROR
val apdu = Apdu(commandApdu)
if (apdu.isError) return apdu.errorResponse()
return when (apdu.ins) {
INS_SELECT -> handleSelect(apdu)
INS_GPO -> handleGpo()
INS_READ -> handleReadRecord()
else -> SW_INS_NOT_SUPPORTED
}
}
override fun onDeactivated(reason: Int) {
if (!gpoSent) onTransactionComplete?.invoke(false)
gpoSent = false
}
// ── APDU handlers ──────────────────────────────────────────────────────────
private fun handleSelect(apdu: Apdu): ByteArray {
val data = apdu.data ?: return SW_UNKNOWN_ERROR
if (data.contentEquals(PPSE_BYTES)) {
val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR }
return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01"))
}
val token = activeToken ?: return SW_UNKNOWN_ERROR
return if (data.contentEquals(hexToBytes(token.appCode))) {
hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode)))
} else {
SW_UNKNOWN_ERROR
}
}
private fun launchPromptActivity() {
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
private fun handleGpo(): ByteArray {
gpoSent = true
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
val miscData = "008008010100"
val body = tlv("80", miscData)
return hexToBytes(body + SW_OK_HEX)
}
private fun handleReadRecord(): ByteArray {
val token = activeToken ?: return SW_UNKNOWN_ERROR
val track2 = buildTrack2(token)
val body = tlv("70", tlv("57", track2))
val response = hexToBytes(body + SW_OK_HEX)
onTransactionComplete?.invoke(true)
return response
}
// ── TLV / APDU response builders ───────────────────────────────────────────
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
val priorityTlv = tlv("87", priority) // tag 87
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
val inner = tlv("BF0C", appEntry) // tag BF0C
val propTemplate = tlv("A5", inner) // tag A5
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
return fci + SW_OK_HEX
}
private fun buildSelectAidResponse(aid: String, label: String): String {
val aidTlv = tlv("84", aid) // tag 84
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
return fci + SW_OK_HEX
}
private fun buildTrack2(token: BmlWalletToken): String {
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
if (t2.length % 2 != 0) t2 += "F"
return t2
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
private fun tlv(tagHex: String, dataHex: String): String {
val lenBytes = dataHex.length / 2
val lenHex = when {
lenBytes <= 0x7F -> lenBytes.toHexByte()
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
}
return tagHex + lenHex + dataHex
}
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
private fun asciiToHex(s: String): String =
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
private fun hexToBytes(hex: String): ByteArray {
val s = hex.uppercase()
return ByteArray(s.length / 2) { i ->
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
// ── APDU parser ─────────────────────────────────────────────────────────────
private inner class Apdu(raw: ByteArray) {
val isError: Boolean
val ins: Int
val data: ByteArray?
init {
if (raw.size < 5) {
isError = true; ins = -1; data = null
} else {
isError = false
ins = raw[1].toInt() and 0xFF
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
}
}
fun errorResponse() = SW_UNKNOWN_ERROR
}
companion object {
private const val TAG = "BmlHCE"
private const val INS_SELECT = 0xA4
private const val INS_GPO = 0xA8
private const val INS_READ = 0xB2
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
private val PPSE_BYTES = byteArrayOf(
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
)
private const val SW_OK_HEX = "9000"
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
private val SW_INS_NOT_SUPPORTED = byteArrayOf(0x6D.toByte(), 0x00.toByte())
@Volatile var activeToken: BmlWalletToken? = null
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
fun setToken(token: BmlWalletToken) { activeToken = token }
fun clearToken() { activeToken = null }
fun applicationLabel(aidHex: String): String = when {
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
else -> "BML"
}
}
}
@@ -1,20 +0,0 @@
package sh.sar.basedbank.nfc
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import sh.sar.basedbank.MainActivity
/**
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
*/
class BmlTapToPayActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(Intent(this, MainActivity::class.java).apply {
action = "sh.sar.basedbank.TAP_TO_PAY"
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
finish()
}
}
@@ -27,10 +27,9 @@ class AccountHistoryAdapter(
private sealed class Item {
data class DateHeader(val label: String) : Item()
data class Trx(val transaction: BankTransaction, val showDate: Boolean = false) : Item()
data class Trx(val transaction: BankTransaction) : Item()
}
private val pendingItems = mutableListOf<Item>()
private val displayItems = mutableListOf<Item>()
private var lastInsertedDateKey = ""
private val imageCache = mutableMapOf<String, Bitmap>()
@@ -38,22 +37,15 @@ class AccountHistoryAdapter(
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((BankAccount) -> Unit)? = null
var onDefaultToggle: ((Boolean) -> Unit)? = null
private var hideAmounts: Boolean = false
var showDefaultToggle: Boolean = false
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
var isDefaultAccount: Boolean = false
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyItemChanged(0) // refresh header card
for (i in pendingItems.indices) {
if (pendingItems[i] is Item.Trx) notifyItemChanged(i + 1)
}
// refresh all transaction rows
for (i in displayItems.indices) {
if (displayItems[i] is Item.Trx) notifyItemChanged(1 + pendingItems.size + i)
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
}
}
@@ -61,7 +53,7 @@ class AccountHistoryAdapter(
imageCache[counterpartyName] = bitmap
displayItems.forEachIndexed { i, item ->
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
notifyItemChanged(1 + pendingItems.size + i)
notifyItemChanged(i + 1) // +1 for account header at position 0
}
}
@@ -69,19 +61,10 @@ class AccountHistoryAdapter(
iconUrlCache[url] = bitmap
displayItems.forEachIndexed { i, item ->
if (item is Item.Trx && item.transaction.iconUrl == url)
notifyItemChanged(1 + pendingItems.size + i)
notifyItemChanged(i + 1) // +1 for account header at position 0
}
}
fun setPendingTransactions(transactions: List<BankTransaction>) {
pendingItems.clear()
if (transactions.isNotEmpty()) {
pendingItems.add(Item.DateHeader("Pending"))
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
}
notifyDataSetChanged()
}
private var _showLoadingFooter = false
var showLoadingFooter: Boolean
get() = _showLoadingFooter
@@ -139,24 +122,18 @@ class AccountHistoryAdapter(
displayItems.add(Item.Trx(trx))
}
val added = displayItems.size - oldCount
if (added > 0) notifyItemRangeInserted(1 + pendingItems.size + oldCount, added)
if (added > 0) notifyItemRangeInserted(1 + oldCount, added) // +1 for account header
}
// Position 0 = account header card
// Positions 1..pendingItems.size = pending header + pending transactions
// Positions 1+pendingItems.size..1+pendingItems.size+displayItems.size = date headers + transactions
// Positions 1..displayItems.size = date headers + transactions
// Last position = loading footer when showLoadingFooter = true
override fun getItemCount() = 1 + pendingItems.size + displayItems.size + if (_showLoadingFooter) 1 else 0
private fun itemAt(position: Int): Item {
val idx = position - 1
return if (idx < pendingItems.size) pendingItems[idx] else displayItems[idx - pendingItems.size]
}
override fun getItemCount() = 1 + displayItems.size + if (_showLoadingFooter) 1 else 0
override fun getItemViewType(position: Int) = when {
position == 0 -> TYPE_HEADER
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
else -> when (itemAt(position)) {
else -> when (displayItems[position - 1]) {
is Item.DateHeader -> TYPE_DATE_HEADER
is Item.Trx -> TYPE_TRANSACTION
}
@@ -175,11 +152,8 @@ class AccountHistoryAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
is TransactionVH -> {
val item = itemAt(position) as Item.Trx
holder.bind(item.transaction, item.showDate)
}
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit
}
}
@@ -200,20 +174,6 @@ class AccountHistoryAdapter(
b.llHeaderBlocked.visibility = View.GONE
}
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
if (showDefaultToggle) {
b.dividerDefaultAccount.visibility = View.VISIBLE
b.llDefaultAccountRow.visibility = View.VISIBLE
b.switchDefaultAccount.setOnCheckedChangeListener(null)
b.switchDefaultAccount.isChecked = isDefaultAccount
b.switchDefaultAccount.setOnCheckedChangeListener { _, checked ->
isDefaultAccount = checked
onDefaultToggle?.invoke(checked)
}
} else {
b.dividerDefaultAccount.visibility = View.GONE
b.llDefaultAccountRow.visibility = View.GONE
}
}
}
@@ -224,7 +184,7 @@ class AccountHistoryAdapter(
inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(trx: BankTransaction, showDate: Boolean = false) {
fun bind(trx: BankTransaction) {
val isCredit = trx.amount >= 0
val color = sourceColor(trx.source)
val name = trx.counterpartyName ?: trx.description
@@ -260,7 +220,7 @@ class AccountHistoryAdapter(
b.tvCounterparty.visibility = View.GONE
}
b.tvDate.text = if (showDate) formatDateOnly(trx.date) else formatTime(trx.date)
b.tvDate.text = formatTime(trx.date)
if (hideAmounts) {
b.tvAmount.text = "${trx.currency} ••••••"
@@ -307,7 +267,6 @@ class AccountHistoryAdapter(
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
private val DATE_ONLY_FMT = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
@@ -329,11 +288,6 @@ class AccountHistoryAdapter(
return DATE_HEADER_FMT.format(date)
}
fun formatDateOnly(raw: String): String {
val date = parseDate(raw) ?: return raw.take(10)
return DATE_ONLY_FMT.format(date)
}
fun formatTime(raw: String): String {
val date = parseDate(raw) ?: return ""
return TIME_FMT.format(date)
@@ -24,15 +24,12 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache
@@ -83,23 +80,6 @@ class AccountHistoryFragment : Fragment() {
}
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
// Show default account toggle only for non-card accounts
val isCard = AccountListParser.from(account)?.isCard ?: false
if (!isCard) {
val store = CredentialStore(requireContext())
adapter.showDefaultToggle = true
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
adapter.onDefaultToggle = { isChecked ->
if (isChecked) {
store.setDefaultAccountNumber(account.accountNumber)
} else {
if (store.getDefaultAccountNumber() == account.accountNumber) {
store.setDefaultAccountNumber(null)
}
}
}
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
@@ -139,7 +119,6 @@ class AccountHistoryFragment : Fragment() {
}
(activity as? HomeActivity)?.setRefreshing(true)
loadNextPage()
loadPendingTransactions()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
@@ -152,12 +131,7 @@ class AccountHistoryFragment : Fragment() {
override fun onResume() {
super.onResume()
if (::account.isInitialized) {
requireActivity().title = account.accountBriefName
if (adapter.showDefaultToggle) {
adapter.isDefaultAccount = CredentialStore(requireContext()).getDefaultAccountNumber() == account.accountNumber
}
}
if (::account.isInitialized) requireActivity().title = account.accountBriefName
}
private fun filterAndDisplay() {
@@ -168,6 +142,8 @@ class AccountHistoryFragment : Fragment() {
}
adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
binding.skeletonContainer.visibility =
if (allTransactions.isEmpty() && isLoading) View.VISIBLE else View.GONE
}
private fun resetAndReload() {
@@ -186,7 +162,6 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = View.GONE
}
loadNextPage()
loadPendingTransactions()
}
private fun loadNextPage() {
@@ -195,6 +170,9 @@ class AccountHistoryFragment : Fragment() {
if (firstPageDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true
} else if (allTransactions.isEmpty()) {
binding.skeletonContainer.visibility = View.VISIBLE
binding.emptyView.visibility = View.GONE
}
val app = requireActivity().application as BasedBankApp
@@ -214,6 +192,7 @@ class AccountHistoryFragment : Fragment() {
isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
if (!firstPageDone) {
firstPageDone = true
@@ -253,26 +232,6 @@ class AccountHistoryFragment : Fragment() {
}
}
private fun loadPendingTransactions() {
if (account.bank != "BML" || account.profileType != "BML") return
val app = requireActivity().application as BasedBankApp
val session = app.bmlSessionFor(account) ?: return
viewLifecycleOwner.lifecycleScope.launch {
try {
val pending = withContext(Dispatchers.IO) {
BmlHistoryClient().fetchPendingHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber
)
}
if (_binding == null) return@launch
adapter.setPendingTransactions(pending)
} catch (_: Exception) { }
}
}
private fun loadContactImage(name: String) {
if (!pendingImageNames.add(name)) return
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
@@ -1,13 +0,0 @@
package sh.sar.basedbank.ui.home
data class AppNotification(
val id: String,
val bank: String, // "BML" or "MIB"
val loginId: String, // key in bmlSessions / mibSessions
val group: String, // "ALERTS" or "INFORMATION"
val title: String,
val message: String,
val timestampMs: Long,
val isRead: Boolean,
val detailFields: List<Pair<String, String>> = emptyList()
)
@@ -33,8 +33,6 @@ import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp
class BmlQrPayFragment : Fragment() {
@@ -152,19 +150,6 @@ class BmlQrPayFragment : Fragment() {
return@launch
}
merchantInfo = info
if (info.amount == 0.0) {
val qrUrl = arguments?.getString(ARG_QR_URL)
if (qrUrl != null) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = "bmlqr:$qrUrl",
displayName = info.merchantName,
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
colorHex = "#0066A1",
imageHash = null,
isProfileImage = false
))
}
}
populateMerchant(info)
}
}
@@ -330,7 +315,6 @@ class BmlQrPayFragment : Fragment() {
.setTitle(R.string.bml_qr_payment_success)
.setView(container)
.setPositiveButton(android.R.string.ok) { _, _ ->
(activity as? HomeActivity)?.triggerRefresh()
requireActivity().onBackPressedDispatcher.onBackPressed()
}
.setCancelable(false)
@@ -1,561 +0,0 @@
package sh.sar.basedbank.ui.home
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.AttributeSet
import android.util.TypedValue
import android.view.*
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.animation.AnimatorListenerAdapter
import android.animation.Animator
import android.widget.FrameLayout
import android.graphics.Typeface
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import kotlin.math.*
class CircularNavFragment : Fragment() {
private var wheelView: CircularWheelView? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val ctx = requireContext()
val colorPrimary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorPrimary, Color.RED)
val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, ctx.resources.displayMetrics)
val root = android.widget.LinearLayout(ctx).apply {
orientation = android.widget.LinearLayout.VERTICAL
setBackgroundColor(colorSurface)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
// Wheel area (weight 1, fills remaining space)
val wheelContainer = FrameLayout(ctx).apply {
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
)
}
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
wheelView = CircularWheelView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
wheelAngle = prefs.getFloat("circular_wheel_angle", 0f)
val savedSlots = NavCustomization.getCircularSlots(prefs).map { id ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == id }!!
CircularWheelView.WheelItem(def.id, def.iconRes, ctx.getString(def.titleRes))
}
items = listOf(
savedSlots[3], // 4 o'clock (strip slot 3)
CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, ctx.getString(R.string.nav_dashboard)), // 6 o'clock
CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, ctx.getString(R.string.nav_more)), // 8 o'clock
savedSlots[0], // 10 o'clock (strip slot 0 — first in strip)
savedSlots[1], // 12 o'clock (strip slot 1)
savedSlots[2], // 2 o'clock (strip slot 2)
)
accentColor = colorPrimary
surfaceColor = colorSurface
labelColor = colorOnSurface
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
onCenterClick = { /* unused: tap on unlocked center locks the wheel */ }
onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() }
}
wheelContainer.addView(wheelView)
// App icon centered at the bottom
val iconSz = dp(48f).toInt()
val footerIcon = android.widget.ImageView(ctx).apply {
setImageDrawable(ctx.packageManager.getApplicationIcon(ctx.packageName))
layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz).also {
it.gravity = Gravity.CENTER_HORIZONTAL
it.topMargin = dp(12f).toInt()
it.bottomMargin = dp(16f).toInt()
}
}
root.addView(wheelContainer)
root.addView(footerIcon)
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
(footerIcon.layoutParams as android.widget.LinearLayout.LayoutParams).bottomMargin = dp(16f).toInt() + bars.bottom
footerIcon.requestLayout()
insets
}
return root
}
override fun onResume() {
super.onResume()
requireActivity().invalidateOptionsMenu()
val ctx = requireContext()
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
requireActivity().title = ""
val textColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.DKGRAY)
val container = android.widget.TextView(ctx).apply {
text = getString(R.string.app_name)
setTextColor(textColor)
textSize = 20f
typeface = Typeface.DEFAULT_BOLD
tag = "wheel_title"
}
toolbar.addView(container, Toolbar.LayoutParams(
Toolbar.LayoutParams.WRAP_CONTENT,
Toolbar.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
))
}
override fun onPause() {
super.onPause()
wheelView?.let { wv ->
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putFloat("circular_wheel_angle", wv.wheelAngle).apply()
}
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
requireActivity().invalidateOptionsMenu()
}
fun unlockWheelLock() {
wheelView?.unlockWheel()
}
}
// ---------------------------------------------------------------------------
// Custom wheel view
// ---------------------------------------------------------------------------
class CircularWheelView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
data class WheelItem(
val navId: Int,
@DrawableRes val iconRes: Int,
val label: String
)
// ---- public properties ------------------------------------------------
var items: List<WheelItem> = emptyList()
set(value) {
field = value
iconBitmaps = arrayOfNulls(value.size)
if (cx > 0f) reloadBitmaps()
invalidate()
}
var accentColor: Int = Color.RED
set(value) { field = value; if (cx > 0f) reloadBitmaps(); invalidate() }
var surfaceColor: Int = Color.WHITE
set(value) { field = value; invalidate() }
var labelColor: Int = Color.DKGRAY
set(value) { field = value; invalidate() }
var isWheelLocked = false
set(value) { field = value; invalidate() }
var onItemClick: ((Int) -> Unit)? = null
var onCenterClick: (() -> Unit)? = null
var onWheelCenterLockedTap: (() -> Unit)? = null
// ---- geometry ---------------------------------------------------------
private var cx = 0f
private var cy = 0f
private var outerRadius = 0f
private var innerRadius = 0f
// ---- paint ------------------------------------------------------------
private val discPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val accentRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val accentRing2Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
private val centerFillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val centerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var iconBitmaps: Array<Bitmap?> = emptyArray()
private var centerBitmap: Bitmap? = null
private var centerUnlockedBitmap: Bitmap? = null
private val grayFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
private var lockShakeAngle = 0f
private var shakeAnimator: ValueAnimator? = null
// ---- touch & fling ----------------------------------------------------
var wheelAngle = 0f
private var isDragging = false
private var snapAnimator: ValueAnimator? = null
// Incremental drag state
private var prevTouchAngle = 0f
private var touchDownX = 0f
private var touchDownY = 0f
// Velocity buffer: stores (cumulative wheel angle, timestamp) for last N samples
private val VEL_SAMPLES = 6
private val velAngles = FloatArray(VEL_SAMPLES)
private val velTimes = LongArray(VEL_SAMPLES)
private var velIdx = 0
private var velCount = 0
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
// ---- helpers ----------------------------------------------------------
private fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
// ---- sizing -----------------------------------------------------------
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
cx = w / 2f
cy = h / 2f
val size = minOf(w, h)
outerRadius = size / 2f * 0.80f
innerRadius = outerRadius * 0.26f
textPaint.textSize = size * 0.034f
dividerPaint.strokeWidth = dp(0.7f)
accentRingPaint.strokeWidth = dp(5f)
accentRing2Paint.strokeWidth = dp(3f)
centerRingPaint.strokeWidth = dp(4f)
reloadBitmaps()
}
private fun reloadBitmaps() {
val iconPx = (outerRadius * 0.24f).toInt().coerceAtLeast(1)
items.forEachIndexed { i, item ->
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
}
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
centerUnlockedBitmap = tintedBitmap(R.drawable.ic_lock_open, accentColor, centerPx)
}
private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? {
if (sizePx <= 0) return null
return try {
val d = AppCompatResources.getDrawable(context, resId)!!.mutate()
DrawableCompat.setTint(DrawableCompat.wrap(d), tint)
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
Canvas(bmp).also { d.setBounds(0, 0, sizePx, sizePx); d.draw(it) }
bmp
} catch (_: Exception) { null }
}
// ---- drawing ----------------------------------------------------------
override fun onDraw(canvas: Canvas) {
if (items.isEmpty()) return
val segCount = items.size
val segDeg = 360f / segCount
// Wheel disc
discPaint.color = surfaceColor
canvas.drawCircle(cx, cy, outerRadius, discPaint)
// Accent ring around wheel
accentRingPaint.color = accentColor
canvas.drawCircle(cx, cy, outerRadius + dp(20f), accentRingPaint)
// Rotatable layer
canvas.save()
canvas.rotate(wheelAngle, cx, cy)
// Divider lines between segments
dividerPaint.color = (labelColor and 0x00FFFFFF) or (100 shl 24)
for (i in 0 until segCount) {
val rad = Math.toRadians((i * segDeg).toDouble())
val cos = cos(rad).toFloat()
val sin = sin(rad).toFloat()
canvas.drawLine(
cx + cos * (innerRadius + dp(6f)), cy + sin * (innerRadius + dp(6f)),
cx + cos * (outerRadius - dp(12f)), cy + sin * (outerRadius - dp(12f)),
dividerPaint
)
}
// Segment content
for (i in 0 until segCount) {
val midDeg = i * segDeg + segDeg / 2f
drawSegment(canvas, i, midDeg)
}
canvas.restore()
// Center button — always upright
centerRingPaint.color = accentColor
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
centerFillPaint.color = surfaceColor
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
val activeCenterBitmap = if (isWheelLocked) centerBitmap else centerUnlockedBitmap
activeCenterBitmap?.let {
canvas.save()
// Shake pivots around the bottom-centre of the icon
if (lockShakeAngle != 0f) canvas.rotate(lockShakeAngle, cx, cy + it.height / 2f)
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
canvas.restore()
}
}
private fun drawSegment(canvas: Canvas, index: Int, midDeg: Float) {
val rad = Math.toRadians(midDeg.toDouble())
val cosA = cos(rad).toFloat()
val sinA = sin(rad).toFloat()
val iconX = cx + cosA * (outerRadius * 0.63f)
val iconY = cy + sinA * (outerRadius * 0.63f)
// Icon — radially oriented; top items are naturally upside-down
iconBitmaps.getOrNull(index)?.let { bmp ->
canvas.save()
canvas.translate(iconX, iconY)
canvas.rotate(midDeg - 90f)
if (isWheelLocked) {
bitmapPaint.colorFilter = grayFilter
bitmapPaint.alpha = 100
}
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
if (isWheelLocked) {
bitmapPaint.colorFilter = null
bitmapPaint.alpha = 255
}
canvas.restore()
}
// Curved label — same radial orientation as icons.
// In the local rotated frame the wheel arc is a circle of radius `labelRadius`
// with its centre directly "above" at (0, -labelRadius). A CCW arc through (0,0)
// flows rightward at that point, matching the natural reading direction at 6 o'clock.
val labelRadius = outerRadius * 0.84f
val textX = cx + cosA * labelRadius
val textY = cy + sinA * labelRadius
val label = items[index].label
textPaint.color = if (isWheelLocked) (labelColor and 0x00FFFFFF) or (80 shl 24) else labelColor
textPaint.textAlign = Paint.Align.LEFT
val halfAngleDeg = Math.toDegrees((textPaint.measureText(label) / 2.0) / labelRadius).toFloat()
val localArcRect = RectF(-labelRadius, -2f * labelRadius, labelRadius, 0f)
val arcPath = Path().apply { addArc(localArcRect, 90f + halfAngleDeg, -(halfAngleDeg * 2f)) }
canvas.save()
canvas.translate(textX, textY)
canvas.rotate(midDeg - 90f)
canvas.drawTextOnPath(label, arcPath, 0f, textPaint.textSize * 0.36f, textPaint)
canvas.restore()
}
// ---- touch ------------------------------------------------------------
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
snapAnimator?.cancel()
prevTouchAngle = angleAt(event.x, event.y)
touchDownX = event.x
touchDownY = event.y
isDragging = false
velIdx = 0
velCount = 0
recordVelSample()
return true
}
MotionEvent.ACTION_MOVE -> {
val curr = angleAt(event.x, event.y)
// Incremental delta — normalised to [-180, 180] to survive the ±180° wrap
var dA = curr - prevTouchAngle
if (dA > 180f) dA -= 360f
if (dA < -180f) dA += 360f
prevTouchAngle = curr
val moved = hypot(event.x - touchDownX, event.y - touchDownY)
if (moved > touchSlop || isDragging) {
isDragging = true
wheelAngle += dA
recordVelSample()
invalidate()
}
}
MotionEvent.ACTION_UP -> {
if (!isDragging) {
val dist = hypot(event.x - cx, event.y - cy)
when {
dist <= innerRadius -> {
if (isWheelLocked) {
onWheelCenterLockedTap?.invoke()
} else {
isWheelLocked = true
}
}
dist <= outerRadius -> {
if (isWheelLocked) {
val idx = segmentAt(event.x, event.y)
if (idx in items.indices) animateToSixOClock(idx) {
vibrateDevice()
shakeLock()
}
} else {
val idx = segmentAt(event.x, event.y)
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
}
}
}
} else {
val vel = computeVelocity()
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
}
}
MotionEvent.ACTION_CANCEL -> {
if (isDragging) {
val vel = computeVelocity()
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
}
}
}
return true
}
private fun recordVelSample() {
val slot = velIdx % VEL_SAMPLES
velAngles[slot] = wheelAngle
velTimes[slot] = System.currentTimeMillis()
velIdx++
if (velCount < VEL_SAMPLES) velCount++
}
/** Returns angular velocity in degrees per millisecond, using the oldest available sample. */
private fun computeVelocity(): Float {
if (velCount < 2) return 0f
val newest = (velIdx - 1 + VEL_SAMPLES) % VEL_SAMPLES
// Use the sample that is ~100 ms old for a stable estimate
val oldest = (velIdx - velCount + VEL_SAMPLES) % VEL_SAMPLES
val dt = velTimes[newest] - velTimes[oldest]
if (dt <= 0L) return 0f
return (velAngles[newest] - velAngles[oldest]) / dt
}
/**
* Kick off a physics-based fling: uniform deceleration from [initialVel] to zero,
* then snap to the nearest segment.
* Formula: total_rotation = v0² / (2 * DECEL), duration = v0 / DECEL
* With DecelerateInterpolator(1) the initial animation velocity matches v0.
*/
private fun fling(initialVel: Float) {
val DECEL = 0.0008f // deg / ms² — tune for feel
val duration = (abs(initialVel) / DECEL).toLong().coerceIn(200, 3500)
val sign = if (initialVel >= 0f) 1f else -1f
val totalRot = sign * initialVel * initialVel / (2f * DECEL)
val startAngle = wheelAngle
val endAngle = startAngle + totalRot
snapAnimator = ValueAnimator.ofFloat(startAngle, endAngle).apply {
this.duration = duration
interpolator = DecelerateInterpolator() // matches v0 at t=0
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(a: Animator) { snapToNearest() }
})
start()
}
}
private fun angleAt(x: Float, y: Float): Float =
Math.toDegrees(atan2((y - cy).toDouble(), (x - cx).toDouble())).toFloat()
private fun segmentAt(x: Float, y: Float): Int {
var a = angleAt(x, y) - wheelAngle
a = (a % 360f + 360f) % 360f
return (a / (360f / items.size)).toInt() % items.size
}
private fun animateToSixOClock(index: Int, onDone: () -> Unit) {
val segDeg = 360f / items.size.coerceAtLeast(1)
val midDeg = index * segDeg + segDeg / 2f
// delta needed so this segment's midpoint lands at 90° (6 o'clock in math coords)
var delta = (90f - midDeg) - wheelAngle
// normalise to shortest path [-180, 180]
delta = ((delta % 360f) + 360f) % 360f
if (delta > 180f) delta -= 360f
val endAngle = wheelAngle + delta
snapAnimator?.cancel()
snapAnimator = ValueAnimator.ofFloat(wheelAngle, endAngle).apply {
duration = 350
interpolator = DecelerateInterpolator()
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationCancel(a: Animator) { cancelled = true }
override fun onAnimationEnd(a: Animator) { if (!cancelled) onDone() }
})
start()
}
}
private fun snapToNearest() {
val segDeg = 360f / items.size.coerceAtLeast(1)
val target = (wheelAngle / segDeg).roundToInt() * segDeg
snapAnimator = ValueAnimator.ofFloat(wheelAngle, target).apply {
duration = 300
interpolator = DecelerateInterpolator()
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
start()
}
}
private fun vibrateDevice() {
val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
v.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
}
fun shakeLock() {
shakeAnimator?.cancel()
shakeAnimator = ValueAnimator.ofFloat(0f, -18f, 18f, -12f, 12f, -6f, 6f, 0f).apply {
duration = 500
interpolator = LinearInterpolator()
addUpdateListener { lockShakeAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(a: Animator) { lockShakeAngle = 0f; invalidate() }
})
start()
}
}
fun unlockWheel() {
isWheelLocked = false
lockShakeAngle = 0f
shakeAnimator?.cancel()
invalidate()
}
}
@@ -168,10 +168,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
when {
accountNumber.startsWith("bmlqr:") -> {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
bundle.putString(KEY_COLOR, "#0066A1")
}
account != null -> {
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E")
@@ -9,6 +9,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
@@ -178,7 +179,7 @@ class ContactsFragment : Fragment() {
colorHex = contact.bankColor,
imageHash = contact.imageHash
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
}
private fun confirmDelete(contact: ContactDisplay) {
@@ -194,10 +195,10 @@ class ContactsFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact)
Snackbar.make(binding.root, R.string.contact_deleted, Snackbar.LENGTH_SHORT).show()
} else {
Toast.makeText(requireContext(), R.string.contact_delete_failed, Toast.LENGTH_SHORT).show()
Snackbar.make(binding.root, R.string.contact_delete_failed, Snackbar.LENGTH_LONG).show()
}
}
}
@@ -4,13 +4,13 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -25,9 +25,6 @@ import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -37,35 +34,21 @@ class DashboardFragment : Fragment() {
private var _binding: FragmentDashboardBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var pendingQrCardNumber: String? = null
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
if (qr?.accountNumber != null) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = defaultFrom
)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -100,11 +83,11 @@ class DashboardFragment : Fragment() {
}
binding.cardPendingFinances.setOnClickListener {
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
}
binding.cardOverdue.setOnClickListener {
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
}
val cardAdapter = DashboardCardAdapter()
@@ -113,29 +96,20 @@ class DashboardFragment : Fragment() {
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
val updateCardList = {
val credStore = CredentialStore(requireContext())
val hidden = credStore.getHiddenDashboardCardNumbers()
val mibItems = (viewModel.mibCards.value ?: emptyList())
.filter { !hidden.contains(it.maskedCardNumber) }
.map { CardItem.Mib(it) }
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
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) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
val defaultNum = credStore.getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
if (def != null) listOf(def) + all.filter { it !== def } else all
} else all
cardAdapter.update(ordered)
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
cardAdapter.update(all)
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
val isBottomNav = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
@@ -146,32 +120,13 @@ class DashboardFragment : Fragment() {
override fun onResume() {
super.onResume()
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
if (isBottom) {
requireActivity().title = getString(R.string.app_name)
val size = (28 * resources.displayMetrics.density).toInt()
val gap = (8 * resources.displayMetrics.density).toInt()
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bmp)
icon.setBounds(0, 0, size, size)
icon.draw(canvas)
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
android.graphics.drawable.BitmapDrawable(resources, bmp)
} else {
requireActivity().title = getString(R.string.nav_dashboard)
}
requireActivity().title = getString(R.string.nav_dashboard)
refreshQuickActions()
}
override fun onPause() {
super.onPause()
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
}
private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) {
binding.buttonBar.visibility = View.GONE
return
@@ -323,23 +278,26 @@ class DashboardFragment : Fragment() {
.groupBy({ it.first }, { it.second })
.mapValues { (_, vs) -> vs.sum() }
val blockedMvr = blockedByCurrency["MVR"] ?: 0.0
val blockedUsd = blockedByCurrency["USD"] ?: 0.0
val blockedTotal = blockedByCurrency.values.sum()
if (blockedTotal > 0.0) {
// Primary line: prefer MVR if present, otherwise the first currency.
val primaryCcy = if ("MVR" in blockedByCurrency) "MVR" else blockedByCurrency.keys.first()
val primaryAmt = blockedByCurrency.getValue(primaryCcy)
binding.tvBlockedTotal.text = if (hide) "$primaryCcy ••••••" else "$primaryCcy %,.2f".format(primaryAmt)
if (blockedMvr > 0.0) {
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
binding.cardBlockedMvr.visibility = View.VISIBLE
val secondary = blockedByCurrency.filterKeys { it != primaryCcy }
if (secondary.isNotEmpty()) {
binding.tvBlockedSecondary.text = secondary.entries.joinToString(" · ") { (ccy, amt) ->
if (hide) "$ccy ••••••" else "$ccy %,.2f".format(amt)
}
binding.tvBlockedSecondary.visibility = View.VISIBLE
} else {
binding.tvBlockedSecondary.visibility = View.GONE
}
binding.cardBlocked.visibility = View.VISIBLE
} else {
binding.cardBlockedMvr.visibility = View.GONE
binding.cardBlocked.visibility = View.GONE
}
if (blockedUsd > 0.0) {
binding.tvBlockedUsd.text = if (hide) "USD ••••••" else "USD %,.2f".format(blockedUsd)
binding.cardBlockedUsd.visibility = View.VISIBLE
} else {
binding.cardBlockedUsd.visibility = View.GONE
}
binding.rowBlocked.visibility = if (blockedTotal > 0.0) View.VISIBLE else View.GONE
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
@@ -352,7 +310,8 @@ class DashboardFragment : Fragment() {
binding.cardOverdue.visibility = View.GONE
}
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
binding.rowAttention.visibility =
if (blockedTotal > 0.0 || overdueTotal > 0.0) View.VISIBLE else View.GONE
}
private fun updatePendingFinances() {
@@ -416,7 +375,7 @@ class DashboardFragment : Fragment() {
if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
@@ -424,17 +383,8 @@ class DashboardFragment : Fragment() {
val nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported
btnPayNfc.setOnClickListener {
if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
NfcPaymentUtil.checkAndProceed(requireContext()) {
val accountNumber = (item as CardItem.Bml).account.accountNumber
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_pay_with_card,
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
)
}
}
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
@@ -68,7 +68,6 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.ThemeHelper
class HomeActivity : AppCompatActivity() {
@@ -76,8 +75,6 @@ class HomeActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
private var cachedTransferFragment: TransferFragment? = null
private val navBackStack = ArrayDeque<Int>()
private var backPressedOnce = false
private val backPressHandler = Handler(Looper.getMainLooper())
@@ -91,10 +88,6 @@ class HomeActivity : AppCompatActivity() {
private val warningRunnable = Runnable { showAutolockWarning() }
private var isLocked = false
private var pendingWheelUnlock = false
private var hasUnreadNotifications = false
private var notifMenuItem: MenuItem? = null
private val autolockRunnable = Runnable {
countdownTimer?.cancel(); countdownTimer = null
@@ -104,21 +97,6 @@ class HomeActivity : AppCompatActivity() {
if (securitySet) lock()
}
fun lockApp() = lock()
fun notifyWheelLockTap() {
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
.getString("security_method", null) != null
if (securitySet) {
pendingWheelUnlock = true
lock()
} else {
// No security configured — unlock the wheel immediately
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
?.unlockWheelLock()
}
}
private fun lock() {
isLocked = true
startActivity(
@@ -128,7 +106,6 @@ class HomeActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater)
@@ -141,9 +118,6 @@ class HomeActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
// Auth guard: HomeActivity must only be reachable after LockActivity or fresh login.
// Using loadSecurityHash() (EncryptedSharedPreferences) instead of plain prefs so
// a rooted device cannot bypass this by editing security_method in plain prefs.
@@ -177,7 +151,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment()
@@ -258,31 +232,14 @@ class HomeActivity : AppCompatActivity() {
if (savedInstanceState == null) {
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
val shareQrText = intent.getStringExtra("share_qr_text")
when {
shareQrText != null -> {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
routeSharedQrText(shareQrText)
}
navDest != -1 -> {
val fragment = when {
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
else -> null
}
navigateTo(navDest, fragment)
}
else -> {
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
show(CircularNavFragment())
} else {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
}
if (navDest != -1) {
val fragment = if (autoScan && navDest == R.id.nav_transfer)
TransferFragment.newInstanceWithAutoScan()
else null
navigateTo(navDest, fragment)
} else {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
}
@@ -293,43 +250,15 @@ class HomeActivity : AppCompatActivity() {
binding.drawerLayout.closeDrawers()
return
}
// Let CardsFragment handle back if in manage mode
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val navMode = NavCustomization.getNavMode(prefs)
// Circular nav mode: back always returns to the wheel
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
return
}
if (currentFrag is CircularNavFragment) {
if (backPressedOnce) {
backPressHandler.removeCallbacks(resetBackPress)
finish()
} else {
backPressedOnce = true
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
backPressHandler.postDelayed(resetBackPress, 2000)
}
} else {
show(CircularNavFragment())
}
return
}
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
return
}
// In bottom nav mode, pressing back navigates up the hierarchy
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
show(MoreFragment())
@@ -385,44 +314,21 @@ class HomeActivity : AppCompatActivity() {
.commit()
}
private fun updateNavSelection(itemId: Int) {
binding.navigationView.setCheckedItem(itemId)
if (binding.bottomNavigation.visibility == View.VISIBLE) {
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
}
fun applyNavMode() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
when (NavCustomization.getNavMode(prefs)) {
NavCustomization.NAV_MODE_BOTTOM -> {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs)
applyNavLabelVisibility()
}
NavCustomization.NAV_MODE_CIRCULAR -> {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.GONE
}
else -> {
supportActionBar?.show()
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true
toggle.syncState()
binding.bottomNavigation.visibility = View.GONE
}
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs)
applyNavLabelVisibility()
} else {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true
toggle.syncState()
binding.bottomNavigation.visibility = View.GONE
}
}
@@ -461,15 +367,11 @@ fun applyNavLabelVisibility() {
}
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
// Restore action bar when leaving the circular wheel screen
if (NavCustomization.getNavMode(getSharedPreferences("prefs", MODE_PRIVATE)) == NavCustomization.NAV_MODE_CIRCULAR) {
supportActionBar?.show()
}
val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment()
@@ -477,16 +379,25 @@ fun applyNavLabelVisibility() {
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> CardsFragment()
R.id.nav_more -> MoreFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
}
show(dest)
updateNavSelection(itemId)
binding.navigationView.setCheckedItem(itemId)
if (binding.bottomNavigation.visibility == View.VISIBLE) {
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
}
fun setBottomNavVisible(visible: Boolean) {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottom) {
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
}
}
@@ -515,33 +426,6 @@ fun applyNavLabelVisibility() {
.commit()
}
fun showWithBackStackAndNav(fragment: Fragment, itemId: Int) {
navBackStack.addLast(binding.bottomNavigation.selectedItemId)
showWithBackStack(fragment)
updateNavSelection(itemId)
}
private fun routeSharedQrText(text: String) {
val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
return
}
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
if (qr?.accountNumber != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = store.getDefaultAccountNumber()
))
} else {
Toast.makeText(this, R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
}
override fun onResume() {
super.onResume()
// Returning from LockActivity — refresh sessions since they may have expired.
@@ -550,11 +434,6 @@ fun applyNavLabelVisibility() {
pauseTime = 0L
resetAutolockTimer()
autoRefresh(CredentialStore(this))
if (pendingWheelUnlock) {
pendingWheelUnlock = false
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
?.unlockWheelLock()
}
return
}
// If we were away long enough to have hit the autolock timeout (e.g. while
@@ -633,23 +512,14 @@ fun applyNavLabelVisibility() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.toolbar_menu, menu)
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
val eyeItem = menu.findItem(R.id.action_hide_amounts)
eyeItem?.isVisible = true
eyeItem?.isVisible = eyeEnabled
val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
notifMenuItem = menu.findItem(R.id.action_notifications)
notifMenuItem?.setIcon(if (hasUnreadNotifications) R.drawable.ic_bell else R.drawable.ic_bell_read)
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
menu.findItem(R.id.action_notifications)?.isVisible = !onWheel
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) {
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
@@ -662,10 +532,6 @@ fun applyNavLabelVisibility() {
}
return true
}
if (item.itemId == R.id.action_notifications) {
openNotificationsSheet()
return true
}
if (item.itemId == R.id.action_hide_amounts) {
val newHidden = !(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.value = newHidden
@@ -679,16 +545,6 @@ fun applyNavLabelVisibility() {
return super.onOptionsItemSelected(item)
}
fun setNotificationUnread(hasUnread: Boolean) {
hasUnreadNotifications = hasUnread
notifMenuItem?.setIcon(if (hasUnread) R.drawable.ic_bell else R.drawable.ic_bell_read)
}
private fun openNotificationsSheet() {
val sheet = NotificationsSheetFragment()
sheet.onUnreadCountChanged = { hasUnread -> setNotificationUnread(hasUnread) }
sheet.show(supportFragmentManager, "notifications")
}
fun relogin() {
val store = CredentialStore(this)
@@ -7,20 +7,6 @@ import sh.sar.basedbank.R
object NavCustomization {
const val NAV_MODE_DRAWER = "drawer"
const val NAV_MODE_BOTTOM = "bottom"
const val NAV_MODE_CIRCULAR = "circular"
fun getNavMode(prefs: SharedPreferences): String {
val explicit = prefs.getString("nav_mode", null)
if (explicit != null) return explicit
return if (prefs.getBoolean("bottom_nav", false)) NAV_MODE_BOTTOM else NAV_MODE_DRAWER
}
fun saveNavMode(prefs: SharedPreferences, mode: String) {
prefs.edit().putString("nav_mode", mode).apply()
}
data class NavItemDef(
val id: Int,
val key: String,
@@ -76,31 +62,8 @@ object NavCustomization {
}
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
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_3_key", null), R.id.nav_contacts),
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
)
fun saveCircularSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.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_3_key", idToKey(slots[2]) ?: "nav_contacts")
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
.apply()
}
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
val slots = getSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slots }
}
/** Items shown in More when circular nav is active — everything not in the saved wheel slots. */
private fun getCircularMoreItems(prefs: SharedPreferences): List<NavItemDef> {
val slotIds = getCircularSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slotIds }
}
}
@@ -1,568 +0,0 @@
package sh.sar.basedbank.ui.home
import android.app.Dialog
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
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.BmlNotificationsClient
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
import sh.sar.basedbank.util.NotificationsCache
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// ── Sealed list item for date-grouped lists ───────────────────────────────────
private sealed class NotifListItem {
data class Header(val label: String) : NotifListItem()
data class Entry(val n: AppNotification) : NotifListItem()
}
private val headerSdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
private val dateKeySdf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private fun toGroupedList(notifications: List<AppNotification>): List<NotifListItem> {
val result = mutableListOf<NotifListItem>()
var lastKey = ""
for (n in notifications) {
val key = dateKeySdf.format(Date(n.timestampMs))
if (key != lastKey) {
result.add(NotifListItem.Header(headerSdf.format(Date(n.timestampMs))))
lastKey = key
}
result.add(NotifListItem.Entry(n))
}
return result
}
class NotificationsSheetFragment : BottomSheetDialogFragment() {
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
private val allNotifications = mutableListOf<AppNotification>()
private val bmlNextPage = mutableMapOf<String, Int>()
private val bmlDone = mutableMapOf<String, Boolean>()
private val mibNextStart = mutableMapOf<String, Int>()
private val mibDone = mutableMapOf<String, Boolean>()
private var isLoadingMore = false
private var mediator: TabLayoutMediator? = null
private val tabAdapters = arrayOfNulls<NotifPageAdapter>(3)
private val tabLabels = listOf("All", "Alerts", "Information")
private val tabGroupFilters = listOf<String?>(null, "ALERTS", "INFORMATION")
private lateinit var viewPager: ViewPager2
private lateinit var btnMarkAllRead: TextView
private val app get() = requireActivity().application as BasedBankApp
// ── Lifecycle ─────────────────────────────────────────────────────────────────
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val d = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
d.setOnShowListener {
val sheet = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)!!
BottomSheetBehavior.from(sheet).apply {
state = BottomSheetBehavior.STATE_EXPANDED
skipCollapsed = true
}
}
return d
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.sheet_notifications, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val tabLayout = view.findViewById<TabLayout>(R.id.notifTabs)
viewPager = view.findViewById(R.id.notifPager)
btnMarkAllRead = view.findViewById(R.id.btnMarkAllRead)
tabAdapters[0] = NotifPageAdapter(null)
tabAdapters[1] = NotifPageAdapter("ALERTS")
tabAdapters[2] = NotifPageAdapter("INFORMATION")
viewPager.adapter = PageAdapter()
viewPager.offscreenPageLimit = 2
mediator = TabLayoutMediator(tabLayout, viewPager) { tab, pos ->
tab.text = tabLabels[pos]
}.also { it.attach() }
btnMarkAllRead.setOnClickListener { markAllRead() }
loadFromCache()
refreshFromNetwork()
}
override fun onDestroyView() {
mediator?.detach()
mediator = null
super.onDestroyView()
}
// ── Data loading ──────────────────────────────────────────────────────────────
private fun loadFromCache() {
val ctx = requireContext()
val readIds = NotificationsCache.getMibReadIds(ctx)
val cached = mutableListOf<AppNotification>()
app.bmlSessions.forEach { (loginId, _) ->
cached.addAll(NotificationsCache.loadBml(ctx, loginId))
}
app.mibSessions.forEach { (loginId, _) ->
cached.addAll(NotificationsCache.loadMib(ctx, loginId, readIds))
}
if (cached.isNotEmpty()) {
mergeInto(allNotifications, cached)
refreshAdapters()
}
}
private fun refreshFromNetwork() {
val bmlSessions = app.bmlSessions.toMap()
val mibSessions = app.mibSessions.toMap()
lifecycleScope.launch {
val bmlClient = BmlNotificationsClient()
bmlSessions.forEach { (loginId, session) ->
val result = withContext(Dispatchers.IO) {
bmlClient.fetchNotifications(session, loginId, page = 1)
}
if (result.items.isNotEmpty() && isAdded) {
allNotifications.removeAll { it.bank == "BML" && it.loginId == loginId }
allNotifications.addAll(result.items)
allNotifications.sortByDescending { it.timestampMs }
bmlNextPage[loginId] = 2
bmlDone[loginId] = result.items.size >= result.total
NotificationsCache.saveBml(requireContext(), loginId, result.items)
refreshAdapters()
broadcastUnread()
}
}
val mibClient = MibActivityHistoryClient()
mibSessions.forEach { (loginId, session) ->
val result = withContext(Dispatchers.IO) {
mibClient.fetchUntilEnough(session, loginId)
}
if (result.items.isNotEmpty() && isAdded) {
val readIds = NotificationsCache.getMibReadIds(requireContext())
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
allNotifications.removeAll { it.bank == "MIB" && it.loginId == loginId }
allNotifications.addAll(resolved)
allNotifications.sortByDescending { it.timestampMs }
mibNextStart[loginId] = result.nextStart
mibDone[loginId] = result.nextStart > result.totalCount
NotificationsCache.saveMib(requireContext(), loginId, result.items)
refreshAdapters()
broadcastUnread()
}
}
}
}
private fun loadMore() {
if (isLoadingMore) return
val bmlSessions = app.bmlSessions.toMap()
val mibSessions = app.mibSessions.toMap()
val anyLeft = bmlSessions.keys.any { bmlDone[it] != true } ||
mibSessions.keys.any { mibDone[it] != true }
if (!anyLeft) return
isLoadingMore = true
lifecycleScope.launch {
val bmlClient = BmlNotificationsClient()
bmlSessions.forEach { (loginId, session) ->
if (bmlDone[loginId] == true) return@forEach
val page = bmlNextPage[loginId] ?: 2
val result = withContext(Dispatchers.IO) {
bmlClient.fetchNotifications(session, loginId, page = page)
}
if (result.items.isNotEmpty() && isAdded) {
allNotifications.addAll(result.items.filter { n -> allNotifications.none { it.id == n.id } })
allNotifications.sortByDescending { it.timestampMs }
bmlNextPage[loginId] = page + 1
bmlDone[loginId] = allNotifications.count { it.bank == "BML" && it.loginId == loginId } >= result.total
val allForLogin = allNotifications.filter { it.bank == "BML" && it.loginId == loginId }
NotificationsCache.saveBml(requireContext(), loginId, allForLogin)
}
}
val mibClient = MibActivityHistoryClient()
mibSessions.forEach { (loginId, session) ->
if (mibDone[loginId] == true) return@forEach
val start = mibNextStart[loginId] ?: 1
val result = withContext(Dispatchers.IO) {
mibClient.fetchActivity(session, loginId, start, start + 29)
}
if (result.items.isNotEmpty() && isAdded) {
val readIds = NotificationsCache.getMibReadIds(requireContext())
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
allNotifications.addAll(resolved.filter { n -> allNotifications.none { it.id == n.id } })
allNotifications.sortByDescending { it.timestampMs }
mibNextStart[loginId] = result.nextStart
mibDone[loginId] = result.nextStart > result.totalCount
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
}
}
isLoadingMore = false
if (isAdded) refreshAdapters()
}
}
// ── Mark all read ─────────────────────────────────────────────────────────────
private fun markAllRead() {
val bmlSessions = app.bmlSessions.toMap()
val mibIds = allNotifications.filter { it.bank == "MIB" && !it.isRead }.map { it.id }
lifecycleScope.launch {
var bmlOk = true
bmlSessions.forEach { (_, session) ->
val ok = withContext(Dispatchers.IO) { BmlNotificationsClient().markAllRead(session) }
if (!ok) bmlOk = false
}
if (mibIds.isNotEmpty()) NotificationsCache.addMibReadIds(requireContext(), mibIds)
val updated = allNotifications.map { it.copy(isRead = true) }
allNotifications.clear()
allNotifications.addAll(updated)
refreshAdapters()
broadcastUnread()
if (isAdded) {
val msg = if (bmlOk) "All notifications marked as read"
else "Marked read locally — some accounts had a network error"
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun mergeInto(target: MutableList<AppNotification>, incoming: List<AppNotification>) {
val existingIds = target.map { it.id }.toSet()
target.addAll(incoming.filter { it.id !in existingIds })
target.sortByDescending { it.timestampMs }
}
private fun refreshAdapters() {
tabGroupFilters.forEachIndexed { i, filter ->
val filtered = if (filter == null) allNotifications
else allNotifications.filter { it.group == filter }
tabAdapters[i]?.update(filtered)
}
}
private fun broadcastUnread() {
onUnreadCountChanged?.invoke(allNotifications.any { !it.isRead })
}
private fun onNotificationTapped(item: AppNotification) {
val idx = allNotifications.indexOfFirst { it.id == item.id }
if (idx >= 0 && !allNotifications[idx].isRead) {
allNotifications[idx] = allNotifications[idx].copy(isRead = true)
if (item.bank == "MIB") NotificationsCache.addMibReadIds(requireContext(), listOf(item.id))
refreshAdapters()
broadcastUnread()
}
val detail = item.detailFields.joinToString("\n\n") { (k, v) -> "$k\n$v" }
MaterialAlertDialogBuilder(requireActivity())
.setTitle(item.title)
.setMessage(detail.ifBlank { item.message })
.setPositiveButton("OK", null)
.show()
}
// ── ViewPager2 page adapter ───────────────────────────────────────────────────
private inner class PageAdapter : RecyclerView.Adapter<PageAdapter.VH>() {
inner class VH(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
override fun getItemCount() = 3
override fun getItemViewType(position: Int) = position
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val rv = RecyclerView(parent.context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
layoutManager = LinearLayoutManager(context)
adapter = tabAdapters[viewType]
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
val lm = rv.layoutManager as LinearLayoutManager
if (lm.findLastVisibleItemPosition() >= lm.itemCount - 4) loadMore()
}
})
}
return VH(rv)
}
override fun onBindViewHolder(holder: VH, position: Int) {}
}
// ── Per-tab list adapter ──────────────────────────────────────────────────────
private inner class NotifPageAdapter(private val groupFilter: String?) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val displayItems = mutableListOf<NotifListItem>()
fun update(filtered: List<AppNotification>) {
displayItems.clear()
displayItems.addAll(toGroupedList(filtered))
notifyDataSetChanged()
}
override fun getItemCount() = if (displayItems.isEmpty()) 1 else displayItems.size
override fun getItemViewType(position: Int): Int {
if (displayItems.isEmpty()) return 2 // empty
return when (displayItems[position]) {
is NotifListItem.Header -> 0
is NotifListItem.Entry -> 1
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
0 -> HeaderVH(buildHeaderView(parent.context))
1 -> ItemVH(buildRowView(parent.context))
else -> EmptyVH(buildEmptyView(parent.context))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind((displayItems[position] as NotifListItem.Header).label)
is ItemVH -> holder.bind((displayItems[position] as NotifListItem.Entry).n)
}
}
// ── Date header ───────────────────────────────────────────────────────────
inner class HeaderVH(private val tv: TextView) : RecyclerView.ViewHolder(tv) {
fun bind(label: String) { tv.text = label }
}
private fun buildHeaderView(ctx: android.content.Context): TextView {
val dp = ctx.resources.displayMetrics.density
return TextView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
setPadding((16 * dp).toInt(), (20 * dp).toInt(), (16 * dp).toInt(), (6 * dp).toInt())
}
}
// ── Empty state ───────────────────────────────────────────────────────────
inner class EmptyVH(v: View) : RecyclerView.ViewHolder(v)
private fun buildEmptyView(ctx: android.content.Context): View {
val dp = ctx.resources.displayMetrics.density
return LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
(300 * dp).toInt()
)
addView(ImageView(ctx).apply {
setImageResource(R.drawable.ic_bell_read)
val s = (48 * dp).toInt()
layoutParams = LinearLayout.LayoutParams(s, s).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = (12 * dp).toInt()
}
alpha = 0.35f
})
addView(TextView(ctx).apply {
text = "No notifications"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
alpha = 0.5f
gravity = Gravity.CENTER
})
}
}
// ── Notification row ──────────────────────────────────────────────────────
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
val iconBg: View = v.findViewWithTag("iconBg")
val iconIv: ImageView = v.findViewWithTag("icon")
val unreadBadge: View = v.findViewWithTag("badge")
val titleTv: TextView = v.findViewWithTag("title")
val messageTv: TextView = v.findViewWithTag("message")
val bankBadge: TextView = v.findViewWithTag("bank")
fun bind(item: AppNotification) {
titleTv.text = item.title
messageTv.text = item.message
bankBadge.text = item.bank
unreadBadge.isVisible = !item.isRead
val (iconRes, colorHex) = iconAndColor(item)
iconIv.setImageResource(iconRes)
iconIv.imageTintList = ColorStateList.valueOf(Color.parseColor(colorHex))
(iconBg.background as? GradientDrawable)
?.setColor(Color.parseColor(colorHex.replace("#", "#22")))
itemView.alpha = if (item.isRead) 0.65f else 1f
itemView.setOnClickListener { onNotificationTapped(item) }
}
}
private fun buildRowView(ctx: android.content.Context): View {
val dp = ctx.resources.displayMetrics.density
val surfaceColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.BLACK)
return LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
setPadding((16 * dp).toInt(), (12 * dp).toInt(), (16 * dp).toInt(), (12 * dp).toInt())
// Icon circle + badge overlay
val frameSize = (44 * dp).toInt()
val iconFrame = FrameLayout(ctx).apply {
layoutParams = LinearLayout.LayoutParams(frameSize, frameSize).apply {
marginEnd = (12 * dp).toInt()
}
}
// Circle background (fills the frame)
val circleSize = (40 * dp).toInt()
iconFrame.addView(View(ctx).apply {
tag = "iconBg"
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize, Gravity.CENTER)
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor("#33FFFFFF"))
}
})
// Icon
val iconSize = (22 * dp).toInt()
iconFrame.addView(ImageView(ctx).apply {
tag = "icon"
layoutParams = FrameLayout.LayoutParams(iconSize, iconSize, Gravity.CENTER)
})
// Unread badge — bottom-right corner
val badgeSize = (12 * dp).toInt()
iconFrame.addView(View(ctx).apply {
tag = "badge"
layoutParams = FrameLayout.LayoutParams(badgeSize, badgeSize, Gravity.BOTTOM or Gravity.END)
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor("#EF5350"))
setStroke((2 * dp).toInt(), surfaceColor)
}
})
addView(iconFrame)
// Text column
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
// Title + bank badge row
val titleRow = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
}
titleRow.addView(TextView(ctx).apply {
tag = "title"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
setTypeface(null, Typeface.BOLD)
maxLines = 1
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
titleRow.addView(TextView(ctx).apply {
tag = "bank"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelSmall)
alpha = 0.55f
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply { marginStart = (6 * dp).toInt() }
})
textCol.addView(titleRow)
textCol.addView(TextView(ctx).apply {
tag = "message"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.7f
maxLines = 2
})
addView(textCol)
}
}
private fun iconAndColor(item: AppNotification): Pair<Int, String> {
if (item.bank == "MIB") return when {
item.title.contains("Transfer", ignoreCase = true) ||
item.title.contains("Payment", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
item.title.contains("Log in", ignoreCase = true) -> R.drawable.ic_lock_open to "#2196F3"
else -> R.drawable.ic_receipt_check to "#9C27B0"
}
return when {
item.group == "INFORMATION" -> R.drawable.ic_receipt_check to "#2196F3"
item.title.contains("Received", ignoreCase = true) ||
item.title.contains("Sent", ignoreCase = true) ||
item.title.contains("Transfer", ignoreCase = true) ||
item.title.contains("Payment", ignoreCase = true) ||
item.title.contains("Paid", ignoreCase = true) ||
item.title.contains("Funds", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
else -> R.drawable.ic_lock to "#EF5350"
}
}
}
}
@@ -1,14 +1,9 @@
package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -47,16 +42,6 @@ class OtpFragment : Fragment() {
override fun onBindViewHolder(holder: VH, position: Int) {
holder.b.tvOtpLabel.text = entries[position].label
update(holder.b, entries[position].seed)
holder.b.root.setOnClickListener {
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
}
}
}
}
fun tick() {
@@ -7,10 +7,13 @@ import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
@@ -33,9 +36,9 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import java.io.File
@@ -52,6 +55,31 @@ class PayMvQrFragment : Fragment() {
private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// BML card/gateway QR — hand off to dedicated payment screen
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
return@registerForActivityResult
}
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
val activity = requireActivity() as HomeActivity
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose
))
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
@@ -70,20 +98,19 @@ class PayMvQrFragment : Fragment() {
}
setupDropdown()
binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.etReference.addTextChangedListener { scheduleGenerate() }
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
binding.btnShare.isEnabled = false
binding.btnSave.isEnabled = false
binding.btnShare.setOnClickListener { shareQr() }
binding.btnSave.setOnClickListener { saveQr() }
binding.btnScanQr.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
private fun setupDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
}
val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter)
@@ -92,20 +119,6 @@ class PayMvQrFragment : Fragment() {
selectedAccount = picked
scheduleGenerate()
}
// Auto-select default account if none is selected yet
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = eligible.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
val prefix = if (defaultAcc.bank == "BML" && defaultAcc.profileName.isNotBlank()) "${defaultAcc.profileName} · " else ""
binding.actvAccount.setText("$prefix${defaultAcc.accountBriefName}", false)
scheduleGenerate()
}
}
}
}
}
@@ -132,28 +145,8 @@ class PayMvQrFragment : Fragment() {
?.let { "%.2f".format(it) }
val ctx = requireContext()
val includePhone = binding.switchIncludePhone.isChecked
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val store = CredentialStore(ctx)
val mobile = if (includePhone) {
when (account.bank) {
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
else -> null
}?.let { m ->
when {
m.startsWith("+") -> m
m.length == 7 -> "+960$m"
else -> m
}
}
} else null
val purpose = binding.etReference.text?.toString()?.trim()
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
renderQrCard(ctx, account, payload, amountFormatted)
}
if (_binding == null) return
@@ -171,9 +164,7 @@ class PayMvQrFragment : Fragment() {
accountNumber: String,
accountName: String,
acquirer: String,
amountStr: String?,
mobile: String?,
purpose: String
amountStr: String?
): String {
fun tlv(tag: String, value: String): String {
val len = value.length
@@ -183,30 +174,17 @@ class PayMvQrFragment : Fragment() {
val poi = tlv("01", "11")
val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer)
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
val sub03 = tlv("03", accountNumber)
val sub05 = if (!mobile.isNullOrBlank()) tlv("05", mobile) else ""
val sub10 = tlv("10", "IPAY")
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
val mcc = tlv("52", "0000")
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
val currency = tlv("53", "462")
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
val country = tlv("58", "MV")
val name = tlv("59", accountName.take(25))
val ref = generateReference()
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
val timestamp = java.time.LocalDateTime.now()
.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 prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
return prefix + crc16(prefix)
}
private fun generateReference(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..9).map { chars.random() }.joinToString("")
}
private fun crc16(data: String): String {
var crc = 0xFFFF
for (c in data) {
@@ -449,7 +427,7 @@ class PayMvQrFragment : Fragment() {
} else {
b.tvDropdownAccountType.visibility = View.GONE
}
b.tvDropdownBalance.visibility = View.GONE
b.tvDropdownBalance.text = displayData?.balance ?: ""
b.root.alpha = 1f
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -3,7 +3,6 @@ package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
@@ -14,42 +13,20 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnNextLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.Gravity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
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.BmlTapToPayClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
class CardsFragment : Fragment() {
@@ -61,52 +38,21 @@ class CardsFragment : Fragment() {
private var cards: List<CardItem> = emptyList()
private var currentCardPosition: Int = 0
private var cardWidth: Int = 0
private var pendingQrCardNumber: String? = null
private var isManageMode: Boolean = false
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
if (qr?.accountNumber != null) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultFrom = store.getDefaultAccountNumber()
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = defaultFrom
)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
private var isTapMode: Boolean = false
private var tapAnimView: NfcTapAnimationView? = null
private var autoTapModeTriggered = false
// Carousel snapshot captured on enter, used to reverse the exit animation
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
private var carouselCardCenterX = 0f // card center X relative to contentLayout
private var carouselTextLayoutTop = 0f // tvSelectedCardType layout top relative to contentLayout
// Swipe-to-dismiss tracking
private var swipeDragStartRawY = 0f
private var swipeIsDragging = false
private lateinit var stackAdapter: CardStackAdapter
private val store by lazy { CredentialStore(requireContext()) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardsBinding.inflate(inflater, container, false)
@@ -118,7 +64,7 @@ class CardsFragment : Fragment() {
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
stackAdapter = CardStackAdapter(cardWidth)
val stackAdapter = CardStackAdapter(cardWidth)
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = stackAdapter
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
@@ -155,8 +101,24 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
insets
}
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
val updateCardList = {
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
cards = mibItems + bmlItems
stackAdapter.update(cards)
binding.loadingView.visibility = View.GONE
val empty = cards.isEmpty()
binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
if (!empty) {
buildDots(cards.size, currentCardPosition)
updateCardInfo(currentCardPosition)
}
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) {
@@ -166,60 +128,12 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
(activity as? HomeActivity)?.triggerRefreshCards()
binding.btnManageCard.setOnClickListener {
setManageMode(!isManageMode)
}
// Swipe-down on the manage card to dismiss manage mode
binding.manageCardView.root.setOnTouchListener { _, event ->
if (!isManageMode && !isTapMode) return@setOnTouchListener false
val mgr = binding.manageCardView.root
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
mgr.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
swipeDragStartRawY = event.rawY
swipeIsDragging = false
true
}
android.view.MotionEvent.ACTION_MOVE -> {
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
if (dy > 12f || swipeIsDragging) {
swipeIsDragging = true
mgr.translationY = dy
binding.tvSelectedCardType.translationY = dy * 0.6f
val scale = 1f - (dy / (binding.contentLayout.height * 2.5f)).coerceIn(0f, 0.12f)
mgr.scaleX = scale
mgr.scaleY = scale
true
} else false
}
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
if (swipeIsDragging) {
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
swipeIsDragging = false
if (dy > 130f) {
if (isTapMode) setTapMode(false) else setManageMode(false)
} else {
// Snap back
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
binding.tvSelectedCardType.animate().translationY(0f)
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
}
true
} else false
}
else -> false
}
}
binding.btnScanToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
@@ -228,19 +142,8 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.btnTapToPay.isEnabled = nfcAvailable
binding.btnTapToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val bmlItem = item as CardItem.Bml
NfcPaymentUtil.checkAndProceed(requireContext()) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(bmlItem)
} else {
setTapMode(true, bmlItem)
}
}
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
val wip = View.OnClickListener {
@@ -251,512 +154,6 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.btnBlock.setOnClickListener(wip)
}
private fun setManageMode(enabled: Boolean) {
isManageMode = enabled
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
if (enabled) enterManageMode() else exitManageMode()
}
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
// Bind card data
val cv = binding.manageCardView
when (item) {
is CardItem.Mib -> {
cv.tvCardOwner.text = item.card.cardHolderName
cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
val assetPath = cardImageAsset(item.card)
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
else cv.ivCardImage.setImageDrawable(null)
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
cv.root.alpha = 1f
}
is CardItem.Bml -> {
cv.tvCardOwner.text = item.account.accountBriefName
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
cv.root.alpha = if (isActive) 1f else 0.45f
}
}
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
val srcView = lm?.findViewByPosition(currentCardPosition)
val srcLoc = IntArray(2).also { srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] } }
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
// Apply layout changes
binding.btnManageCard.visibility = View.GONE
binding.topSpacer.visibility = View.GONE
binding.rvCards.visibility = View.GONE
binding.pageIndicator.visibility = View.GONE
binding.llPayButtons.visibility = View.GONE
binding.llManageButtons.visibility = View.VISIBLE
binding.llDefaultCardRow.visibility = View.VISIBLE
binding.llHideDashboardRow.visibility = View.VISIBLE
binding.manageCardView.root.visibility = View.VISIBLE
// Set switch state (clear listener first to avoid triggering on programmatic set)
val isBml = item is CardItem.Bml
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchDefaultCard.isChecked = isBml && store.getDefaultCardAccountNumber() == (item as? CardItem.Bml)?.account?.accountNumber
binding.switchDefaultCard.setOnCheckedChangeListener { _, isChecked ->
if (item is CardItem.Mib) {
// MIB doesn't support NFC/QR pay — same toast as scan/tap to pay
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchDefaultCard.isChecked = false
binding.switchDefaultCard.setOnCheckedChangeListener { _, c ->
handleDefaultCardToggle(c)
}
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
handleDefaultCardToggle(isChecked)
}
}
val accountNumber = (item as? CardItem.Bml)?.account?.accountNumber
?: (item as? CardItem.Mib)?.card?.maskedCardNumber
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
binding.switchHideFromDashboard.isChecked = accountNumber != null &&
store.getHiddenDashboardCardNumbers().contains(accountNumber)
binding.switchHideFromDashboard.setOnCheckedChangeListener { _, isChecked ->
if (accountNumber != null) {
store.setCardHiddenFromDashboard(accountNumber, isChecked)
}
}
// After layout pass, compute offsets, save carousel snapshot, and animate
binding.contentLayout.doOnNextLayout {
val mgr = binding.manageCardView.root
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
val scaleStart = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
val transXStart = srcCenterX - dstCenterX
val transYStart = srcScreenTop - dstTop
// Save the carousel card's position (relative to contentLayout) for the exit animation
carouselCardLayoutTop = srcScreenTop
carouselCardCenterX = srcCenterX
carouselTextLayoutTop = textSrcScreenTop
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textDstTop = (textDstLoc[1] - contentLoc[1]).toFloat()
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.scaleX = scaleStart
mgr.scaleY = scaleStart
mgr.translationX = transXStart
mgr.translationY = transYStart
mgr.animate()
.scaleX(1f).scaleY(1f)
.translationX(0f).translationY(0f)
.setDuration(380)
.setInterpolator(DecelerateInterpolator())
.start()
binding.tvSelectedCardType.translationY = textSrcScreenTop - textDstTop
binding.tvSelectedCardType.animate()
.translationY(0f)
.setDuration(380)
.setInterpolator(DecelerateInterpolator())
.start()
}
}
private fun handleDefaultCardToggle(isChecked: Boolean) {
val item = cards.getOrNull(currentCardPosition) as? CardItem.Bml ?: return
store.setDefaultCardAccountNumber(if (isChecked) item.account.accountNumber else null)
rebuildCards()
}
private fun exitManageMode() {
binding.manageCardView.root.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
val mgr = binding.manageCardView.root
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
// Compute layout top of manage card (strip current translationY which may be from a swipe drag)
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
// Target: animate card back to carousel position
val scaleEnd = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
val targetTransX = carouselCardCenterX - mgrLayoutCenterX
val targetTransY = carouselCardLayoutTop - mgrLayoutTop
val targetTextTransY = carouselTextLayoutTop - textLayoutTop
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.animate()
.scaleX(scaleEnd).scaleY(scaleEnd)
.translationX(targetTransX)
.translationY(targetTransY)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
mgr.scaleX = 1f; mgr.scaleY = 1f
mgr.translationX = 0f; mgr.translationY = 0f
mgr.visibility = View.GONE
binding.tvSelectedCardType.translationY = 0f
binding.btnManageCard.visibility = View.VISIBLE
binding.topSpacer.visibility = View.VISIBLE
binding.rvCards.visibility = View.VISIBLE
binding.llPayButtons.visibility = View.VISIBLE
binding.llManageButtons.visibility = View.GONE
binding.llDefaultCardRow.visibility = View.GONE
binding.llHideDashboardRow.visibility = View.GONE
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
buildDots(cards.size, currentCardPosition)
}
.start()
binding.tvSelectedCardType.animate()
.translationY(targetTextTransY)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
.start()
}
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
isTapMode = enabled
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
if (enabled) enterTapMode(item!!) else exitTapMode()
}
private fun showBiometricPromptForTap(item: CardItem.Bml) {
val bmgr = BiometricManager.from(requireContext())
if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
setTapMode(true, item)
return
}
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
setTapMode(true, item)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { }
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle(getString(R.string.card_pay_nfc))
.setSubtitle(item.account.accountBriefName)
.setNegativeButtonText(getString(R.string.cancel))
.build()
)
}
private fun enterTapMode(item: CardItem.Bml) {
// Bind card data to the shared manage card view
val cv = binding.manageCardView
cv.tvCardOwner.text = item.account.accountBriefName
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
cv.root.alpha = if (isActive) 1f else 0.45f
// Snapshot carousel card position before layout changes (for animation)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
val srcView = lm?.findViewByPosition(currentCardPosition)
val srcLoc = IntArray(2).also {
srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] }
}
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
carouselCardLayoutTop = srcScreenTop
carouselCardCenterX = srcCenterX
carouselTextLayoutTop = textSrcScreenTop
// Apply layout changes
binding.btnManageCard.visibility = View.GONE
binding.topSpacer.visibility = View.GONE
binding.rvCards.visibility = View.GONE
binding.pageIndicator.visibility = View.GONE
binding.divider.visibility = View.GONE
binding.llPayButtons.visibility = View.GONE
binding.llManageButtons.visibility = View.GONE
binding.llDefaultCardRow.visibility = View.GONE
binding.manageCardView.root.visibility = View.VISIBLE
binding.flTapMode.visibility = View.VISIBLE
// Build tap mode content: animation view + cancel button
binding.flTapMode.removeAllViews()
val animView = NfcTapAnimationView(requireContext())
tapAnimView = animView
val dp = resources.displayMetrics.density
val cancelBtn = (layoutInflater.inflate(R.layout.view_cancel_button, null, false) as MaterialButton).apply {
setOnClickListener { setTapMode(false) }
}
val colorOutlineVariant = MaterialColors.getColor(
requireContext(), com.google.android.material.R.attr.colorOutlineVariant, android.graphics.Color.LTGRAY
)
val tapDivider = View(requireContext()).apply {
setBackgroundColor(colorOutlineVariant)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, dp.toInt().coerceAtLeast(1)
).also {
it.marginStart = (24 * dp).toInt()
it.marginEnd = (24 * dp).toInt()
it.bottomMargin = (4 * dp).toInt()
}
}
val baseCancelPaddingBottom = (24 * dp).toInt()
val cancelWrapper = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom)
addView(cancelBtn, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
}
ViewCompat.setOnApplyWindowInsetsListener(cancelWrapper) { v, insets ->
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
v.setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom + navBarBottom)
insets
}
val container = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
addView(View(requireContext()).apply { // spacer pushes content below card
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
})
addView(animView.apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
})
addView(tapDivider)
addView(cancelWrapper.apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
})
}
binding.flTapMode.addView(container)
// Animate card up from carousel position (same as manage mode)
binding.contentLayout.doOnNextLayout {
val mgr = binding.manageCardView.root
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.scaleX = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
mgr.scaleY = mgr.scaleX
mgr.translationX = srcCenterX - dstCenterX
mgr.translationY = srcScreenTop - dstTop
mgr.animate()
.scaleX(1f).scaleY(1f)
.translationX(0f).translationY(0f)
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
binding.tvSelectedCardType.translationY = textSrcScreenTop - (textDstLoc[1] - contentLoc[1]).toFloat()
binding.tvSelectedCardType.animate()
.translationY(0f)
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
}
fetchAndArmToken(item)
}
private fun exitTapMode() {
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
binding.manageCardView.root.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
val mgr = binding.manageCardView.root
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.animate()
.scaleX(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
.scaleY(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
.translationX(carouselCardCenterX - mgrLayoutCenterX)
.translationY(carouselCardLayoutTop - mgrLayoutTop)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
mgr.scaleX = 1f; mgr.scaleY = 1f
mgr.translationX = 0f; mgr.translationY = 0f
mgr.visibility = View.GONE
binding.tvSelectedCardType.translationY = 0f
binding.flTapMode.visibility = View.GONE
binding.flTapMode.removeAllViews()
binding.btnManageCard.visibility = View.VISIBLE
binding.topSpacer.visibility = View.VISIBLE
binding.rvCards.visibility = View.VISIBLE
binding.divider.visibility = View.VISIBLE
binding.llPayButtons.visibility = View.VISIBLE
buildDots(cards.size, currentCardPosition)
}
.start()
binding.tvSelectedCardType.animate()
.translationY(carouselTextLayoutTop - textLayoutTop)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
.start()
}
private fun fetchAndArmToken(item: CardItem.Bml) {
val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch {
val loginId = item.account.loginTag.removePrefix("bml_")
val session = app.bmlSessionFor(item.account)
val otpSeed = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
if (session == null || otpSeed == null) {
if (isTapMode) {
Toast.makeText(requireContext(),
if (session == null) getString(R.string.transfer_session_unavailable)
else "OTP unavailable",
Toast.LENGTH_SHORT).show()
setTapMode(false)
}
return@launch
}
(activity as? HomeActivity)?.setRefreshing(true)
val otp = Totp.generate(otpSeed)
val result = withContext(Dispatchers.IO) {
runCatching { BmlTapToPayClient().fetchTokens(session, item.account.internalId, otp) }
}
(activity as? HomeActivity)?.setRefreshing(false)
val token = result.getOrNull()?.firstOrNull()
if (!isTapMode) return@launch // user cancelled while we were fetching
if (token == null) {
if (isTapMode) {
Toast.makeText(requireContext(),
result.exceptionOrNull()?.message ?: "Failed to get payment token",
Toast.LENGTH_SHORT).show()
setTapMode(false)
}
return@launch
}
BmlHostCardEmulatorService.setToken(token)
BmlHostCardEmulatorService.onTransactionComplete = { success ->
view?.post {
if (!isTapMode) return@post
setTapMode(false)
if (success) {
Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.triggerRefresh()
}
}
}
}
}
private fun rebuildCards() {
// Remember which card is currently selected by identity so we can restore position after reorder
val currentCard = cards.getOrNull(currentCardPosition)
val defaultNum = store.getDefaultCardAccountNumber()
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
val all: List<CardItem> = mibItems + bmlItems
// Move default BML card to front
cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
if (def != null) listOf(def) + all.filter { it !== def } else all
} else all
// Restore position to follow the same card after reorder
if (currentCard != null) {
val newPos = cards.indexOf(currentCard)
if (newPos >= 0 && newPos != currentCardPosition) {
currentCardPosition = newPos
binding.rvCards.scrollToPosition(newPos)
}
}
stackAdapter.update(cards)
binding.loadingView.visibility = View.GONE
val empty = cards.isEmpty()
binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
if (!empty) {
buildDots(cards.size, currentCardPosition)
updateCardInfo(currentCardPosition)
}
// Auto-enter tap mode when launched from shortcut, NFC prompt, or dashboard
if (!autoTapModeTriggered && arguments?.getBoolean(ARG_AUTO_TAP_MODE) == true) {
val targetAccount = arguments?.getString(ARG_AUTO_TAP_ACCOUNT)
val targetCard = if (targetAccount != null)
cards.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == targetAccount }
else
cards.filterIsInstance<CardItem.Bml>().firstOrNull()
if (targetCard != null) {
autoTapModeTriggered = true
// Scroll to the target card first
val pos = cards.indexOf(targetCard)
if (pos >= 0) {
currentCardPosition = pos
binding.rvCards.scrollToPosition(pos)
}
NfcPaymentUtil.checkAndProceed(requireContext()) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(targetCard)
} else {
setTapMode(true, targetCard)
}
}
}
}
}
private fun applyCardScales() {
val rv = binding.rvCards
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
@@ -776,7 +173,6 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
private fun buildDots(count: Int, selected: Int) {
if (isManageMode || isTapMode) return
binding.pageIndicator.removeAllViews()
if (count <= 1) {
binding.pageIndicator.visibility = View.GONE
@@ -811,36 +207,12 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
}
fun onBackPressed(): Boolean {
if (isTapMode) {
setTapMode(false)
return true
}
if (isManageMode) {
setManageMode(false)
return true
}
return false
}
override fun onPause() {
super.onPause()
if (isTapMode) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
}
override fun onDestroyView() {
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
super.onDestroyView()
_binding = null
}
@@ -902,103 +274,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
}
// ── NFC animation view ─────────────────────────────────────────────────────
private inner class NfcTapAnimationView(context: Context) : View(context) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1600
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
addUpdateListener { invalidate() }
start()
}
fun stopAnimation() = animator.cancel()
override fun onDraw(canvas: Canvas) {
val w = width.toFloat(); val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val dp = resources.displayMetrics.density
val progress = animator.animatedFraction
val cx = w / 2f; val cy = h / 2f + 24 * dp
val colorOnSurface = MaterialColors.getColor(this,
com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
val colorPrimary = MaterialColors.getColor(this,
com.google.android.material.R.attr.colorPrimary, android.graphics.Color.BLUE)
val colorSurfaceVariant = MaterialColors.getColor(this,
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
// POS terminal (top center)
val posW = 44 * dp; val posH = 72 * dp
val posX = cx - posW / 2f; val posY = cy - 170 * dp
// Phone (bottom center)
val phoneW = 52 * dp; val phoneH = 90 * dp
val phoneX = cx - phoneW / 2f; val phoneY = cy + 30 * dp
// POS terminal body
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 7 * dp, 7 * dp, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp; paint.color = colorOnSurface
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 7 * dp, 7 * dp, paint)
// POS screen
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
canvas.drawRoundRect(posX + 4 * dp, posY + 6 * dp,
posX + posW - 4 * dp, posY + posH * 0.45f, 4 * dp, 4 * dp, paint)
paint.alpha = 255
// POS card slot
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
canvas.drawLine(posX + 6 * dp, posY + posH * 0.72f, posX + posW - 6 * dp, posY + posH * 0.72f, paint)
// Phone body
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 8 * dp, 8 * dp, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp; paint.color = colorOnSurface
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 8 * dp, 8 * dp, paint)
// Phone screen
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
canvas.drawRoundRect(phoneX + 4 * dp, phoneY + 10 * dp,
phoneX + phoneW - 4 * dp, phoneY + phoneH - 15 * dp, 4 * dp, 4 * dp, paint)
paint.alpha = 255
// Animated NFC rings originating from phone top, travelling upward toward POS
val gapTop = posY + posH + 4 * dp
val originY = phoneY
val maxR = (originY - gapTop) - 4 * dp
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * dp
for (i in 0..2) {
val p = ((progress + i / 3f) % 1f)
val r = (p * maxR + 6 * dp).coerceAtMost(maxR)
paint.color = colorPrimary; paint.alpha = ((1f - p) * 200).toInt().coerceIn(0, 255)
canvas.drawArc(RectF(cx - r, originY - r, cx + r, originY + r), -160f, 140f, false, paint)
}
paint.alpha = 255
// Label
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 160
paint.textSize = 15 * dp; paint.textAlign = Paint.Align.CENTER
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, phoneY + phoneH + 28 * dp, paint)
paint.alpha = 255; paint.textAlign = Paint.Align.LEFT
}
}
companion object {
private const val ARG_AUTO_TAP_MODE = "auto_tap_mode"
private const val ARG_AUTO_TAP_ACCOUNT = "auto_tap_account"
fun newInstanceWithAutoTapMode(accountNumber: String? = null) = CardsFragment().apply {
arguments = Bundle().apply {
putBoolean(ARG_AUTO_TAP_MODE, true)
if (accountNumber != null) putString(ARG_AUTO_TAP_ACCOUNT, accountNumber)
}
}
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"51" -> "cards/mib/faisa_card.png"
"53" -> "cards/mib/visa_black_platinum.png"
@@ -1029,12 +305,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE
tv.text = statusLabel
val dp = tv.context.resources.displayMetrics.density
tv.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 12 * dp
setColor(0xCC212121.toInt())
}
tv.setBackgroundResource(R.drawable.card_overlay_pill_bg)
}
fun formatMasked(masked: String): String {
@@ -106,8 +106,6 @@ class QrScannerActivity : AppCompatActivity() {
}
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = android.graphics.Color.TRANSPARENT
window.navigationBarColor = android.graphics.Color.TRANSPARENT
binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root)
// Black camera background — always use light (white) system bar icons
@@ -2,10 +2,7 @@ package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -13,22 +10,16 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import sh.sar.basedbank.util.ThemeHelper
import java.util.Collections
class SettingsAppearanceFragment : Fragment() {
@@ -39,10 +30,8 @@ class SettingsAppearanceFragment : Fragment() {
private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>()
private val circularSlots = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: NavItemAdapter
private lateinit var circularSlotAdapter: NavItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
@@ -51,30 +40,13 @@ class SettingsAppearanceFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = prefs.getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
// Navigation mode
val currentMode = NavCustomization.getNavMode(prefs)
binding.navModeToggle.check(when (currentMode) {
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
else -> R.id.btnNavDrawer
})
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val mode = when (checkedId) {
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
else -> NavCustomization.NAV_MODE_DRAWER
}
NavCustomization.saveNavMode(prefs, mode)
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
(activity as? HomeActivity)?.applyNavMode()
updateShortcutsVisibility()
}
@@ -85,22 +57,10 @@ class SettingsAppearanceFragment : Fragment() {
quickActionAdapter = NavItemAdapter(
items = quickActions,
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
)
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM
}
// Circular nav shortcuts
circularSlots.clear()
circularSlots.addAll(NavCustomization.getCircularSlots(prefs))
circularSlotAdapter = NavItemAdapter(
items = circularSlots,
onSave = { NavCustomization.saveCircularSlots(prefs, circularSlots) },
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR }
)
setupNavItemRecyclerView(binding.rvCircularSlots, circularSlotAdapter, circularSlots) {
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR
!prefs.getBoolean("bottom_nav", false)
}
// Bottom bar shortcuts
@@ -112,10 +72,10 @@ class SettingsAppearanceFragment : Fragment() {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
},
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
isEnabled = { prefs.getBoolean("bottom_nav", false) }
)
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
prefs.getBoolean("bottom_nav", false)
}
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
@@ -136,7 +96,6 @@ class SettingsAppearanceFragment : Fragment() {
})
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val previousKey = prefs.getString("theme", "system")
val (key, mode) = when (checkedId) {
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
@@ -144,55 +103,8 @@ class SettingsAppearanceFragment : Fragment() {
}
prefs.edit().putString("theme", key).apply()
AppCompatDelegate.setDefaultNightMode(mode)
updateAccentState(key == "system")
updatePitchBlackState(key == "dark")
if (key == "system") {
requireActivity().recreate()
} else if (previousKey == "system") {
// setDefaultNightMode only recreates if the effective mode changes.
// If system was already dark and we switch to dark (or light→light),
// no recreation is triggered and the custom accent never gets applied.
val currentIsNight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val newIsNight = mode == AppCompatDelegate.MODE_NIGHT_YES
if (currentIsNight == newIsNight) requireActivity().recreate()
}
}
// Pitch black
binding.switchPitchBlack.isChecked = prefs.getBoolean("pitch_black", false)
binding.switchPitchBlack.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("pitch_black", checked).apply()
requireActivity().recreate()
}
val isDark = prefs.getString("theme", "system") == "dark"
updatePitchBlackState(isDark)
// Accent color
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (savedPreset) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
binding.accentToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val preset = when (checkedId) {
R.id.btnAccentOrange -> ThemeHelper.PRESET_RED
R.id.btnAccentGreen -> ThemeHelper.PRESET_GREEN
R.id.btnAccentCustom -> ThemeHelper.PRESET_CUSTOM
else -> ThemeHelper.PRESET_BLUE
}
if (preset == ThemeHelper.PRESET_CUSTOM) {
showCustomColorPicker()
} else {
prefs.edit().putString("accent_preset", preset).apply()
requireActivity().recreate()
}
}
val isSystem = prefs.getString("theme", "system") == "system"
updateAccentState(isSystem)
// Language
val currentLocales = AppCompatDelegate.getApplicationLocales()
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
@@ -236,85 +148,18 @@ class SettingsAppearanceFragment : Fragment() {
}
private fun updateShortcutsVisibility() {
val mode = NavCustomization.getNavMode(prefs)
val isBottom = mode == NavCustomization.NAV_MODE_BOTTOM
val isCircular = mode == NavCustomization.NAV_MODE_CIRCULAR
binding.sectionQuickActions.alpha = if (!isBottom) 1f else 0.38f
binding.sectionCircularSlots.alpha = if (isCircular) 1f else 0.38f
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
binding.switchShowLabels.isClickable = isBottom
quickActionAdapter.notifyDataSetChanged()
circularSlotAdapter.notifyDataSetChanged()
slotAdapter.notifyDataSetChanged()
}
private fun updatePitchBlackState(isDark: Boolean) {
binding.rowPitchBlack.alpha = if (isDark) 1f else 0.38f
binding.switchPitchBlack.isEnabled = isDark
}
private fun updateAccentState(isSystem: Boolean) {
binding.sectionAccentColor.alpha = if (isSystem) 0.38f else 1f
for (i in 0 until binding.accentToggle.childCount) {
binding.accentToggle.getChildAt(i)?.isEnabled = !isSystem
}
}
private fun showCustomColorPicker() {
val ctx = requireContext()
val currentHex = prefs.getString("accent_custom_color", "") ?: ""
val inputLayout = TextInputLayout(ctx).apply {
hint = getString(R.string.accent_custom_hint)
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
}
val input = TextInputEditText(ctx).apply {
setText(currentHex)
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
setSingleLine(true)
}
inputLayout.addView(input)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.accent_custom_pick)
.setView(inputLayout)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(R.string.cancel) { _, _ -> revertAccentToggle() }
.setOnCancelListener { revertAccentToggle() }
.show()
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val raw = input.text.toString().trim()
val hex = if (raw.startsWith("#")) raw else "#$raw"
try {
Color.parseColor(hex)
prefs.edit()
.putString("accent_preset", ThemeHelper.PRESET_CUSTOM)
.putString("accent_custom_color", hex)
.apply()
dialog.dismiss()
requireActivity().recreate()
} catch (_: Exception) {
Toast.makeText(ctx, R.string.accent_invalid_color, Toast.LENGTH_SHORT).show()
}
}
}
private fun revertAccentToggle() {
val saved = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (saved) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
val mode = NavCustomization.getNavMode(prefs)
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
val isBottom = prefs.getBoolean("bottom_nav", false)
if (items === slots && !isBottom) return
if (items === quickActions && isBottom) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
@@ -323,7 +168,6 @@ class SettingsAppearanceFragment : Fragment() {
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.findViewById<TextView>(R.id.tvDescription).visibility = View.GONE
listLayout.addView(row)
}
}
@@ -9,8 +9,6 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
@@ -34,14 +32,6 @@ class SettingsFragment : Fragment() {
inflater.inflate(R.layout.fragment_settings, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(view as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
val list = view.findViewById<LinearLayout>(R.id.settingsList)
val inflater = LayoutInflater.from(requireContext())
for (item in items) {
@@ -1,10 +1,7 @@
package sh.sar.basedbank.ui.home
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.Settings as AndroidSettings
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
@@ -18,10 +15,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -83,29 +77,6 @@ class SettingsLoginsFragment : Fragment() {
handlePickedImage(bitmap)
}
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) cameraLauncher.launch(cameraPhotoUri ?: return@registerForActivityResult)
else showCameraPermissionRationale()
}
private fun showCameraPermissionRationale() {
val ctx = requireContext()
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.qr_camera_permission_title)
.setMessage(R.string.camera_permission_profile_message)
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
.setPositiveButton(R.string.go_to_settings) { _, _ ->
startActivity(
Intent(AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", ctx.packageName, null)
}
)
}
.show()
}
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
return try {
val ctx = requireContext()
@@ -188,11 +159,7 @@ class SettingsLoginsFragment : Fragment() {
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
cameraPhotoUri = uri
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(uri)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
cameraLauncher.launch(uri)
}
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
@@ -335,14 +302,6 @@ class SettingsLoginsFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
binding.btnAddAccount.setOnClickListener {
startActivity(Intent(requireContext(), LoginActivity::class.java))
}
@@ -6,8 +6,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.biometric.BiometricManager
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
@@ -24,14 +22,6 @@ class SettingsSecurityFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Change lock
@@ -96,6 +86,17 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer()
}
// Hide sensitive information (enables/disables the eye icon in toolbar)
val viewModel = (requireActivity() as HomeActivity).let {
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
}
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
if (!isChecked) viewModel.hideAmounts.value = false
requireActivity().invalidateOptionsMenu()
}
// Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots
@@ -6,8 +6,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R
@@ -33,14 +31,6 @@ class SettingsStorageFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
binding.btnClearCache.setOnClickListener {
val ctx = requireContext()
clearAllCaches(ctx)
@@ -92,18 +92,9 @@ class TransferFragment : Fragment() {
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
private var selectedFahipayService: String? = null
// Form state preserved across view destroy/create when the fragment instance is cached
private var savedAmount = ""
private var savedRemarks = ""
private var savedToText = ""
private var savedToSubtitle = ""
private var savedToColorHex = "#607D8B"
private var savedToImageHash: String? = null
// BML QR merchant payment mode (set when navigated from a card QR scan)
private var bmlQrInfo: BmlQrPayInfo? = null
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
private var bmlQrLookupAttempted = false // prevents re-lookup after user clears the merchant
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
// BML business profile OTP flow state
@@ -134,12 +125,9 @@ class TransferFragment : Fragment() {
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// BML card/gateway QR — hand off to dedicated payment screen
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
}
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber))
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
return@registerForActivityResult
}
@@ -148,28 +136,6 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
// Cards can't pay PayMV QR — fall back to default account or clear selection
val isCard = selectedAccount?.let {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
} ?: false
if (isCard) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val defaultAcc = defaultNum?.let { num -> viewModel.accounts.value?.firstOrNull { it.accountNumber == num } }
selectedAccount = defaultAcc
binding.tilAmount.prefixText = null
if (defaultAcc != null) {
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
} else {
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
}
updateTransferButton()
}
if (qr.amount != null) binding.etAmount.setText(qr.amount)
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
prefillToFromContact(qr.accountNumber, "")
@@ -222,15 +188,13 @@ class TransferFragment : Fragment() {
accountNumber: String,
displayName: String,
amount: String?,
remarks: String?,
fromAccountNumber: String? = null
remarks: String?
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, accountNumber)
putString(ARG_COLOR, "#607D8B")
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
}
@@ -254,27 +218,11 @@ class TransferFragment : Fragment() {
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
if (accountNumber.startsWith("bmlqr:")) {
lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:"))
return@setFragmentResultListener
}
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
}
binding.btnPickContact.setOnClickListener {
@@ -314,37 +262,9 @@ class TransferFragment : Fragment() {
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
// Restore form state when view is recreated on the cached no-args instance
if (arguments == null) {
if (resolvedAccountNumber.isNotEmpty()) {
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
if (ownAccount != null) {
showToCard(ownAccount)
} else {
binding.tvToAccountName.text = resolvedRecipientName
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(resolvedRecipientName, savedToColorHex))
}
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
} else if (savedToText.isNotEmpty()) {
binding.etTo.setText(savedToText)
}
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
updateTransferButton()
}
}
private fun lookupBmlQrMerchant(qrUrl: String) {
bmlQrLookupAttempted = true
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
@@ -369,35 +289,6 @@ class TransferFragment : Fragment() {
return@launch
}
bmlQrInfo = info
if (info.amount == 0.0) {
RecentsCache.save(requireContext(), 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 (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber()
if (defaultNum != null) {
val allAccounts = viewModel.accounts.value ?: emptyList()
val defaultCard = allAccounts.firstOrNull {
it.accountNumber == defaultNum &&
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") &&
it.statusDesc.equals("Active", ignoreCase = true)
}
if (defaultCard != null) {
selectedAccount = defaultCard
updateAmountPrefix(defaultCard)
showFromCard(defaultCard)
updateTransferButton()
}
}
}
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName
@@ -406,6 +297,7 @@ class TransferFragment : Fragment() {
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
binding.btnClearToInfo.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill amount if dynamic QR
@@ -461,14 +353,6 @@ class TransferFragment : Fragment() {
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
if (bmlQrInfo != null) {
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
if (!isCard) {
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
binding.actvFrom.setText("", false)
return@setOnItemClickListener
}
}
selectedAccount = picked
updateAmountPrefix(picked)
showFromCard(picked)
@@ -485,35 +369,6 @@ class TransferFragment : Fragment() {
updateTransferButton()
}
}
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
// On a cold start (e.g. share intent), anyBmlSession() may be null when
// onViewCreated runs. Retry the lookup once sessions are available.
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
if (pendingBmlQrUrl != null && !bmlQrLookupAttempted) {
val app = requireActivity().application as BasedBankApp
if (app.anyBmlSession() != null) lookupBmlQrMerchant(pendingBmlQrUrl)
}
// Re-render the from card when the view is recreated on a cached instance
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
updateAmountPrefix(selectedAccount!!)
showFromCard(selectedAccount!!)
updateTransferButton()
}
}
}
@@ -651,14 +506,6 @@ class TransferFragment : Fragment() {
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
binding.btnClearToInfo.setOnClickListener {
if (bmlQrInfo != null) {
bmlQrInfo = null
bmlGatewayQr = false
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedToOwnAccount = null
@@ -689,21 +536,8 @@ class TransferFragment : Fragment() {
private fun lookupAccount() {
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val allAccounts = viewModel.accounts.value ?: emptyList()
val defaultAcc = allAccounts.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
if (selectedAccount == null) {
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
return
}
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
return
}
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
if (accountNumber.isBlank()) {
@@ -799,13 +633,6 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) {
showToCard(matchedAcc)
@@ -938,9 +765,6 @@ class TransferFragment : Fragment() {
) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
savedToSubtitle = subtitle
savedToColorHex = colorHex
savedToImageHash = imageHash
val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
@@ -996,20 +820,13 @@ class TransferFragment : Fragment() {
message: String? = null,
customView: android.view.View? = null,
biometricSubtitle: String,
onConfirmed: (AlertDialog, android.widget.FrameLayout) -> Unit
onConfirmed: () -> Unit
) {
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(requireView().windowToken, 0)
val frame = android.widget.FrameLayout(requireContext())
if (customView != null) frame.addView(customView)
val builder = MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
.setPositiveButton(R.string.transfer_confirm, null)
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setCancelable(false)
if (customView != null) builder.setView(frame) else builder.setMessage(message)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() }
.setNegativeButton(android.R.string.cancel, null)
if (customView != null) builder.setView(customView) else builder.setMessage(message)
val dialog = builder.show()
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
@@ -1020,7 +837,8 @@ class TransferFragment : Fragment() {
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onConfirmed(dialog, frame)
dialog.dismiss()
onConfirmed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
@@ -1039,10 +857,6 @@ class TransferFragment : Fragment() {
.build()
)
}
} else {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
onConfirmed(dialog, frame)
}
}
}
@@ -1061,27 +875,11 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return
}
val qrFromTypeLabel = AccountListParser.from(src)?.typeLabel
?: BmlDashboardParser.productLabel(src.accountTypeName)
val qrFromDetail = listOfNotNull("BML", qrFromTypeLabel.ifBlank { null }).joinToString(" · ")
val qrConfirmView = buildTransferConfirmView(
amountCurrency = info.currency,
amountValue = "%.2f".format(amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = qrFromDetail,
toName = info.merchantName,
toNumber = "",
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
)
showConfirmWithBiometric(
title = getString(R.string.transfer),
customView = qrConfirmView,
message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}",
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}",
onConfirmed = { dialog, frame ->
showProcessingInDialog(dialog, frame)
executeBmlQrPayment(src, debitAccount, info, amount, dialog, frame)
}
onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) }
)
return
}
@@ -1136,17 +934,18 @@ class TransferFragment : Fragment() {
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
val isSrcCredit = src.profileType == "BML_CREDIT"
val doTransfer: (AlertDialog, android.widget.FrameLayout) -> Unit = { dialog, frame ->
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
val doTransfer: () -> Unit = {
if (isBmlBusiness) {
// Business profile: async OTP channel selection flow — dismiss dialog first
dialog.dismiss()
// Business profile: async OTP channel selection flow
startBmlBusinessOtpFlow(
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
)
} else {
showProcessingInDialog(dialog, frame)
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) {
@@ -1156,15 +955,14 @@ class TransferFragment : Fragment() {
}
}
binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt)
clearForm()
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
dialog.dismiss()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) {
dialog.dismiss()
if (msg == "CONNECTIVITY") {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
} else {
@@ -1175,202 +973,56 @@ class TransferFragment : Fragment() {
}
}
val fromTypeLabel = AccountListParser.from(src)?.typeLabel
?: if (src.bank == "BML") BmlDashboardParser.productLabel(src.accountTypeName)
else src.accountTypeName.ifBlank { src.profileType }
val fromBankLabel = when (src.bank) {
"BML" -> "BML"
"FAHIPAY" -> "Fahipay"
"MIB" -> "MIB"
else -> src.bank
}
val fromDetail = listOfNotNull(fromBankLabel.ifBlank { null }, fromTypeLabel.ifBlank { null }).joinToString(" · ")
val toTypeLabel = resolvedToOwnAccount?.let { acc ->
AccountListParser.from(acc)?.typeLabel
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
else acc.accountTypeName.ifBlank { acc.profileType }
}
val toBankLabel = resolvedToOwnAccount?.let { acc ->
when (acc.bank) {
"BML" -> "BML"
"FAHIPAY" -> "Fahipay"
"MIB" -> "MIB"
else -> acc.bank
}
} ?: when {
bankNameCapture.equals("MALBMVMV", ignoreCase = true) -> "BML"
bankNameCapture.equals("MADVMVMV", ignoreCase = true) -> "MIB"
bankNameCapture.isNotBlank() -> bankNameCapture
isDestMib -> "MIB"
else -> when (selectedFahipayService) {
"RAASTAS" -> "Ooredoo · Raastas"
"OOREDOO_BILL" -> "Ooredoo · Bill Pay"
"DHIRAAGU_RELOAD" -> "Dhiraagu · Reload"
"DHIRAAGU_BILL" -> "Dhiraagu · Bill Pay"
else -> ""
}
}
val toDetail = listOfNotNull(toBankLabel.ifBlank { null }, toTypeLabel?.ifBlank { null }).joinToString(" · ")
val warnings = buildList {
if (isUsdToMvr) add("⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!")
if (isSrcCredit) add("⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month.")
}
val confirmView = buildTransferConfirmView(
amountCurrency = currency,
amountValue = "%.2f".format(amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = fromDetail,
toName = destDisplay,
toNumber = resolvedAccountNumber,
toDetail = toDetail,
warningTexts = warnings
)
showConfirmWithBiometric(
title = getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "$currency ${"%.2f".format(amount)}$destDisplay",
onConfirmed = { dialog, frame -> doTransfer(dialog, frame) }
)
}
private fun buildTransferConfirmView(
amountCurrency: String,
amountValue: String,
fromName: String,
fromNumber: String,
fromDetail: String,
toName: String,
toNumber: String,
toDetail: String,
warningTexts: List<String> = emptyList()
): android.view.View {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val colorOnSurface = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
val colorMuted = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
val colorPrimary = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE)
val MATCH = LinearLayout.LayoutParams.MATCH_PARENT
val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT
fun lp(w: Int = MATCH, h: Int = WRAP, init: LinearLayout.LayoutParams.() -> Unit = {}) =
LinearLayout.LayoutParams(w, h).apply(init)
fun accountBlock(label: String, name: String, number: String, detail: String) =
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = lp()
addView(TextView(ctx).apply {
text = label
textSize = 10f
isAllCaps = true
letterSpacing = 0.12f
setTextColor(colorMuted)
gravity = Gravity.CENTER
})
addView(TextView(ctx).apply {
text = name
textSize = 16f
setTypeface(null, Typeface.BOLD)
setTextColor(colorOnSurface)
gravity = Gravity.CENTER
layoutParams = lp { topMargin = (2 * dp).toInt() }
})
if (number.isNotBlank()) addView(TextView(ctx).apply {
text = number
textSize = 13f
setTextColor(colorMuted)
gravity = Gravity.CENTER
})
if (detail.isNotBlank()) addView(TextView(ctx).apply {
text = detail
textSize = 12f
setTextColor(colorMuted)
gravity = Gravity.CENTER
alpha = 0.75f
})
}
return LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((20 * dp).toInt(), (8 * dp).toInt(), (20 * dp).toInt(), (8 * dp).toInt())
// Currency + amount on same line, centered, baseline-aligned
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = lp { bottomMargin = (20 * dp).toInt() }
addView(TextView(ctx).apply {
text = "$amountCurrency "
textSize = 16f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = amountValue
textSize = 34f
setTypeface(null, Typeface.BOLD)
setTextColor(colorPrimary)
})
})
addView(accountBlock("From", fromName, fromNumber, fromDetail))
// Down arrow — centered
addView(ImageView(ctx).apply {
setImageResource(R.drawable.ic_arrow_right)
rotation = 90f
setColorFilter(colorMuted)
layoutParams = lp(WRAP, WRAP) {
gravity = Gravity.CENTER_HORIZONTAL
width = (24 * dp).toInt()
height = (24 * dp).toInt()
topMargin = (12 * dp).toInt()
bottomMargin = (12 * dp).toInt()
}
})
addView(accountBlock("To", toName, toNumber, toDetail))
for (warning in warningTexts) {
addView(TextView(ctx).apply {
text = warning
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
addView(TextView(ctx).apply { text = mainMsg })
if (isUsdToMvr) addView(TextView(ctx).apply {
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
setTextColor(Color.RED)
textSize = 14f
setTypeface(null, Typeface.BOLD)
layoutParams = lp { topMargin = (16 * dp).toInt() }
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, (16 * dp).toInt(), 0, 0)
})
if (isSrcCredit) addView(TextView(ctx).apply {
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
setTextColor(Color.RED)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, (16 * dp).toInt(), 0, 0)
})
}
}
} else null
showConfirmWithBiometric(
title = getString(R.string.transfer),
message = if (warningView == null) mainMsg else null,
customView = warningView,
biometricSubtitle = "$currency $amountStr$destDisplay",
onConfirmed = { doTransfer() }
)
}
private fun executeBmlQrPayment(
src: BankAccount,
debitAccount: String,
info: BmlQrPayInfo,
amount: Double,
dialog: AlertDialog,
frame: android.widget.FrameLayout
amount: Double
) {
val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("bml_")
val session = bmlSessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: run { dialog.dismiss(); Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
?: run { Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
@@ -1391,174 +1043,75 @@ class TransferFragment : Fragment() {
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
}
}
(activity as? HomeActivity)?.setRefreshing(false)
if (_binding == null) return@launch
if (result == null) {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch
}
if (result.success) {
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 }
) {
clearForm()
(activity as? HomeActivity)?.triggerRefresh()
}
showBmlQrSuccessDialog(
merchant = result.merchant.ifBlank { info.merchantName },
amount = result.amount.ifBlank { "%.2f".format(amount) },
currency = result.currency.ifBlank { info.currency }
)
} else {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
}
}
}
private fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
dialog.setCancelable(false)
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val spinner = CircularProgressDrawable(ctx).apply {
setStyle(CircularProgressDrawable.LARGE)
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
start()
val container = android.widget.LinearLayout(ctx).apply {
orientation = android.widget.LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
}
frame.removeAllViews()
frame.addView(LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt())
addView(ImageView(ctx).apply {
setImageDrawable(spinner)
layoutParams = LinearLayout.LayoutParams((48 * dp).toInt(), (48 * dp).toInt()).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = (12 * dp).toInt()
}
})
addView(TextView(ctx).apply {
text = "Processing..."
textSize = 16f
gravity = Gravity.CENTER
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK))
})
container.addView(android.widget.ImageView(ctx).apply {
setImageResource(R.drawable.ic_check_circle)
setColorFilter(android.graphics.Color.parseColor("#4CAF50"))
layoutParams = android.widget.LinearLayout.LayoutParams(
(64 * dp).toInt(), (64 * dp).toInt()
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
})
}
private fun showSuccessInDialog(
dialog: AlertDialog,
frame: android.widget.FrameLayout,
amountCurrency: String,
amountValue: String,
fromName: String,
toName: String,
onDone: () -> Unit
) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val colorOnSurface = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
val colorMuted = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
val colorPrimary = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE)
val MATCH = LinearLayout.LayoutParams.MATCH_PARENT
val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT
frame.removeAllViews()
frame.addView(LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (20 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
// Checkmark
addView(ImageView(ctx).apply {
setImageResource(R.drawable.ic_check_circle)
setColorFilter(Color.parseColor("#4CAF50"))
layoutParams = LinearLayout.LayoutParams((64 * dp).toInt(), (64 * dp).toInt()).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = (16 * dp).toInt()
}
})
// Currency + amount
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply {
bottomMargin = (16 * dp).toInt()
}
addView(TextView(ctx).apply {
text = "$amountCurrency "
textSize = 16f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = amountValue
textSize = 28f
setTypeface(null, Typeface.BOLD)
setTextColor(colorPrimary)
})
})
// From row
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP)
addView(TextView(ctx).apply {
text = "From "
textSize = 12f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = fromName
textSize = 13f
setTypeface(null, Typeface.BOLD)
setTextColor(colorOnSurface)
gravity = Gravity.CENTER
})
})
// To row
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply {
topMargin = (4 * dp).toInt()
}
addView(TextView(ctx).apply {
text = "To "
textSize = 12f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = toName
textSize = 13f
setTypeface(null, Typeface.BOLD)
setTextColor(colorOnSurface)
gravity = Gravity.CENTER
})
})
container.addView(android.widget.TextView(ctx).apply {
text = "$currency $amount"
textSize = 28f
setTypeface(null, android.graphics.Typeface.BOLD)
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK))
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
})
val okBtn = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
okBtn?.visibility = View.VISIBLE
okBtn?.text = "OK"
okBtn?.setOnClickListener { dialog.dismiss(); onDone() }
container.addView(android.widget.TextView(ctx).apply {
text = merchant
textSize = 14f
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY))
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL }
})
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.bml_qr_payment_success)
.setView(container)
.setPositiveButton(android.R.string.ok) { _, _ ->
requireActivity().onBackPressedDispatcher.onBackPressed()
}
.setCancelable(false)
.show()
}
private fun doMibTransfer(
src: BankAccount,
destAccount: String,
@@ -2128,17 +1681,8 @@ class TransferFragment : Fragment() {
requireActivity().title = getString(R.string.transfer)
}
override fun onDestroyView() {
super.onDestroyView()
// Persist form state so it can be restored when the view is recreated
savedAmount = binding.etAmount.text?.toString() ?: ""
savedRemarks = binding.etRemarks.text?.toString() ?: ""
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// Reset in-progress OTP flow — it cannot sensibly resume after the view is gone
bmlOtpState = BmlOtpState.NONE
pendingBmlTransfer = null
bmlOtpChannel = null
_binding = null
}
@@ -2193,7 +1737,6 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it }
}
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isCard = acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT"
val isBmlAccount = acc.bank == "BML"
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
val hide = viewModel.hideAmounts.value ?: false
@@ -2211,11 +1754,7 @@ class TransferFragment : Fragment() {
}
val balance = displayData?.balance ?: ""
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = when {
inactive -> 0.4f
bmlQrInfo != null && !isCard -> 0.35f
else -> 1f
}
b.root.alpha = if (inactive) 0.4f else 1f
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
when {
networkIcon != null -> {
@@ -181,6 +181,9 @@ class TransferHistoryFragment : Fragment() {
if (firstBatchDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true
} else if (allTransactions.isEmpty()) {
binding.skeletonContainer.visibility = View.VISIBLE
binding.emptyView.visibility = View.GONE
}
val app = requireActivity().application as BasedBankApp
@@ -286,6 +289,7 @@ class TransferHistoryFragment : Fragment() {
isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
val raw = bannerMsg.get()
when {
@@ -329,6 +333,8 @@ class TransferHistoryFragment : Fragment() {
}
adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
binding.skeletonContainer.visibility =
if (allTransactions.isEmpty() && isLoading) View.VISIBLE else View.GONE
}
private fun loadContactImage(name: String) {
@@ -26,8 +26,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
@@ -113,32 +111,6 @@ class TransferReceiptFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
receiptCard.setOnClickListener { showFullScreenReceipt() }
val btnRow = view.findViewById<View>(R.id.btnRow)
val basePaddingBottom = btnRow.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(btnRow) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
val receiptContainer = view.findViewById<android.widget.ScrollView>(R.id.receiptContainer)
receiptContainer.setOnTouchListener { _, _ -> true }
receiptContainer.viewTreeObserver.addOnGlobalLayoutListener(object : android.view.ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
receiptContainer.viewTreeObserver.removeOnGlobalLayoutListener(this)
val available = receiptContainer.height
val natural = receiptCard.height
if (natural > available && available > 0) {
val scale = available.toFloat() / natural
receiptCard.scaleX = scale
receiptCard.scaleY = scale
receiptCard.pivotX = receiptCard.width / 2f
receiptCard.pivotY = 0f
}
}
})
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack()
}
@@ -1,8 +1,5 @@
package sh.sar.basedbank.ui.login
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
@@ -84,17 +81,6 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() }
binding.cardOtp.setOnClickListener {
val code = binding.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(requireContext(), "OTP copied", Toast.LENGTH_SHORT).show()
}
}
}
val loginFieldWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
@@ -9,14 +9,12 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.databinding.ActivityLoginBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is configured and the user hasn't unlocked this session,
@@ -36,8 +34,5 @@ class LoginActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
}
}
@@ -19,7 +19,6 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
@@ -28,17 +27,11 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
// Only redirect to the lock screen if onboarding is fully complete. Checking the
// security hash alone is not sufficient — the hash is written during the PIN/pattern
// setup step (page 1) which happens *before* the user clicks "Get Started", so a
// theme change or process restart mid-onboarding would otherwise trigger this guard
// and strand the user in the lock flow without finishing onboarding.
if (prefs.getBoolean("onboarding_done", false) && CredentialStore(this).loadSecurityHash() != null) {
// If security is already configured, onboarding is complete. Redirect to lock screen
// to prevent overwriting an existing PIN/pattern via direct activity launch.
if (CredentialStore(this).loadSecurityHash() != null) {
startActivity(Intent(this, LockActivity::class.java))
finish()
return
@@ -52,9 +45,7 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val originalBottomPadding = binding.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
@@ -10,7 +10,6 @@ import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
import sh.sar.basedbank.ui.home.NavCustomization
class OnboardingConfigureFragment : Fragment() {
@@ -25,20 +24,12 @@ class OnboardingConfigureFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation
binding.navModeToggle.check(when (NavCustomization.getNavMode(prefs)) {
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
else -> R.id.btnNavDrawer
})
// Navigation — default Drawer
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val mode = when (checkedId) {
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
else -> NavCustomization.NAV_MODE_DRAWER
}
NavCustomization.saveNavMode(prefs, mode)
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
}
// Theme — default System
@@ -59,7 +59,6 @@ class OnboardingFragment : Fragment() {
private fun notifyScrolledToBottom() {
if (scrolledToBottom) return
if (!isAdded) return
scrolledToBottom = true
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
}
@@ -8,7 +8,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.fragment.app.Fragment
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
@@ -103,17 +102,8 @@ class SecuritySetupFragment : Fragment() {
else
com.google.android.material.R.attr.materialButtonOutlinedStyle
val btn = MaterialButton(requireContext(), null, style).apply {
if (key == "" || key == "") {
text = ""
icon = ContextCompat.getDrawable(requireContext(),
if (key == "") R.drawable.ic_backspace else R.drawable.ic_check)
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
iconPadding = 0
iconSize = (28 * dp).toInt()
} else {
text = key
textSize = 24f
}
text = key
textSize = 24f
insetTop = 0; insetBottom = 0
minimumWidth = 0; minimumHeight = 0
cornerRadius = btnSize / 2
@@ -615,41 +615,6 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null }
}
// ── Default payment card ──────────────────────────────────────────────────
/** BML card account number the user has pinned as their default payment card, or null. */
fun getDefaultCardAccountNumber(): String? = prefs.getString("default_card_account_number", null)
fun setDefaultCardAccountNumber(accountNumber: String?) {
val editor = prefs.edit()
if (accountNumber == null) editor.remove("default_card_account_number")
else editor.putString("default_card_account_number", accountNumber)
editor.apply()
}
// ── Default transfer/QR account ───────────────────────────────────────────
/** Account number the user has pinned as their default source for transfers and PayMV QR, or null. */
fun getDefaultAccountNumber(): String? = prefs.getString("default_account_number", null)
fun setDefaultAccountNumber(accountNumber: String?) {
val editor = prefs.edit()
if (accountNumber == null) editor.remove("default_account_number")
else editor.putString("default_account_number", accountNumber)
editor.apply()
}
// ── Dashboard card visibility ─────────────────────────────────────────────
fun getHiddenDashboardCardNumbers(): Set<String> =
prefs.getStringSet("hidden_dashboard_cards", emptySet()) ?: emptySet()
fun setCardHiddenFromDashboard(accountNumber: String, hidden: Boolean) {
val current = getHiddenDashboardCardNumbers().toMutableSet()
if (hidden) current.add(accountNumber) else current.remove(accountNumber)
prefs.edit().putStringSet("hidden_dashboard_cards", current).apply()
}
// ── MIB profile visibility (per loginId) ─────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
@@ -1,53 +0,0 @@
package sh.sar.basedbank.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.cardemulation.CardEmulation
import android.provider.Settings
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
object NfcPaymentUtil {
fun checkAndProceed(context: Context, onReady: () -> Unit) {
val nfcAdapter = NfcAdapter.getDefaultAdapter(context) ?: run {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_unsupported_title)
.setMessage(R.string.nfc_unsupported_message)
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
if (!nfcAdapter.isEnabled) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_disabled_title)
.setMessage(R.string.nfc_disabled_message)
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
}
.setNegativeButton(R.string.cancel, null)
.show()
return
}
val cardEmulation = CardEmulation.getInstance(nfcAdapter)
val componentName = ComponentName(context, BmlHostCardEmulatorService::class.java)
if (!cardEmulation.isDefaultServiceForCategory(componentName, CardEmulation.CATEGORY_PAYMENT)) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_not_default_title)
.setMessage(context.getString(R.string.nfc_not_default_message,
context.applicationInfo.loadLabel(context.packageManager)))
.setPositiveButton(R.string.nfc_payment_open_settings) { _, _ ->
context.startActivity(Intent(Settings.ACTION_NFC_PAYMENT_SETTINGS))
}
.setNegativeButton(R.string.cancel, null)
.show()
return
}
onReady()
}
}
@@ -1,137 +0,0 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.ui.home.AppNotification
object NotificationsCache {
private const val PREFS = "notifications_cache"
private const val KEY_MIB_READ_IDS = "mib_read_ids"
private fun bmlKey(loginId: String) = "bml_notifs_$loginId"
private fun mibKey(loginId: String) = "mib_activities_$loginId"
// ── BML ─────────────────────────────────────────────────────────────────────
fun saveBml(ctx: Context, loginId: String, items: List<AppNotification>) {
val arr = JSONArray()
items.forEach { n ->
arr.put(JSONObject().apply {
put("id", n.id)
put("group", n.group)
put("title", n.title)
put("message", n.message)
put("timestampMs", n.timestampMs)
put("isRead", n.isRead)
val fields = JSONArray()
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
put("detailFields", fields)
})
}
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(ctx: Context, loginId: String): List<AppNotification> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
val fields = obj.optJSONArray("detailFields")
val detailFields = if (fields != null) {
(0 until fields.length()).map { j ->
val f = fields.getJSONObject(j)
f.getString("k") to f.getString("v")
}
} else emptyList()
AppNotification(
id = obj.getString("id"),
bank = "BML",
loginId = loginId,
group = obj.getString("group"),
title = obj.getString("title"),
message = obj.getString("message"),
timestampMs = obj.getLong("timestampMs"),
isRead = obj.getBoolean("isRead"),
detailFields = detailFields
)
}
} catch (_: Exception) { emptyList() }
}
// ── MIB ─────────────────────────────────────────────────────────────────────
fun saveMib(ctx: Context, loginId: String, items: List<AppNotification>) {
val arr = JSONArray()
items.forEach { n ->
arr.put(JSONObject().apply {
put("id", n.id)
put("title", n.title)
put("message", n.message)
put("timestampMs", n.timestampMs)
val fields = JSONArray()
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
put("detailFields", fields)
})
}
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(mibKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadMib(ctx: Context, loginId: String, readIds: Set<String>): List<AppNotification> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(mibKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
val id = obj.getString("id")
val fields = obj.optJSONArray("detailFields")
val detailFields = if (fields != null) {
(0 until fields.length()).map { j ->
val f = fields.getJSONObject(j)
f.getString("k") to f.getString("v")
}
} else emptyList()
AppNotification(
id = id,
bank = "MIB",
loginId = loginId,
group = "ALERTS",
title = obj.getString("title"),
message = obj.getString("message"),
timestampMs = obj.getLong("timestampMs"),
isRead = id in readIds,
detailFields = detailFields
)
}
} catch (_: Exception) { emptyList() }
}
// ── MIB read IDs (in-app only) ───────────────────────────────────────────────
fun getMibReadIds(ctx: Context): Set<String> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB_READ_IDS, null) ?: return emptySet()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }.toSet()
} catch (_: Exception) { emptySet() }
}
fun addMibReadIds(ctx: Context, ids: Collection<String>) {
val current = getMibReadIds(ctx).toMutableSet()
current.addAll(ids)
val arr = JSONArray().apply { current.forEach { put(it) } }
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB_READ_IDS, arr.toString()).apply()
}
fun clearAll(ctx: Context) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
}
@@ -9,24 +9,6 @@ data class PaymvQrData(
object PaymvQrParser {
/**
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
* 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)
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
*/
fun extractBmlGatewayUrl(raw: String): String? {
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
return try {
val root = parseTlv(raw)
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
} catch (_: Exception) {
null
}
}
fun parse(raw: String): PaymvQrData? {
return try {
val root = parseTlv(raw)
@@ -41,7 +23,7 @@ object PaymvQrParser {
PaymvQrData(
accountNumber = merchantInfo?.get("03"),
amount = root["54"]?.takeIf { it != "***" },
amount = root["54"],
purpose = additionalData?.get("08"),
merchantName = root["59"]
)
@@ -1,78 +0,0 @@
package sh.sar.basedbank.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions
import sh.sar.basedbank.R
object ThemeHelper {
const val PRESET_BLUE = "blue"
const val PRESET_RED = "red"
const val PRESET_GREEN = "green"
const val PRESET_CUSTOM = "custom"
fun isSystemTheme(context: Context): Boolean =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("theme", "system") == "system"
fun getAccentPreset(context: Context): String =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("accent_preset", PRESET_BLUE) ?: PRESET_BLUE
fun getCustomColor(context: Context): Int? {
val hex = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("accent_custom_color", null) ?: return null
return try { Color.parseColor(hex) } catch (_: Exception) { null }
}
fun presetSeedColor(context: Context, preset: String): Int = when (preset) {
PRESET_RED -> Color.parseColor("#D32F2F")
PRESET_GREEN -> Color.parseColor("#4CAF50")
PRESET_CUSTOM -> getCustomColor(context) ?: Color.parseColor("#3F65AD")
else -> Color.parseColor("#3F65AD")
}
fun isPitchBlackEnabled(context: Context): Boolean =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("pitch_black", false)
/**
* Apply the user-chosen accent theme to the given activity.
* Must be called BEFORE super.onCreate() so window-level attributes
* (status bar color, etc.) are resolved from the correct overlay.
* Has no effect when the theme is set to "system" (dynamic colors are
* handled by BasedBankApp via DynamicColors.applyToActivitiesIfAvailable).
*/
fun applyAccent(activity: AppCompatActivity) {
if (isSystemTheme(activity)) return
val preset = getAccentPreset(activity)
val seed = presetSeedColor(activity, preset)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
bitmap.setPixel(0, 0, seed)
DynamicColors.applyToActivityIfAvailable(
activity,
DynamicColorsOptions.Builder().setContentBasedSource(bitmap).build()
)
} else {
// API < 31: apply a simple style overlay (partial palette, but functional)
val styleRes = when (preset) {
PRESET_RED -> R.style.ThemeOverlay_Accent_Orange
PRESET_GREEN -> R.style.ThemeOverlay_Accent_Green
else -> R.style.ThemeOverlay_Accent_Blue
}
activity.theme.applyStyle(styleRes, true)
}
val prefs = activity.getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isDark = prefs.getString("theme", "system") == "dark"
if (isDark && prefs.getBoolean("pitch_black", false)) {
activity.theme.applyStyle(R.style.ThemeOverlay_PitchBlack, true)
}
}
}
@@ -40,10 +40,10 @@ object BmlCardParser {
"C8040", "C8044" -> "cards/bml/master_world.png"
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C8905", "C8995" -> "cards/bml/visa_credit.png"
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
"C1005", "C1006", "C1030", "C1089" -> "cards/bml/visa_debit_islamic.png"
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
"C1017" -> "cards/bml/visa_infinite.png"
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
+10 -11
View File
@@ -5,20 +5,19 @@
android:viewportWidth="60"
android:viewportHeight="60">
<!-- White background -->
<path
android:fillColor="#FFFFFF"
android:pathData="M0 59.776h59.785V0H0z"/>
<!-- Red background -->
<path
android:fillColor="#E21B23"
android:pathData="M0 60h60V0H0z"/>
android:pathData="M3.297 56.421h53.191V3.356H3.298z"/>
<!-- White logo mark -->
<group
android:scaleX="0.85"
android:scaleY="0.85"
android:pivotX="30"
android:pivotY="30">
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
</group>
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Translucent dark pill used as a status overlay on card-visual surfaces
(item_card_dashboard / _stack / _wallet / _settings_entry). Theme-independent
by design: it sits on top of card artwork, not the surface color. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="#CC212121" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#44808080" />
<corners android:radius="2dp" />
</shape>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M22,3H7C6.31,3 5.77,3.35 5.41,3.88L0,12L5.41,20.12C5.77,20.65 6.31,21 7,21H22C23.1,21 24,20.1 24,19V5C24,3.9 23.1,3 22,3ZM19,15.59L17.59,17L14,13.41L10.41,17L9,15.59L12.59,12L9,8.41L10.41,7L14,10.59L17.59,7L19,8.41L15.41,12L19,15.59Z" />
</vector>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Bell body (white) -->
<path
android:fillColor="@android:color/white"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
<!-- Unread notification dot (red) -->
<path
android:fillColor="#EF5350"
android:pathData="M18.5,2A3.5,3.5,0,1,0,18.5,9A3.5,3.5,0,0,0,18.5,2Z"/>
</vector>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<!-- Bell outline (no fill) -->
<path
android:fillColor="@android:color/transparent"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"
android:strokeColor="@android:color/white"
android:strokeWidth="1.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59Z" />
</vector>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Shackle (open - right leg lifted free) -->
<path
android:fillColor="@android:color/transparent"
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeWidth="2.2"/>
<!-- Body + keyhole cutout -->
<path
android:fillColor="@android:color/white"
android:fillType="evenOdd"
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
</vector>
+1 -1
View File
@@ -2,7 +2,7 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="@color/ic_logo_background" />
<solid android:color="#E8B547" />
</shape>
</item>
<item android:drawable="@drawable/ic_launcher_foreground" />
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Card icon -->
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
<!-- Background circle separating gear from card -->
<path
android:fillColor="?attr/colorSurface"
android:pathData="M12.5,18 A5.5,5.5 0 1 0 23.5,18 A5.5,5.5 0 1 0 12.5,18 Z" />
<!--
Simple 6-tooth gear, scaled 0.5x and placed at (18,18) in the icon.
The gear path is drawn in a 24×24 space (center 12,12); the group
transform maps it: point(x,y) → (x·0.5+12, y·0.5+12).
Outer radius=10, root radius=7, hub radius=4.
Teeth are rectangular (straight-line) so they stay crisp at small scale.
Outer ring is clockwise; hub hole is counterclockwise (nonZero cutout).
-->
<group
android:scaleX="0.5"
android:scaleY="0.5"
android:translateX="12"
android:translateY="12">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="
M18.58,9.61 L21.85,10.26 L21.85,13.74 L18.58,14.39
L17.36,16.50 L18.43,19.66 L15.42,21.40 L13.22,18.89
L10.78,18.89 L8.58,21.40 L5.57,19.66 L6.64,16.50
L5.42,14.39 L2.15,13.74 L2.15,10.26 L5.42,9.61
L6.64,7.50 L5.57,4.34 L8.58,2.60 L10.78,5.11
L13.22,5.11 L15.42,2.60 L18.43,4.34 L17.36,7.50 Z
M12,8 A4,4 0 1 0 12,16 A4,4 0 1 0 12,8 Z" />
</group>
</vector>
+4 -36
View File
@@ -2,41 +2,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<!-- Phone outline -->
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
<!-- Top notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M4.5,12.55 L22.03,12.55"/>
<!-- Bottom notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M4.5,35.45 L22.03,35.45"/>
<!-- NFC waves (outer, mid, inner) -->
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
android:fillColor="?attr/colorOnSurface"
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4l0,16c0,1.1 0.9,2 2,2l16,0c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2zM13,18l-2,0 0,-1c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5l-2,0c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3l0,-2 2,3zM19,12l-2,0c0,-2.76 -2.24,-5 -5,-5l0,-2C15.87,5 19,8.13 19,12z"/>
</vector>
+1 -1
View File
@@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:fillColor="?attr/colorOnSurface"
android:pathData="M9.5,6.5v3h-3v-3H9.5zM11,5L5,5v5.5h6L11,5zM9.5,14.5v3h-3v-3H9.5zM11,13L5,13v5.5h6L11,13zM17.5,6.5v3h-3v-3H17.5zM19,5h-6v5.5h6L19,5zM13,13h1.5v1.5L13,14.5zM14.5,14.5L16,14.5L16,16h-1.5zM16,13h1.5v1.5L16,14.5zM13,16h1.5v1.5L13,17.5zM14.5,17.5L16,17.5L16,19h-1.5zM16,16h1.5v1.5L16,17.5zM17.5,14.5L19,14.5L19,16h-1.5zM17.5,17.5L19,17.5L19,19h-1.5z"/>
</vector>
@@ -4,47 +4,13 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30"
android:scaleX="1.0"
android:scaleY="1.0">
<!-- Phone outline -->
android:scaleX="2"
android:scaleY="2">
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
<!-- Top notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:pathData="M4.5,12.55 L22.03,12.55"/>
<!-- Bottom notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:pathData="M4.5,35.45 L22.03,35.45"/>
<!-- NFC waves -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
android:fillColor="#FFFFFF"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
</group>
</vector>
+2 -3
View File
@@ -44,8 +44,8 @@
android:id="@+id/connectivityBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#C62828"
android:textColor="#FFFFFF"
android:background="?attr/colorErrorContainer"
android:textColor="?attr/colorOnErrorContainer"
android:gravity="center"
android:paddingTop="6dp"
android:paddingBottom="6dp"
@@ -87,7 +87,6 @@ app:menu="@menu/bottom_nav_menu" />
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout>
@@ -48,12 +48,28 @@
android:paddingBottom="16dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/skeletonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
</LinearLayout>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No transactions found"
android:text="@string/transactions_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
@@ -49,7 +49,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No recent transfers"
android:text="@string/activities_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
@@ -38,7 +38,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
@@ -81,7 +81,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
@@ -125,7 +125,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
+18 -128
View File
@@ -14,47 +14,12 @@
android:orientation="vertical"
android:visibility="gone">
<!-- Manage Card button row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end"
android:paddingHorizontal="12dp"
android:paddingTop="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnManageCard"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/card_manage"
android:textSize="12sp"
app:icon="@drawable/ic_manage_card"
app:iconSize="20dp"
app:iconPadding="4dp" />
</LinearLayout>
<!-- Top spacer: pushes card to vertical center (hidden in manage mode) -->
<!-- Top spacer: pushes card to vertical center -->
<View
android:id="@+id/topSpacer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Manage mode: selected card with overlays -->
<include
android:id="@+id/manageCardView"
layout="@layout/item_card_stack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="8dp"
android:visibility="gone" />
<!-- Horizontal card stack. Width/padding set programmatically for centering + peek. -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCards"
@@ -93,22 +58,20 @@
<!-- Divider -->
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="4dp"
android:background="?attr/colorOutlineVariant" />
<!-- Primary pay actions (normal mode) -->
<!-- Primary pay actions -->
<LinearLayout
android:id="@+id/llPayButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="12dp">
android:paddingBottom="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanToPay"
@@ -148,134 +111,61 @@
</LinearLayout>
<!-- Default card toggle (manage mode only) -->
<!-- Secondary card management actions -->
<LinearLayout
android:id="@+id/llDefaultCardRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="20dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/card_set_as_default"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchDefaultCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Hide from dashboard toggle (manage mode only) -->
<LinearLayout
android:id="@+id/llHideDashboardRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="20dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/card_hide_from_dashboard"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchHideFromDashboard"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Card management actions (manage mode only) -->
<LinearLayout
android:id="@+id/llManageButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:visibility="gone">
android:paddingTop="4dp"
android:paddingBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangePin"
style="@style/Widget.Material3.Button.TonalButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_change_pin"
android:textSize="12sp"
android:textSize="11sp"
app:icon="@drawable/ic_edit"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
app:iconSize="16dp"
app:iconPadding="3dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFreeze"
style="@style/Widget.Material3.Button.TonalButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_freeze"
android:textSize="12sp"
android:textSize="11sp"
app:icon="@drawable/ic_freeze"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
app:iconSize="16dp"
app:iconPadding="3dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBlock"
style="@style/Widget.Material3.Button.TonalButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_block"
android:textSize="12sp"
android:textSize="11sp"
app:icon="@drawable/ic_block"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
app:iconSize="16dp"
app:iconPadding="3dp" />
</LinearLayout>
</LinearLayout>
<!-- Tap-to-pay overlay: shown in tap mode, sits above contentLayout -->
<FrameLayout
android:id="@+id/flTapMode"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Loading state -->
<LinearLayout
android:id="@+id/loadingView"
@@ -116,11 +116,9 @@
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:visibility="invisible"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="@dimen/card_elevation_flat">
<LinearLayout
android:layout_width="match_parent"
+95 -107
View File
@@ -21,7 +21,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="@dimen/screen_margin">
<!-- Balance cards row -->
<LinearLayout
@@ -34,8 +34,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -56,7 +56,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -67,8 +68,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -89,7 +90,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -112,8 +114,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -134,7 +136,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -146,8 +149,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -168,7 +171,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -176,92 +180,7 @@
</LinearLayout>
<!-- Blocked funds row: MVR + USD separate cards (hidden when no blocked amounts) -->
<LinearLayout
android:id="@+id/rowBlocked"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp"
android:visibility="gone">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBlockedMvr"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked_mvr"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedMvr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBlockedUsd"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked_usd"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedUsd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Overdue row (hidden when no overdue financing) -->
<!-- Attention row: Blocked + Overdue (hidden when nothing applies) -->
<LinearLayout
android:id="@+id/rowAttention"
android:layout_width="match_parent"
@@ -271,11 +190,62 @@
android:visibility="gone">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOverdue"
android:layout_width="match_parent"
android:id="@+id/cardBlocked"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer"
android:fontFeatureSettings="tnum" />
<TextView
android:id="@+id/tvBlockedSecondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer"
android:fontFeatureSettings="tnum"
android:alpha="0.75"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOverdue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:clickable="true"
android:focusable="true"
@@ -301,7 +271,8 @@
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
android:textColor="?attr/colorOnErrorContainer"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -315,8 +286,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
android:clickable="true"
android:focusable="true">
@@ -339,7 +310,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -361,6 +333,14 @@
android:layout_marginTop="16dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/nav_pay_with_card"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCards"
android:layout_width="match_parent"
@@ -386,6 +366,14 @@
android:paddingTop="8dp"
android:paddingBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -44,14 +44,6 @@
android:layout_weight="1"
android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavCircular"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_circular" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton"
+12 -42
View File
@@ -64,6 +64,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/paymvqr_amount_hint"
app:helperText="@string/paymvqr_amount_helper"
app:prefixText="MVR ">
<com.google.android.material.textfield.TextInputEditText
@@ -75,47 +76,6 @@
</com.google.android.material.textfield.TextInputLayout>
<!-- Reference / purpose (optional) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilReference"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:hint="@string/paymvqr_reference_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etReference"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Include phone number toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/paymvqr_include_phone"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchIncludePhone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
</LinearLayout>
<!-- Action buttons — always visible; share/save disabled until QR is rendered -->
<LinearLayout
android:id="@+id/layoutActions"
@@ -139,11 +99,21 @@
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginHorizontal="4dp"
android:enabled="false"
android:text="@string/paymvqr_save_image"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanQr"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/transfer_scan_qr"
app:icon="@drawable/ic_qr_scan" />
</LinearLayout>
</LinearLayout>
@@ -7,14 +7,6 @@
android:orientation="vertical"
android:background="?attr/colorSurface">
<ScrollView
android:id="@+id/receiptContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Renderable receipt card -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
@@ -215,13 +207,10 @@
</LinearLayout>
</ScrollView>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Action buttons — outside renderable area -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
<LinearLayout
android:id="@+id/btnRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
@@ -7,14 +7,6 @@
android:orientation="vertical"
android:background="?attr/colorSurface">
<ScrollView
android:id="@+id/receiptContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<!-- Renderable receipt card (header grows to fill remaining space) -->
<LinearLayout
android:id="@+id/receiptCard"
@@ -244,11 +236,8 @@
</LinearLayout>
</ScrollView>
<!-- Action buttons — outside renderable area -->
<LinearLayout
android:id="@+id/btnRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
@@ -52,7 +52,7 @@
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
@@ -94,7 +94,7 @@
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
@@ -35,14 +35,6 @@
android:layout_weight="1"
android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavCircular"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_circular" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton"
@@ -78,31 +70,6 @@
</LinearLayout>
<!-- Circular nav shortcuts — shown only when circular nav is active -->
<LinearLayout
android:id="@+id/sectionCircularSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_circular_shortcuts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCircularSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never" />
</LinearLayout>
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
<LinearLayout
android:id="@+id/sectionBottomBarShortcuts"
@@ -189,87 +156,6 @@
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Pitch black — enabled only in explicit dark mode -->
<LinearLayout
android:id="@+id/rowPitchBlack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
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_pitch_black"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchPitchBlack"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Accent color — always shown, disabled/grayed in system theme mode -->
<LinearLayout
android:id="@+id/sectionAccentColor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_accent_color"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/accentToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentBlue"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_blue" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentOrange"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_orange" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentGreen"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentCustom"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_custom" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -194,6 +194,44 @@
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowHideAmounts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<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="@string/settings_hide_amounts"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchHideAmounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
@@ -49,12 +49,28 @@
android:paddingBottom="16dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/skeletonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
</LinearLayout>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No transactions found"
android:text="@string/transactions_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
+5 -3
View File
@@ -9,8 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
@@ -70,7 +70,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum" />
<TextView
android:id="@+id/tvBlocked"
@@ -79,6 +80,7 @@
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorError"
android:fontFeatureSettings="tnum"
android:visibility="gone" />
<ImageButton
@@ -15,6 +15,7 @@
android:scaleType="fitCenter"
android:layout_marginEnd="10dp"
android:visibility="gone"
android:importantForAccessibility="no"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
xmlns:app="http://schemas.android.com/apk/res-auto" />
@@ -49,7 +50,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -7,8 +7,8 @@
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -114,7 +114,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -137,7 +138,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -162,7 +164,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorError"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -182,55 +185,6 @@
</LinearLayout>
<!-- Default account divider + row (shown only for non-card accounts) -->
<View
android:id="@+id/dividerDefaultAccount"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:background="?attr/colorOutlineVariant"
android:visibility="gone" />
<LinearLayout
android:id="@+id/llDefaultAccountRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="12dp"
android:visibility="gone">
<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="Default account"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Auto-selected for transfers and PayMV QR"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchDefaultAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+5 -7
View File
@@ -5,8 +5,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -51,11 +53,7 @@
android:id="@+id/tvLoanStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
style="@style/Widget.App.StatusChip" />
</LinearLayout>
+4 -3
View File
@@ -9,8 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
@@ -83,7 +83,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" />
android:layout_marginTop="6dp"
android:fontFeatureSettings="tnum" />
<ImageButton
android:id="@+id/btnTransfer"
@@ -2,7 +2,7 @@
<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="320dp"
android:layout_width="280dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
app:cardCornerRadius="16dp"
+9 -9
View File
@@ -26,6 +26,15 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnTransferContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:padding="6dp"
android:contentDescription="@string/transfer" />
<ImageButton
android:id="@+id/btnEditContact"
android:layout_width="36dp"
@@ -45,15 +54,6 @@
android:tint="?attr/colorError"
android:contentDescription="@string/contact_delete" />
<ImageButton
android:id="@+id/btnTransferContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:padding="6dp"
android:contentDescription="@string/transfer" />
</LinearLayout>
<LinearLayout
@@ -5,8 +5,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -51,11 +53,7 @@
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
style="@style/Widget.App.StatusChip" />
</LinearLayout>
@@ -5,8 +5,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
android:clickable="true"
android:focusable="true"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
+4 -3
View File
@@ -5,15 +5,16 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/ivIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp" />
android:layout_marginEnd="16dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
+2 -1
View File
@@ -13,7 +13,8 @@
android:id="@+id/ivNavIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginBottom="6dp" />
android:layout_marginBottom="6dp"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/tvNavLabel"
+2 -4
View File
@@ -6,10 +6,8 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardElevation="2dp">
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_raised">
<LinearLayout
android:layout_width="match_parent"
@@ -16,6 +16,7 @@
android:layout_width="44dp"
android:layout_height="44dp"
android:scaleType="centerCrop"
android:importantForAccessibility="no"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
@@ -53,6 +54,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum"
android:paddingHorizontal="8dp"
android:visibility="gone" />
@@ -20,10 +20,6 @@
android:id="@+id/tvProfileType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
style="@style/Widget.App.StatusChip" />
</LinearLayout>
@@ -86,6 +86,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textStyle="bold"
android:fontFeatureSettings="tnum"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/fvAvatar"
app:layout_constraintBottom_toBottomOf="@id/fvAvatar" />
-25
View File
@@ -1,25 +0,0 @@
<?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="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="24dp"
android:paddingBottom="16dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="@string/app_name" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/app_name"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
@@ -65,8 +65,8 @@
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="@dimen/card_elevation_raised">
<LinearLayout
android:layout_width="match_parent"
@@ -1,72 +0,0 @@
<?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:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Drag handle -->
<View
android:layout_width="36dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"
android:background="@drawable/drag_handle_bg" />
<!-- Header row -->
<LinearLayout
android:id="@+id/notifHeader"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp"
android:paddingVertical="8dp">
<TextView
android:id="@+id/tvNotifTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Notifications"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textStyle="bold" />
<TextView
android:id="@+id/btnMarkAllRead"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Mark all read"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:padding="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:focusable="true"
android:clickable="true"
android:textColor="?attr/colorPrimary" />
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:alpha="0.12"
android:background="?attr/colorOnSurface" />
<!-- Tabs -->
<com.google.android.material.tabs.TabLayout
android:id="@+id/notifTabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="fixed"
app:tabGravity="fill" />
<!-- Pager fills remaining height -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/notifPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.button.MaterialButton
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/cancel"
android:textSize="13sp"
app:icon="@drawable/ic_block"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />

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