Compare commits

..

23 Commits

Author SHA1 Message Date
shihaam ed5b456e3b Release version 1.0.11 2026-05-28 23:40:19 +05:00
shihaam 9b284cc8d4 BML change payment gateway to use PayMV QR, so added support for it 2026-05-28 23:39:39 +05:00
shihaam c0b58061c2 release version 1.0.10 2026-05-28 23:08:04 +05:00
shihaam 978da26ff1 update ci to push to tg 2026-05-28 23:07:24 +05:00
shihaam 7fe2ba5788 Able to set Default card for payments now 2026-05-28 22:55:49 +05:00
shihaam 26a0c7b81d manage card mode improve part 2 - animations 2026-05-28 22:27:51 +05:00
shihaam 83fc340e2b manage card mode improve part 1 2026-05-28 22:09:26 +05:00
shihaam bfbb649b33 manage card mode added 2026-05-28 22:02:09 +05:00
shihaam b780091bb8 improve BML QR payment flow - dismiss if account selected, keep if card selected, refuse to select account 2026-05-28 21:47:36 +05:00
shihaam e4468c4a8f handle camera permission for profile pic upload 2026-05-28 21:18:59 +05:00
shihaam b4e1f57347 fix paymv qr generation part 1 2026-05-28 20:29:24 +05:00
shihaam 907757c893 remove unsupported accounts from paymv qr generation 2026-05-28 19:06:59 +05:00
shihaam 1ea0355ce6 remove money value from paymv qr generation 2026-05-28 19:02:52 +05:00
shihaam c9b8973b65 dashboard cards are not 14% bigger 2026-05-28 19:00:46 +05:00
shihaam 7a0e32f4d6 seperate mvr and usd blocked funds in dashboard 2026-05-28 18:45:57 +05:00
shihaam d68b8aaf0a tap to copy OTP 2026-05-28 18:41:51 +05:00
shihaam 396f778ad4 remove white border on bml logo and make logo part smaller and red part bigger (looks nicer now) 2026-05-28 18:38:25 +05:00
shihaam dc0f1b96c1 remove cards and quick action lables from dashboard 2026-05-28 18:26:09 +05:00
shihaam 640dd5de22 theme customizations support 2026-05-28 18:21:38 +05:00
shihaam f0a0e7857c optimize button customizations 2026-05-28 17:25:37 +05:00
shihaam 836f4c493a eye always enabled and removed setting to hide eye 2026-05-28 17:19:04 +05:00
shihaam 6325f4fd7a debug builds get name suffix and different launcher icon color 2026-05-28 17:15:14 +05:00
shihaam 69aa172eff quick actions are drawer-only, bottom bar shortcuts disabled when using drawer 2026-05-28 17:15:04 +05:00
56 changed files with 1193 additions and 371 deletions
+21
View File
@@ -87,3 +87,24 @@ 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
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 8
versionName = "1.0.9"
versionCode = 10
versionName = "1.0.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -115,7 +115,11 @@ class BasedBankApp : Application() {
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)
// 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"
}
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
AppCompatDelegate.setDefaultNightMode(when (theme) {
@@ -21,6 +21,7 @@ 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
@@ -46,6 +47,7 @@ class LockActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater)
@@ -55,6 +57,9 @@ 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)
@@ -142,8 +142,6 @@ 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() {
@@ -170,9 +168,6 @@ 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
@@ -192,7 +187,6 @@ class AccountHistoryFragment : Fragment() {
isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
if (!firstPageDone) {
firstPageDone = true
@@ -9,7 +9,6 @@ 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
@@ -195,10 +194,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 {
Snackbar.make(binding.root, R.string.contact_delete_failed, Snackbar.LENGTH_LONG).show()
Toast.makeText(requireContext(), R.string.contact_delete_failed, Toast.LENGTH_SHORT).show()
}
}
}
@@ -25,6 +25,8 @@ 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.PaymvQrParser
import sh.sar.basedbank.util.CredentialStore
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -40,10 +42,10 @@ class DashboardFragment : Fragment() {
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
@@ -101,8 +103,13 @@ class DashboardFragment : Fragment() {
.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
cardAdapter.update(all)
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
val defaultNum = CredentialStore(requireContext()).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
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
@@ -278,26 +285,23 @@ 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)
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
if (blockedMvr > 0.0) {
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
binding.cardBlockedMvr.visibility = View.VISIBLE
} else {
binding.cardBlocked.visibility = View.GONE
binding.cardBlockedMvr.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 }
@@ -310,8 +314,7 @@ class DashboardFragment : Fragment() {
binding.cardOverdue.visibility = View.GONE
}
binding.rowAttention.visibility =
if (blockedTotal > 0.0 || overdueTotal > 0.0) View.VISIBLE else View.GONE
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
}
private fun updatePendingFinances() {
@@ -68,6 +68,7 @@ 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() {
@@ -106,6 +107,7 @@ class HomeActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater)
@@ -118,6 +120,9 @@ 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.
@@ -250,6 +255,9 @@ 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
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
@@ -258,7 +266,6 @@ class HomeActivity : AppCompatActivity() {
// In bottom nav mode, pressing back navigates up the hierarchy
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())
@@ -512,9 +519,8 @@ 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 = eyeEnabled
eyeItem?.isVisible = true
val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
return true
@@ -1,9 +1,14 @@
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
@@ -42,6 +47,16 @@ 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() {
@@ -36,6 +36,7 @@ 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
@@ -60,9 +61,9 @@ class PayMvQrFragment : Fragment() {
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))
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw))
return@registerForActivityResult
}
@@ -98,6 +99,8 @@ 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() }
@@ -110,7 +113,9 @@ class PayMvQrFragment : Fragment() {
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.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
}
val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter)
@@ -145,8 +150,28 @@ 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)
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, account, payload, amountFormatted)
}
if (_binding == null) return
@@ -164,7 +189,9 @@ class PayMvQrFragment : Fragment() {
accountNumber: String,
accountName: String,
acquirer: String,
amountStr: String?
amountStr: String?,
mobile: String?,
purpose: String
): String {
fun tlv(tag: String, value: String): String {
val len = value.length
@@ -174,17 +201,30 @@ 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 + sub03 + sub10)
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
val mcc = tlv("52", "0000")
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 prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
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"
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) {
@@ -427,7 +467,7 @@ class PayMvQrFragment : Fragment() {
} else {
b.tvDropdownAccountType.visibility = View.GONE
}
b.tvDropdownBalance.text = displayData?.balance ?: ""
b.tvDropdownBalance.visibility = View.GONE
b.root.alpha = 1f
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -14,8 +14,11 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
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
@@ -26,7 +29,9 @@ import sh.sar.basedbank.R
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.bmlapi.BmlCardParser
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
class CardsFragment : Fragment() {
@@ -39,14 +44,27 @@ class CardsFragment : Fragment() {
private var currentCardPosition: Int = 0
private var cardWidth: Int = 0
private var pendingQrAccountNumber: String? = null
private var isManageMode: Boolean = 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()) }
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
@@ -64,7 +82,7 @@ class CardsFragment : Fragment() {
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
val stackAdapter = CardStackAdapter(cardWidth)
stackAdapter = CardStackAdapter(cardWidth)
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = stackAdapter
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
@@ -101,24 +119,8 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
insets
}
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() }
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) {
@@ -128,6 +130,54 @@ 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) 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) {
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) {
@@ -154,6 +204,216 @@ 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.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)
}
}
// 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.switchDefaultCard.setOnCheckedChangeListener(null)
buildDots(cards.size, currentCardPosition)
}
.start()
binding.tvSelectedCardType.animate()
.translationY(targetTextTransY)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
.start()
}
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)
}
}
private fun applyCardScales() {
val rv = binding.rvCards
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
@@ -173,6 +433,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
private fun buildDots(count: Int, selected: Int) {
if (isManageMode) return
binding.pageIndicator.removeAllViews()
if (count <= 1) {
binding.pageIndicator.visibility = View.GONE
@@ -207,6 +468,14 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
}
fun onBackPressed(): Boolean {
if (isManageMode) {
setManageMode(false)
return true
}
return false
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
@@ -305,7 +574,12 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE
tv.text = statusLabel
tv.setBackgroundResource(R.drawable.card_overlay_pill_bg)
val dp = tv.context.resources.displayMetrics.density
tv.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 12 * dp
setColor(0xCC212121.toInt())
}
}
fun formatMasked(masked: String): String {
@@ -2,7 +2,9 @@ package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Color
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -10,6 +12,7 @@ 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
@@ -18,8 +21,11 @@ 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() {
@@ -103,8 +109,45 @@ class SettingsAppearanceFragment : Fragment() {
}
prefs.edit().putString("theme", key).apply()
AppCompatDelegate.setDefaultNightMode(mode)
updateAccentState(key == "system")
updatePitchBlackState(key == "dark")
}
// 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"
@@ -156,6 +199,68 @@ class SettingsAppearanceFragment : Fragment() {
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 isBottom = prefs.getBoolean("bottom_nav", false)
if (items === slots && !isBottom) return
@@ -168,6 +273,7 @@ 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)
}
}
@@ -1,7 +1,10 @@
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
@@ -15,6 +18,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.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -77,6 +81,29 @@ 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()
@@ -159,7 +186,11 @@ class SettingsLoginsFragment : Fragment() {
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
cameraPhotoUri = uri
cameraLauncher.launch(uri)
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(uri)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
@@ -86,17 +86,6 @@ 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
@@ -125,9 +125,12 @@ class TransferFragment : Fragment() {
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))
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))
return@registerForActivityResult
}
@@ -290,6 +293,25 @@ class TransferFragment : Fragment() {
}
bmlQrInfo = info
// 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
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
@@ -297,7 +319,6 @@ 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
@@ -353,6 +374,14 @@ 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)
@@ -506,6 +535,14 @@ 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
@@ -1737,6 +1774,7 @@ 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
@@ -1754,7 +1792,11 @@ class TransferFragment : Fragment() {
}
val balance = displayData?.balance ?: ""
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = if (inactive) 0.4f else 1f
b.root.alpha = when {
inactive -> 0.4f
bmlQrInfo != null && !isCard -> 0.35f
else -> 1f
}
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
when {
networkIcon != null -> {
@@ -181,9 +181,6 @@ 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
@@ -289,7 +286,6 @@ class TransferHistoryFragment : Fragment() {
isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
val raw = bannerMsg.get()
when {
@@ -333,8 +329,6 @@ 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) {
@@ -1,5 +1,8 @@
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
@@ -81,6 +84,17 @@ 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,12 +9,14 @@ 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,
@@ -34,5 +36,8 @@ 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,6 +19,7 @@ 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 {
@@ -27,6 +28,7 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is already configured, onboarding is complete. Redirect to lock screen
@@ -45,6 +47,9 @@ 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 ->
@@ -615,6 +615,18 @@ 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()
}
// ── MIB profile visibility (per loginId) ─────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
@@ -9,6 +9,24 @@ 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)
@@ -0,0 +1,78 @@
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)
}
}
}
+11 -10
View File
@@ -5,19 +5,20 @@
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="M3.297 56.421h53.191V3.356H3.298z"/>
android:pathData="M0 60h60V0H0z"/>
<!-- White logo mark -->
<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
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>
</vector>
@@ -1,9 +0,0 @@
<?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>
@@ -0,0 +1,39 @@
<?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>
+1 -1
View File
@@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:fillColor="?attr/colorOnSurfaceVariant"
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>
+2 -2
View File
@@ -44,8 +44,8 @@
android:id="@+id/connectivityBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorErrorContainer"
android:textColor="?attr/colorOnErrorContainer"
android:background="#C62828"
android:textColor="#FFFFFF"
android:gravity="center"
android:paddingTop="6dp"
android:paddingBottom="6dp"
@@ -48,28 +48,12 @@
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="@string/transactions_empty"
android:text="No transactions found"
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="@string/activities_empty"
android:text="No recent transfers"
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="@dimen/card_radius_lg"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
@@ -81,7 +81,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
@@ -125,7 +125,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
+94 -18
View File
@@ -14,12 +14,47 @@
android:orientation="vertical"
android:visibility="gone">
<!-- Top spacer: pushes card to vertical center -->
<!-- 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) -->
<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"
@@ -64,14 +99,15 @@
android:layout_marginBottom="4dp"
android:background="?attr/colorOutlineVariant" />
<!-- Primary pay actions -->
<!-- Primary pay actions (normal mode) -->
<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="4dp">
android:paddingBottom="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanToPay"
@@ -111,56 +147,96 @@
</LinearLayout>
<!-- Secondary card management actions -->
<!-- Default card toggle (manage mode only) -->
<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>
<!-- 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="4dp"
android:paddingBottom="8dp">
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangePin"
style="@style/Widget.Material3.Button.TextButton"
style="@style/Widget.Material3.Button.TonalButton"
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="11sp"
android:textSize="12sp"
app:icon="@drawable/ic_edit"
app:iconSize="16dp"
app:iconPadding="3dp" />
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFreeze"
style="@style/Widget.Material3.Button.TextButton"
style="@style/Widget.Material3.Button.TonalButton"
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="11sp"
android:textSize="12sp"
app:icon="@drawable/ic_freeze"
app:iconSize="16dp"
app:iconPadding="3dp" />
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBlock"
style="@style/Widget.Material3.Button.TextButton"
style="@style/Widget.Material3.Button.TonalButton"
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="11sp"
android:textSize="12sp"
app:icon="@drawable/ic_block"
app:iconSize="16dp"
app:iconPadding="3dp" />
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout>
@@ -116,9 +116,11 @@
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:visibility="invisible"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="@dimen/card_elevation_flat">
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
+74 -62
View File
@@ -21,7 +21,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/screen_margin">
android:padding="16dp">
<!-- Balance cards row -->
<LinearLayout
@@ -34,8 +34,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
@@ -56,8 +56,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
@@ -68,8 +67,8 @@
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:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
@@ -90,8 +89,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
@@ -114,8 +112,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
@@ -136,8 +134,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
@@ -149,8 +146,8 @@
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:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
@@ -171,8 +168,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
@@ -180,9 +176,9 @@
</LinearLayout>
<!-- Attention row: Blocked + Overdue (hidden when nothing applies) -->
<!-- Blocked funds row: MVR + USD separate cards (hidden when no blocked amounts) -->
<LinearLayout
android:id="@+id/rowAttention"
android:id="@+id/rowBlocked"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
@@ -190,13 +186,13 @@
android:visibility="gone">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBlocked"
android:id="@+id/cardBlockedMvr"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
@@ -209,43 +205,77 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked"
android:text="@string/dashboard_blocked_mvr"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedTotal"
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"
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" />
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOverdue"
android:id="@+id/cardBlockedUsd"
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: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) -->
<LinearLayout
android:id="@+id/rowAttention"
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/cardOverdue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:clickable="true"
android:focusable="true"
@@ -271,8 +301,7 @@
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer"
android:fontFeatureSettings="tnum" />
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
@@ -286,8 +315,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
android:clickable="true"
android:focusable="true">
@@ -310,8 +339,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
@@ -333,14 +361,6 @@
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"
@@ -366,14 +386,6 @@
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"
+41 -1
View File
@@ -64,7 +64,6 @@
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
@@ -76,6 +75,47 @@
</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"
@@ -52,7 +52,7 @@
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardCornerRadius="16dp"
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="@dimen/card_radius_lg"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
@@ -156,6 +156,87 @@
</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,44 +194,6 @@
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,28 +49,12 @@
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="@string/transactions_empty"
android:text="No transactions found"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
+3 -5
View File
@@ -9,8 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
@@ -70,8 +70,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum" />
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvBlocked"
@@ -80,7 +79,6 @@
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorError"
android:fontFeatureSettings="tnum"
android:visibility="gone" />
<ImageButton
@@ -15,7 +15,6 @@
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" />
@@ -50,8 +49,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum" />
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
@@ -7,8 +7,8 @@
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -114,8 +114,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
android:layout_marginTop="2dp" />
</LinearLayout>
@@ -138,8 +137,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
android:layout_marginTop="2dp" />
</LinearLayout>
@@ -164,8 +162,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorError"
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
android:layout_marginTop="2dp" />
</LinearLayout>
+7 -5
View File
@@ -5,10 +5,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -53,7 +51,11 @@
android:id="@+id/tvLoanStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.App.StatusChip" />
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
</LinearLayout>
+3 -4
View File
@@ -9,8 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
@@ -83,8 +83,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp"
android:fontFeatureSettings="tnum" />
android:layout_marginTop="6dp" />
<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="280dp"
android:layout_width="320dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
app:cardCornerRadius="16dp"
@@ -5,10 +5,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -53,7 +51,11 @@
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.App.StatusChip" />
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
</LinearLayout>
@@ -5,10 +5,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:clickable="true"
android:focusable="true"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
+3 -4
View File
@@ -5,16 +5,15 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/ivIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
android:importantForAccessibility="no" />
android:layout_marginEnd="16dp" />
<LinearLayout
android:layout_width="0dp"
+1 -2
View File
@@ -13,8 +13,7 @@
android:id="@+id/ivNavIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginBottom="6dp"
android:importantForAccessibility="no" />
android:layout_marginBottom="6dp" />
<TextView
android:id="@+id/tvNavLabel"
+4 -2
View File
@@ -6,8 +6,10 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_raised">
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
@@ -16,7 +16,6 @@
android:layout_width="44dp"
android:layout_height="44dp"
android:scaleType="centerCrop"
android:importantForAccessibility="no"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
@@ -54,7 +53,6 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum"
android:paddingHorizontal="8dp"
android:visibility="gone" />
@@ -20,6 +20,10 @@
android:id="@+id/tvProfileType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.App.StatusChip" />
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
</LinearLayout>
@@ -86,7 +86,6 @@
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" />
@@ -65,8 +65,8 @@
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="@dimen/card_elevation_raised">
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
+1 -33
View File
@@ -5,36 +5,4 @@
<dimen name="nav_header_vertical_spacing">8dp</dimen>
<dimen name="nav_header_height">176dp</dimen>
<dimen name="fab_margin">16dp</dimen>
<!-- ====== Design tokens ======================================== -->
<!-- Card surfaces.
Two-tier radius:
lg → hero/standalone cards (bank tiles, history header, finance deals, OTP cards)
md → repeating row/summary cards (dashboard cards, foreign limit, bottom sheets)
Card-visual representations (item_card_*) keep their own larger radius/elevation
to read as real credit cards — they are not tokenized. -->
<dimen name="card_radius_lg">16dp</dimen>
<dimen name="card_radius_md">12dp</dimen>
<!-- Card elevation. Two tiers:
flat → outlined cards (paired with a colorOutlineVariant stroke)
raised → subtle Material 3 default raise
Larger elevations belong to specific card-visual surfaces only. -->
<dimen name="card_elevation_flat">0dp</dimen>
<dimen name="card_elevation_raised">1dp</dimen>
<!-- Screen-level spacing -->
<dimen name="screen_margin">16dp</dimen>
<dimen name="section_gap">16dp</dimen>
<!-- Standard list/row padding -->
<dimen name="list_item_padding_h">16dp</dimen>
<dimen name="list_item_padding_v">14dp</dimen>
<!-- Gutters: small inline spacing units -->
<dimen name="gutter_xs">2dp</dimen>
<dimen name="gutter_sm">4dp</dimen>
<dimen name="gutter_md">8dp</dimen>
<dimen name="gutter_lg">12dp</dimen>
</resources>
</resources>
+17 -4
View File
@@ -115,11 +115,13 @@
<!-- PayMV QR Generator -->
<string name="paymvqr_select_account">Select account</string>
<string name="paymvqr_amount_hint">Amount (optional)</string>
<string name="paymvqr_amount_helper">Leave empty to allow payer to enter any amount</string>
<string name="paymvqr_share">Share</string>
<string name="paymvqr_save_image">Save Image</string>
<string name="paymvqr_saved">QR saved to gallery</string>
<string name="paymvqr_save_failed">Failed to save image</string>
<string name="paymvqr_include_phone">Include phone number</string>
<string name="paymvqr_reference_hint">Reference (optional)</string>
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
<!-- Toolbar -->
<string name="action_lock">Lock app</string>
@@ -148,6 +150,15 @@
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="settings_pitch_black">Pitch Black</string>
<string name="settings_accent_color">Accent Color</string>
<string name="accent_blue">Blue</string>
<string name="accent_orange">Red</string>
<string name="accent_green">Green</string>
<string name="accent_custom">Custom</string>
<string name="accent_custom_pick">Custom Accent Color</string>
<string name="accent_custom_hint">#RRGGBB hex color</string>
<string name="accent_invalid_color">Invalid color — enter a valid hex code</string>
<string name="language">Language</string>
<string name="lang_english">English</string>
<string name="lang_dhivehi">ދިވެހި</string>
@@ -209,7 +220,8 @@
<string name="cards">Cards</string>
<string name="available_balance">Available Balance</string>
<string name="account_blocked_label">%1$s blocked</string>
<string name="dashboard_blocked">Blocked Funds</string>
<string name="dashboard_blocked_mvr">Blocked MVR</string>
<string name="dashboard_blocked_usd">Blocked USD</string>
<string name="dashboard_overdue">Overdue Financing</string>
<!-- Transfer -->
@@ -231,6 +243,7 @@
<string name="transfer_qr_invalid">Invalid or unsupported QR code</string>
<string name="qr_camera_permission_title">Camera permission required</string>
<string name="qr_camera_permission_message">Camera access is needed to scan QR codes. Please grant the permission in Settings.</string>
<string name="camera_permission_profile_message">Camera access is needed to take a photo. Please grant the permission in Settings.</string>
<string name="go_to_settings">Go to Settings</string>
<string name="transfer_select_source_first">Select a source account first</string>
<string name="transfer_enter_account_first">Enter an account number first</string>
@@ -255,8 +268,6 @@
<!-- Accounts -->
<string name="accounts_empty">No accounts found</string>
<string name="activities_empty">No recent transfers</string>
<string name="transactions_empty">No transactions found</string>
<!-- Contacts -->
<string name="contacts_empty">No contacts found</string>
@@ -319,6 +330,8 @@
<string name="card_pay_qr">Scan to Pay</string>
<string name="card_pay_nfc">Tap to Pay</string>
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
<string name="card_manage">Manage Card</string>
<string name="card_set_as_default">Set as Default Card</string>
<string name="card_action_change_pin">Change PIN</string>
<string name="card_action_freeze">Freeze</string>
<string name="card_action_block">Block</string>
+28 -9
View File
@@ -1,18 +1,37 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
<!-- Reusable status chip (filled, secondary container). Apply to a TextView via style. -->
<style name="Widget.App.StatusChip" parent="">
<item name="android:paddingHorizontal">10dp</item>
<item name="android:paddingVertical">4dp</item>
<item name="android:background">@drawable/chip_background</item>
<item name="android:textAppearance">?attr/textAppearanceLabelSmall</item>
<item name="android:textColor">?attr/colorOnSecondaryContainer</item>
</style>
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/seed_primary</item>
<item name="colorSecondary">@color/seed_secondary</item>
<item name="android:windowSoftInputMode">adjustResize</item>
</style>
<!-- Accent overlays — applied on API < 31 as fallback when content-based dynamic colors unavailable -->
<style name="ThemeOverlay_Accent_Blue" parent="">
<item name="colorPrimary">#3F65AD</item>
<item name="colorSecondary">#9AD141</item>
</style>
<style name="ThemeOverlay_Accent_Orange" parent="">
<item name="colorPrimary">#D32F2F</item>
<item name="colorSecondary">#EF9A9A</item>
</style>
<style name="ThemeOverlay_Accent_Green" parent="">
<item name="colorPrimary">#4CAF50</item>
<item name="colorSecondary">#80CBC4</item>
</style>
<!-- Pitch black overlay — forces pure black surfaces for OLED displays -->
<style name="ThemeOverlay_PitchBlack" parent="">
<item name="android:colorBackground">#000000</item>
<item name="colorSurface">#000000</item>
<item name="colorSurfaceVariant">#0d0d0d</item>
<item name="colorSurfaceContainer">#0d0d0d</item>
<item name="colorSurfaceContainerLow">#000000</item>
<item name="colorSurfaceContainerLowest">#000000</item>
<item name="colorSurfaceContainerHigh">#1a1a1a</item>
<item name="colorSurfaceContainerHighest">#1a1a1a</item>
</style>
</resources>