Compare commits

...

14 Commits

Author SHA1 Message Date
ahusan 4fe67aba6e contacts: paginate MIB contact loading + cache progressively
The old refresh path called MibContactsClient.fetchContacts() per
profile, which greedily walked every page and stuffed the union into
the contacts cache in one shot. A user with several hundred or several
thousand MIB beneficiaries paid for N round-trips before the screen
could render, and the cache write encrypted the whole blob each time.

Introduces a paginator that streams contacts on demand:

  * MibContactsClient.fetchContactsPage(session, start, count) — single
    page; fetchContacts() now layers on top for callers that want all
    pages.

  * MibContactsPaginator — walks the user's MIB profiles serially under
    the existing mibMutex, returning one page per nextPage() call.
    Categories are accumulated across calls.

  * HomeActivity.refreshContacts now requests just the first page
    (default 30 contacts) per login. Subsequent pages arrive via
    HomeActivity.loadMoreMibContacts(), which ContactsFragment fires
    from a scroll listener when the user is within 5 rows of the end
    of any tab. NEXT_PAGE_SIZE is 100 — matches the API's natural
    page size.

  * ContactsCache.appendContacts merges a new page into the cached
    list (dedupe by benefNo) instead of overwriting. The first page
    of a refresh still uses save() so deletions take effect.

  * HomeViewModel exposes mibContactsLoading / mibContactsHasMore
    so the fragment can show a loading footer (item_loading_footer)
    on each contact tab while a page is in flight.

  * ContactsAdapter grows a showLoadingFooter flag and a second view
    type for the footer row.

Search remains client-side over the loaded set — results may be
partial until the user keeps scrolling. The MIB API supports
server-side search via the `search` parameter but wiring it in is a
separate change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:27:17 +05:00
shihaam 5cba468781 debug builds has debug suffix and different launcher icon color 2026-05-28 17:11:55 +05:00
shihaam ed2054fb81 fixbug that took user to empty dashboard without any accounts 2026-05-28 16:32:06 +05:00
shihaam e9583f0580 Merge pull request 'fix/accounts-list-balance-consistency' (#5) from ahusan/fksar:fix/accounts-list-balance-consistency into main
Reviewed-on: shihaam/thijooree#5
2026-05-28 16:23:33 +05:00
shihaam a32841a319 compress images 2026-05-28 15:58:26 +05:00
shihaam 7a66dd836c add faisawear images (intergration later) 2026-05-28 15:57:23 +05:00
shihaam 68dd49b90c rename fisa to fisa card 2026-05-28 15:55:49 +05:00
shihaam 76090525e1 add binga mvr usd and fiasa cards 2026-05-28 15:33:49 +05:00
shihaam f7fd06cdf3 Unified card settings and pay with card into 1 page and redsigned it 2026-05-28 15:28:45 +05:00
ahusan 8d09e760a8 Enhance dashboard: add attention row for blocked and overdue funds
Introduces a new attention row in the dashboard to display blocked funds and overdue financing. The row is conditionally visible based on the presence of blocked amounts or overdue totals. Updates the account display logic to show blocked amounts where applicable, ensuring users have a clear view of their financial status. Additionally, new string resources for "Blocked Funds" and "Overdue Financing" are added for localization.
2026-05-28 15:24:49 +05:00
ahusan 62ccae602d accounts list: use available balance, show blocked amount as secondary line
BML CASA rows on the accounts list were showing currentBalance (the
working/ledger balance, which includes blocked funds). Every other
balance display in the app — transfer screen, contact picker, QR pay,
dashboard totals — uses availableBalance, so the same account was
showing a different figure depending on where you looked at it.

This switches the accounts list to availableBalance for consistency,
and adds a small muted "MVR X.XX blocked" line beneath the balance
when blocked > 0 so the blocked funds are still visible at a glance.
Only BML reports a non-zero blocked amount; MIB and Fahipay rows are
unaffected.

The per-account history page header is untouched — its three-column
Available / Balance / Blocked breakdown still works as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:54:33 +05:00
ahusan 9011ef2f5a debug builds: separate applicationId so they coexist with release
Adds applicationIdSuffix=.debug and versionNameSuffix=-debug so a
side-loaded debug build can be installed alongside the Play/release
build without conflicting on package id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:53:45 +05:00
shihaam dd620763ec new feature: add launcher shortcuts 2026-05-28 14:06:49 +05:00
shihaam 86063d600f Fix Bug that allowed lockscreen bypass on rooted androids 2026-05-28 13:41:39 +05:00
84 changed files with 1275 additions and 346 deletions
+4
View File
@@ -27,6 +27,10 @@ android {
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#CC0000"
android:pathData="M0,0h108v108h-108z" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Thijooree Debug</string>
</resources>
+4 -1
View File
@@ -10,7 +10,7 @@
<application
android:name=".BasedBankApp"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
@@ -30,6 +30,9 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

+1
View File
@@ -0,0 +1 @@
visa_bingaa.png
+1
View File
@@ -0,0 +1 @@
visa_bingaa.png
Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 36 KiB

@@ -16,6 +16,13 @@ import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() {
/**
* Set to true only after the user passes LockActivity or completes fresh login.
* Resets to false on every process restart so direct ADB/root activity launches
* cannot reach HomeActivity without re-authenticating.
*/
var isUnlocked = false
// Held in memory after successful login; cleared on logout
var accounts: List<BankAccount> = emptyList()
var fullName: String = ""
@@ -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.BasedBankApp
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
@@ -259,10 +260,23 @@ class LockActivity : AppCompatActivity() {
}
private fun proceed() {
(application as BasedBankApp).isUnlocked = true
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
finish()
} else {
startActivity(Intent(this, HomeActivity::class.java))
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
if (!hasCredentials) {
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
finish()
return
}
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
startActivity(Intent(this, HomeActivity::class.java).apply {
if (navDest != -1) putExtra("nav_destination", navDest)
if (autoScan) putExtra("auto_scan", true)
})
finish()
}
}
@@ -7,24 +7,43 @@ import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val onboardingDone = prefs.getBoolean("onboarding_done", false)
val securitySet = prefs.getString("security_method", null) != null
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
val navDestination = when (intent?.action) {
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
else -> -1
}
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
val target = when {
!onboardingDone -> OnboardingActivity::class.java
!hasCredentials -> LoginActivity::class.java
securitySet -> LockActivity::class.java // proceed() → HomeActivity
else -> HomeActivity::class.java
}
startActivity(Intent(this, target))
// No lock screen configured — mark as unlocked so HomeActivity's guard passes
if (target == HomeActivity::class.java) {
(application as BasedBankApp).isUnlocked = true
}
startActivity(Intent(this, target).apply {
if (navDestination != -1) putExtra("nav_destination", navDestination)
if (autoScan) putExtra("auto_scan", true)
})
finish()
}
}
@@ -61,38 +61,51 @@ class MibContactsClient {
}
}
/** Fetch a single page of contacts. Returns the list and the server-reported total count.
* [start] is 1-based; [count] is the desired page size. */
fun fetchContactsPage(
session: MibSession,
start: Int,
count: Int
): Pair<List<MibBeneficiary>, Int> {
val end = start + count - 1
// Server uses 100-row pages internally for its own pagination; we mirror that
// by computing a page index that aligns with our start offset.
val page = ((start - 1) / 100) + 1
val body = FormBody.Builder()
.add("page", page.toString())
.add("search", "")
.add("searchCategoryId", "0")
.add("benefType", "A")
.add("sortBenef", "name")
.add("sortDir", "asc")
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/main")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return Pair(emptyList(), 0)
parseContacts(response.body?.string() ?: return Pair(emptyList(), 0))
}
}
/** Convenience: fetch all pages and return the union. Used by callers that don't paginate. */
fun fetchContacts(session: MibSession): List<MibBeneficiary> {
val all = mutableListOf<MibBeneficiary>()
var page = 1
var start = 1
val pageSize = 100
while (true) {
val start = (page - 1) * pageSize + 1
val end = page * pageSize
val body = FormBody.Builder()
.add("page", page.toString())
.add("search", "")
.add("searchCategoryId", "0")
.add("benefType", "A")
.add("sortBenef", "name")
.add("sortDir", "asc")
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/main")
.post(body)
.withSessionHeaders(session)
.build()
val (contacts, totalCount) = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return all
parseContacts(response.body?.string() ?: return all)
}
val (contacts, totalCount) = fetchContactsPage(session, start, pageSize)
all.addAll(contacts)
if (all.size >= totalCount || contacts.isEmpty()) break
page++
start += pageSize
}
return all
}
@@ -0,0 +1,80 @@
package sh.sar.basedbank.api.mib
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
/**
* Streams MIB contacts one page at a time, walking the user's MIB profiles in order.
*
* For multi-profile users we stay on the current profile until its server-reported
* `total_count` is reached, then advance to the next profile. Each profile switch is
* protected by the global `mibMutex` to keep the session state coherent.
*
* Categories are fetched lazily on the first page of each profile and accumulated
* across calls — the latest snapshot is exposed via [allCategories].
*/
class MibContactsPaginator(
private val app: BasedBankApp,
val loginId: String,
private val session: MibSession,
private val profiles: List<MibProfile>,
private val client: MibContactsClient = MibContactsClient()
) {
private var profileIndex = 0
private var nextStart = 1
private var totalForCurrentProfile = -1
private var categoriesFetchedForProfile = false
private val accumulatedCategories = LinkedHashMap<String, BankContactCategory>()
val allCategories: List<BankContactCategory>
get() = accumulatedCategories.values.toList()
fun hasMore(): Boolean = profileIndex < profiles.size
/** Fetch one page of up to [count] contacts. Returns the contacts contributed by
* this call (may be empty if a profile errored or returned no rows). Advances
* the internal cursor; subsequent calls continue from where this left off. */
suspend fun nextPage(count: Int): List<BankContact> = withContext(Dispatchers.IO) {
if (!hasMore()) return@withContext emptyList()
val profile = profiles[profileIndex]
val flow = app.mibFlowFor(loginId)
val contacts = app.mibMutex.withLock {
try {
flow.switchProfile(session, profile)
if (!categoriesFetchedForProfile) {
for (cat in client.fetchCategories(session)) {
accumulatedCategories.putIfAbsent(
cat.id,
BankContactCategory(cat.id, cat.categoryName, cat.numBenef)
)
}
categoriesFetchedForProfile = true
}
val (page, total) = client.fetchContactsPage(session, nextStart, count)
if (total > 0) totalForCurrentProfile = total
nextStart += page.size.coerceAtLeast(count)
page.map { it.copy(profileId = profile.profileId) }
} catch (_: Exception) {
// Bail on this profile and advance — keeps the paginator robust.
emptyList()
}
}
val exhausted =
(totalForCurrentProfile > 0 && nextStart - 1 >= totalForCurrentProfile) ||
contacts.isEmpty()
if (exhausted) {
profileIndex++
nextStart = 1
totalForCurrentProfile = -1
categoriesFetchedForProfile = false
}
contacts
}
}
@@ -9,6 +9,7 @@ import org.json.JSONObject
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.random.Random
class SessionExpiredException : Exception("MIB session expired")
@@ -168,7 +169,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
@@ -188,6 +189,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
// ─── Helpers ─────────────────────────────────────────────────────────────
/** MIB returns blockedAmount as a signed decimal where negative = funds held.
* Normalize to a positive magnitude so downstream code can treat it uniformly. */
private fun absBlockedAmount(raw: String): String {
val v = raw.toDoubleOrNull() ?: return raw
return "%.2f".format(abs(v))
}
private fun initialKeyExchange(
appId: String, encKey: String, sfunc: String, key2: String? = null
): Pair<MibSession, String> {
@@ -325,7 +333,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
@@ -125,6 +125,14 @@ class AccountsAdapter(
binding.tvAccountNumber.text = display.number
binding.tvAccountType.text = display.typeLabel
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
val blocked = display.blockedBalance
if (blocked != null) {
val shown = if (hideAmounts) maskAmount(blocked) else blocked
binding.tvBlocked.text = binding.root.context.getString(R.string.account_blocked_label, shown)
binding.tvBlocked.visibility = View.VISIBLE
} else {
binding.tvBlocked.visibility = View.GONE
}
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener {
@@ -1,139 +1,3 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
import sh.sar.basedbank.util.bmlapi.BmlCardParser
class CardSettingsFragment : Fragment() {
private var _binding: FragmentCardSettingsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardSettingsAdapter(emptyList(), requireContext())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
}
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) }
val all = mibItems + bmlItems
adapter.update(all)
binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
if (viewModel.mibCards.value == null) {
binding.loadingView.visibility = View.VISIBLE
(activity as? HomeActivity)?.triggerRefreshCards()
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_card_settings)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class CardSettingsAdapter(
private var cards: List<CardItem>,
private val context: Context
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
fun update(newCards: List<CardItem>) {
cards = newCards
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
private val btnBlock: View = view.findViewById(R.id.btnBlock)
fun bind(item: CardItem) {
when (item) {
is CardItem.Mib -> {
tvCardOwner.text = item.card.cardHolderName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
tvCardType.text = item.card.cardTypeDesc
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
tvCardType.text = item.account.accountTypeName
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
val bmlStatus = item.account.statusDesc.takeUnless { isActive }
PayWithCardFragment.bindCardStatus(tvCardStatus, bmlStatus)
itemView.alpha = if (isActive) 1f else 0.45f
}
}
val wip = View.OnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
btnChangePin.setOnClickListener(wip)
btnFreeze.setOnClickListener(wip)
btnBlock.setOnClickListener(wip)
}
}
}
}
// Merged into CardsFragment
@@ -8,6 +8,7 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
@@ -20,7 +21,7 @@ class ContactsAdapter(
private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<ContactDisplay> = emptyList()
@@ -28,6 +29,15 @@ class ContactsAdapter(
private var activeCategoryId: String? = null
private var searchQuery: String = ""
/** When true, a loading footer row is shown after the last contact. */
var showLoadingFooter: Boolean = false
set(value) {
if (field == value) return
field = value
if (value) notifyItemInserted(displayed.size)
else notifyItemRemoved(displayed.size)
}
fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts
applyFilter()
@@ -58,24 +68,32 @@ class ContactsAdapter(
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
override fun getItemViewType(position: Int): Int =
if (position == displayed.size && showLoadingFooter) VIEW_TYPE_FOOTER else VIEW_TYPE_CONTACT
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
if (viewType == VIEW_TYPE_FOOTER) {
val footer = LayoutInflater.from(parent.context)
.inflate(R.layout.item_loading_footer, parent, false)
return FooterHolder(footer)
}
val binding = ItemContactBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val holder = ViewHolder(binding)
binding.btnTransferContact.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
if (pos != RecyclerView.NO_POSITION && pos < displayed.size) onTransferClick(displayed[pos])
}
binding.btnEditContact.setOnClickListener {
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnDeleteContact.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
if (pos != RecyclerView.NO_POSITION && pos < displayed.size) onDeleteClick(displayed[pos])
}
binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
if (pos == RecyclerView.NO_POSITION || pos >= displayed.size) return@setOnLongClickListener false
val account = displayed[pos].accountNumber
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
@@ -86,15 +104,23 @@ class ContactsAdapter(
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is FooterHolder) return
val contact = displayed[position]
val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage)
(holder as ViewHolder).bind(contact, cachedImage)
}
override fun getItemCount() = displayed.size
override fun getItemCount() = displayed.size + if (showLoadingFooter) 1 else 0
private class FooterHolder(view: View) : RecyclerView.ViewHolder(view)
private companion object {
const val VIEW_TYPE_CONTACT = 0
const val VIEW_TYPE_FOOTER = 1
}
inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
@@ -76,6 +76,9 @@ class ContactsFragment : Fragment() {
fun updateImage(hash: String, bitmap: Bitmap) =
contactAdapters.forEach { it.updateImage(hash, bitmap) }
fun setLoadingFooter(visible: Boolean) =
contactAdapters.forEach { it.showLoadingFooter = visible }
inner class PageHolder(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
override fun getItemCount() = pages.size
@@ -93,6 +96,7 @@ class ContactsFragment : Fragment() {
val p80 = (65 * density).toInt()
setPadding(0, p4, 0, p80)
adapter = contactAdapters[viewType]
addOnScrollListener(scrollListener)
}
return PageHolder(rv)
}
@@ -100,6 +104,24 @@ class ContactsFragment : Fragment() {
override fun onBindViewHolder(holder: PageHolder, position: Int) {}
}
/** Triggers paginated MIB contact loading when the user scrolls within
* LOAD_MORE_THRESHOLD rows of the end of any tab. */
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0) return
val lm = rv.layoutManager as? LinearLayoutManager ?: return
val total = lm.itemCount
if (total == 0) return
if (lm.findLastVisibleItemPosition() >= total - LOAD_MORE_THRESHOLD) {
(activity as? HomeActivity)?.loadMoreMibContacts()
}
}
}
private companion object {
const val LOAD_MORE_THRESHOLD = 5
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
return binding.root
@@ -149,6 +171,15 @@ class ContactsFragment : Fragment() {
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
// Show a loading footer on each contact list while more MIB pages are arriving.
val updateFooter: () -> Unit = {
val loading = viewModel.mibContactsLoading.value == true
val hasMore = viewModel.mibContactsHasMore.value == true
pagerAdapter.setLoadingFooter(loading && hasMore)
}
viewModel.mibContactsLoading.observe(viewLifecycleOwner) { updateFooter() }
viewModel.mibContactsHasMore.observe(viewLifecycleOwner) { updateFooter() }
}
private fun attachMediator(pages: List<TabPage>) {
@@ -165,6 +196,10 @@ class ContactsFragment : Fragment() {
}
val savedPosition = binding.viewPager.currentItem
pagerAdapter = ContactsPagerAdapter(pages)
pagerAdapter.setLoadingFooter(
(viewModel.mibContactsLoading.value == true) &&
(viewModel.mibContactsHasMore.value == true)
)
binding.viewPager.adapter = pagerAdapter
attachMediator(pages)
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
@@ -57,14 +57,24 @@ class DashboardFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.accounts.observe(viewLifecycleOwner) {
updateBalances(it)
updateAttentionRow()
}
viewModel.financing.observe(viewLifecycleOwner) {
updatePendingFinances()
updateAttentionRow()
}
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) {
updatePendingFinances()
updateAttentionRow()
}
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances()
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
updateAttentionRow()
}
binding.swipeRefresh.setOnRefreshListener {
@@ -76,6 +86,10 @@ class DashboardFragment : Fragment() {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
}
binding.cardOverdue.setOnClickListener {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
}
val cardAdapter = DashboardCardAdapter()
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = cardAdapter
@@ -112,6 +126,12 @@ class DashboardFragment : Fragment() {
private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) {
binding.buttonBar.visibility = View.GONE
return
}
binding.buttonBar.visibility = View.VISIBLE
val ids = NavCustomization.getQuickActions(prefs)
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
@@ -244,6 +264,56 @@ class DashboardFragment : Fragment() {
}
}
private fun updateAttentionRow() {
val hide = viewModel.hideAmounts.value ?: false
val accounts = viewModel.accounts.value ?: emptyList()
// Blocked: sum across CASA-style accounts (exclude cards and loans) per currency.
val blockedByCurrency = accounts
.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_PREPAID" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
.mapNotNull { acc ->
val v = acc.blockedAmount.replace(",", "").toDoubleOrNull() ?: 0.0
if (v > 0.0) acc.currencyName.uppercase() to v else null
}
.groupBy({ it.first }, { it.second })
.mapValues { (_, vs) -> vs.sum() }
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
} else {
binding.cardBlocked.visibility = View.GONE
}
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
val bmlOverdue = (viewModel.bmlLoanDetails.value ?: emptyMap()).values.sumOf { it.overdueAmount }
val overdueTotal = mibOverdue + bmlOverdue
if (overdueTotal > 0.0) {
binding.tvOverdueTotal.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(overdueTotal)
binding.cardOverdue.visibility = View.VISIBLE
} else {
binding.cardOverdue.visibility = View.GONE
}
binding.rowAttention.visibility =
if (blockedTotal > 0.0 || overdueTotal > 0.0) View.VISIBLE else View.GONE
}
private fun updatePendingFinances() {
val hide = viewModel.hideAmounts.value ?: false
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
@@ -287,17 +357,17 @@ class DashboardFragment : Fragment() {
when (item) {
is CardItem.Mib -> {
tvCardOwner.text = item.card.cardHolderName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
tvCardNumber.text = CardsFragment.formatMasked(item.card.maskedCardNumber)
val assetPath = CardsFragment.cardImageAsset(item.card)
if (assetPath != null) CardsFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
CardsFragment.bindCardStatus(tvCardStatus, CardsFragment.mibCardStatusLabel(item.card.cardStatus))
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
tvCardNumber.text = CardsFragment.formatMasked(item.account.accountNumber)
CardsFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
CardsFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
}
}
val isMib = item is CardItem.Mib
@@ -57,6 +57,7 @@ import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibContactsPaginator
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfile
@@ -73,6 +74,10 @@ class HomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityHomeBinding
private val viewModel: HomeViewModel by viewModels()
/** One paginator per MIB login. Reset on every refresh. */
private var mibContactPaginators: List<MibContactsPaginator> = emptyList()
private var mibLoadMoreInFlight = false
private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
@@ -118,6 +123,18 @@ class HomeActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
// 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.
val app = application as BasedBankApp
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
startActivity(
android.content.Intent(this, sh.sar.basedbank.LockActivity::class.java)
)
finish()
return
}
setSupportActionBar(binding.toolbar)
toggle = ActionBarDrawerToggle(
@@ -147,8 +164,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> PayWithCardFragment()
R.id.nav_card_settings -> CardSettingsFragment()
R.id.nav_pay_with_card -> CardsFragment()
else -> null
}
if (frag != null) show(frag)
@@ -165,7 +181,6 @@ class HomeActivity : AppCompatActivity() {
}
// Load data
val app = application as BasedBankApp
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
@@ -218,10 +233,19 @@ class HomeActivity : AppCompatActivity() {
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
// Show dashboard on first create
// Show dashboard on first create, or navigate to shortcut destination
if (savedInstanceState == null) {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
if (navDest != -1) {
val fragment = if (autoScan && navDest == R.id.nav_transfer)
TransferFragment.newInstanceWithAutoScan()
else null
navigateTo(navDest, fragment)
} else {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
@@ -359,8 +383,7 @@ fun applyNavLabelVisibility() {
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> PayWithCardFragment()
R.id.nav_card_settings -> CardSettingsFragment()
R.id.nav_pay_with_card -> CardsFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
}
show(dest)
@@ -905,44 +928,78 @@ fun applyNavLabelVisibility() {
return result
}
/** Spin up (or replace) the paginator for this MIB login and fetch the first page.
* Subsequent pages arrive via [loadMoreMibContacts] in response to user scrolling. */
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient()
val paginator = MibContactsPaginator(
app = application as BasedBankApp,
loginId = loginId,
session = session,
profiles = profiles
)
// Replace any existing paginator for this login.
mibContactPaginators = mibContactPaginators.filter { it.loginId != loginId } + paginator
viewModel.mibContactsHasMore.value = mibContactPaginators.any { it.hasMore() }
lifecycleScope.launch {
try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>()
val seenCategories = mutableSetOf<String>()
val contacts = mutableListOf<BankContact>()
val categories = mutableListOf<BankContactCategory>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (cat in contactsClient.fetchCategories(session)) {
if (seenCategories.add(cat.id)) categories.add(cat)
}
for (contact in contactsClient.fetchContacts(session)) {
if (seenContacts.add(contact.benefNo))
contacts.add(contact.copy(profileId = profile.profileId))
}
} catch (_: Exception) { /* profile has no contacts access */ }
}
Pair(contacts, categories)
}
if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, store.getBmlLoginIds())
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
viewModel.contactCategories.postValue(allCategories + fahipayCategories)
}
} catch (_: Exception) { /* keep cached data */ }
viewModel.mibContactsLoading.postValue(true)
val firstPage = try {
paginator.nextPage(MIB_FIRST_PAGE_SIZE)
} catch (_: Exception) {
null
}
// Treat a successful refresh as authoritative: replace the cached MIB list
// with the first page, even if it shrinks (so deletions take effect).
if (firstPage != null) {
ContactsCache.save(this@HomeActivity, firstPage, paginator.allCategories)
publishContacts(paginator.allCategories)
}
viewModel.mibContactsLoading.postValue(false)
viewModel.mibContactsHasMore.postValue(mibContactPaginators.any { it.hasMore() })
}
}
/** Called by ContactsFragment when the user scrolls near the end of the loaded list. */
fun loadMoreMibContacts() {
if (mibLoadMoreInFlight) return
val paginator = mibContactPaginators.firstOrNull { it.hasMore() } ?: return
mibLoadMoreInFlight = true
lifecycleScope.launch {
viewModel.mibContactsLoading.postValue(true)
val page = try {
paginator.nextPage(MIB_NEXT_PAGE_SIZE)
} catch (_: Exception) {
emptyList()
}
if (page.isNotEmpty()) {
ContactsCache.appendContacts(this@HomeActivity, page, paginator.allCategories)
publishContacts(paginator.allCategories)
}
mibLoadMoreInFlight = false
viewModel.mibContactsLoading.postValue(false)
viewModel.mibContactsHasMore.postValue(mibContactPaginators.any { it.hasMore() })
}
}
/** Merge the current MIB cache with BML and Fahipay caches and post to the view model. */
private fun publishContacts(mibCategories: List<BankContactCategory>) {
val store = sh.sar.basedbank.util.CredentialStore(this)
val mibContacts = ContactsCache.loadContacts(this)
val bmlContacts = ContactsCache.loadBml(this, store.getBmlLoginIds())
val fahipayContacts = ContactsCache.loadFahipay(this)
val fahipayCategories = ContactsCache.loadFahipayCategories(this)
viewModel.contacts.postValue(
mergeContacts(mergeContacts(mibContacts, bmlContacts), fahipayContacts)
)
viewModel.contactCategories.postValue(mibCategories + fahipayCategories)
}
companion object {
private const val MIB_FIRST_PAGE_SIZE = 30
private const val MIB_NEXT_PAGE_SIZE = 100
}
fun refreshBalances(src: BankAccount) {
val app = application as BasedBankApp
lifecycleScope.launch {
@@ -23,6 +23,11 @@ class HomeViewModel : ViewModel() {
val contacts = MutableLiveData<List<BankContact>>(emptyList())
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
/** True while MIB contacts are paging in (initial load or scroll-triggered next page). */
val mibContactsLoading = MutableLiveData<Boolean>(false)
/** True while any MIB paginator still has pages to deliver. */
val mibContactsHasMore = MutableLiveData<Boolean>(false)
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
@@ -25,7 +25,6 @@ object NavCustomization {
NavItemDef(R.id.nav_transfer_history, "nav_transfer_history", R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
NavItemDef(R.id.nav_finances, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
NavItemDef(R.id.nav_pay_with_card, "nav_pay_with_card", R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
NavItemDef(R.id.nav_card_settings, "nav_card_settings", R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
NavItemDef(R.id.nav_settings, "nav_settings", R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
)
@@ -3,13 +3,14 @@ package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -18,20 +19,25 @@ import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import kotlin.math.abs
class PayWithCardFragment : Fragment() {
class CardsFragment : Fragment() {
private var _binding: FragmentPayWithCardBinding? = null
private var _binding: FragmentCardsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var cards: List<CardItem> = emptyList()
private var currentCardPosition: Int = 0
private var cardWidth: Int = 0
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -49,39 +55,67 @@ class PayWithCardFragment : Fragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
_binding = FragmentCardsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardWalletAdapter(emptyList(), requireContext())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val screenW = resources.displayMetrics.widthPixels
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val stackAdapter = CardStackAdapter(cardWidth)
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = stackAdapter
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
binding.rvCards.clipToPadding = false
val snapHelper = PagerSnapHelper()
snapHelper.attachToRecyclerView(binding.rvCards)
binding.rvCards.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
applyCardScales()
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
val lm = recyclerView.layoutManager ?: return
val snapView = snapHelper.findSnapView(lm) ?: return
val position = lm.getPosition(snapView)
if (position >= 0) {
currentCardPosition = position
buildDots(cards.size, position)
updateCardInfo(position)
}
applyCardScales()
}
}
})
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
v.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + extraBottom)
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
}
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") && it.statusDesc.equals("Active", ignoreCase = true) }
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
adapter.update(all)
cards = mibItems + bmlItems
stackAdapter.update(cards)
binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
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() }
@@ -93,6 +127,84 @@ class PayWithCardFragment : Fragment() {
binding.loadingView.visibility = View.VISIBLE
}
(activity as? HomeActivity)?.triggerRefreshCards()
binding.btnScanToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
val nfcAvailable = android.nfc.NfcAdapter.getDefaultAdapter(requireContext()) != null
binding.btnTapToPay.isEnabled = nfcAvailable
binding.btnTapToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
val wip = View.OnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnChangePin.setOnClickListener(wip)
binding.btnFreeze.setOnClickListener(wip)
binding.btnBlock.setOnClickListener(wip)
}
private fun applyCardScales() {
val rv = binding.rvCards
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
val lm = rv.layoutManager as? LinearLayoutManager ?: return
val first = lm.findFirstVisibleItemPosition()
val last = lm.findLastVisibleItemPosition()
if (first < 0) return
for (i in first..last) {
val child = lm.findViewByPosition(i) ?: continue
val childCenter = (child.left + child.right) / 2f
val fraction = (abs(childCenter - rvCenter) / cardWidth.toFloat()).coerceIn(0f, 1f)
val scale = 1f - 0.18f * fraction
child.scaleX = scale
child.scaleY = scale
child.alpha = 1f - 0.4f * fraction
}
}
private fun buildDots(count: Int, selected: Int) {
binding.pageIndicator.removeAllViews()
if (count <= 1) {
binding.pageIndicator.visibility = View.GONE
return
}
binding.pageIndicator.visibility = View.VISIBLE
val dp = resources.displayMetrics.density
val activeColor = MaterialColors.getColor(
requireContext(), com.google.android.material.R.attr.colorPrimary, Color.GRAY)
val inactiveColor = MaterialColors.getColor(
requireContext(), com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
val size = (8 * dp).toInt()
val margin = (4 * dp).toInt()
repeat(count) { i ->
val dot = View(requireContext())
dot.layoutParams = LinearLayout.LayoutParams(size, size).apply {
setMargins(margin, 0, margin, 0)
}
dot.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(if (i == selected) activeColor else inactiveColor)
}
binding.pageIndicator.addView(dot)
}
}
private fun updateCardInfo(position: Int) {
val item = cards.getOrNull(position) ?: return
binding.tvSelectedCardType.text = when (item) {
is CardItem.Mib -> item.card.cardTypeDesc
is CardItem.Bml -> item.account.accountTypeName
}
}
override fun onResume() {
@@ -105,83 +217,78 @@ class PayWithCardFragment : Fragment() {
_binding = null
}
private inner class CardWalletAdapter(
private var cards: List<CardItem>,
private val context: Context
) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
private var items: List<CardItem> = emptyList()
fun update(newCards: List<CardItem>) {
cards = newCards
fun update(newItems: List<CardItem>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false))
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(parent.context).inflate(R.layout.item_card_stack, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
// Pre-scale based on data position so initial render and off-screen cards are correct
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
val scale = 1f - 0.18f * fraction
holder.itemView.scaleX = scale
holder.itemView.scaleY = scale
holder.itemView.alpha = 1f - 0.4f * fraction
}
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
init {
itemView.layoutParams = RecyclerView.LayoutParams(cardWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
}
fun bind(item: CardItem) {
when (item) {
is CardItem.Mib -> {
tvCardOwner.text = item.card.cardHolderName
tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
tvCardType.text = item.card.cardTypeDesc
val assetPath = cardImageAsset(item.card)
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = formatMasked(item.account.accountNumber)
tvCardType.text = item.account.accountTypeName
loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
val bmlStatus = item.account.statusDesc.takeUnless { it.equals("Active", ignoreCase = true) }
bindCardStatus(tvCardStatus, bmlStatus)
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
bindCardStatus(tvCardStatus, item.account.statusDesc.takeUnless { isActive })
itemView.alpha = if (isActive) 1f else 0.45f
}
}
val isMib = item is CardItem.Mib
btnPayQr.setOnClickListener {
if (isMib) {
Toast.makeText(context, R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
val nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported
btnPayNfc.setOnClickListener {
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
}
}
companion object {
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"51" -> "cards/mib/faisa_card.png"
"53" -> "cards/mib/visa_black_platinum.png"
"57" -> "cards/mib/visa_blue_everyday.png"
"70" -> "cards/mib/visa_business.png"
"701" -> "cards/mib/visa_bingaa_mvr.png"
"702" -> "cards/mib/visa_bingaa_usd.png"
else -> null
}
fun loadCardImage(imageView: ImageView, assetPath: String) {
try {
val bitmap = imageView.context.assets.open(assetPath).use {
BitmapFactory.decodeStream(it)
android.graphics.BitmapFactory.decodeStream(it)
}
imageView.setImageBitmap(bitmap)
} catch (_: Exception) {
@@ -190,8 +297,8 @@ class PayWithCardFragment : Fragment() {
}
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
"CHST0" -> null // Active — no badge
else -> cardStatus
"CHST0" -> null
else -> cardStatus
}
fun bindCardStatus(tv: TextView, statusLabel: String?) {
@@ -37,8 +37,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
import sh.sar.basedbank.util.CredentialStore
import java.util.concurrent.Executors
class QrScannerActivity : AppCompatActivity() {
@@ -95,6 +97,14 @@ class QrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (CredentialStore(this).loadSecurityHash() != null &&
!(application as BasedBankApp).isUnlocked) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
finish()
return
}
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -54,19 +54,29 @@ class SettingsAppearanceFragment : Fragment() {
// Quick actions
quickActions.clear()
quickActions.addAll(NavCustomization.getQuickActions(prefs))
quickActionAdapter = NavItemAdapter(quickActions) {
NavCustomization.saveQuickActions(prefs, quickActions)
quickActionAdapter = NavItemAdapter(
items = quickActions,
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
)
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
!prefs.getBoolean("bottom_nav", false)
}
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
// Bottom bar shortcuts
slots.clear()
slots.addAll(NavCustomization.getSlots(prefs))
slotAdapter = NavItemAdapter(slots) {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
slotAdapter = NavItemAdapter(
items = slots,
onSave = {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
},
isEnabled = { prefs.getBoolean("bottom_nav", false) }
)
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
prefs.getBoolean("bottom_nav", false)
}
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
binding.switchShowLabels.isChecked = showLabels
@@ -109,13 +119,18 @@ class SettingsAppearanceFragment : Fragment() {
private fun setupNavItemRecyclerView(
rv: RecyclerView,
adapter: NavItemAdapter,
items: MutableList<Int>
items: MutableList<Int>,
isEnabled: () -> Boolean
) {
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
rv.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.START or ItemTouchHelper.END, 0
) {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
if (!isEnabled()) return 0
return super.getMovementFlags(recyclerView, viewHolder)
}
override fun onMove(
rv: RecyclerView,
from: RecyclerView.ViewHolder,
@@ -134,11 +149,17 @@ class SettingsAppearanceFragment : Fragment() {
private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
binding.switchShowLabels.isClickable = isBottom
quickActionAdapter.notifyDataSetChanged()
slotAdapter.notifyDataSetChanged()
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
val isBottom = prefs.getBoolean("bottom_nav", false)
if (items === slots && !isBottom) return
if (items === quickActions && isBottom) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
@@ -169,7 +190,8 @@ class SettingsAppearanceFragment : Fragment() {
private inner class NavItemAdapter(
val items: MutableList<Int>,
val onSave: () -> Unit
val onSave: () -> Unit,
val isEnabled: () -> Boolean = { true }
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
@@ -191,7 +213,12 @@ class SettingsAppearanceFragment : Fragment() {
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
holder.ivNavIcon.setImageResource(def.iconRes)
holder.tvNavLabel.setText(def.titleRes)
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
val enabled = isEnabled()
holder.itemView.setOnClickListener(
if (enabled) View.OnClickListener { showItemPicker(items, holder.adapterPosition, this) }
else null
)
holder.itemView.isClickable = enabled
}
}
@@ -151,6 +151,11 @@ class TransferFragment : Fragment() {
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
private const val ARG_BML_QR_URL = "bml_qr_url"
private const val ARG_AUTO_SCAN = "auto_scan"
fun newInstanceWithAutoScan() = TransferFragment().apply {
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
}
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
arguments = Bundle().apply {
@@ -253,6 +258,10 @@ class TransferFragment : Fragment() {
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
arguments?.getString(ARG_BML_QR_URL)?.let { lookupBmlQrMerchant(it) }
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
private fun lookupBmlQrMerchant(qrUrl: String) {
@@ -215,6 +215,7 @@ class CredentialsFragment : Fragment() {
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
app.isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -364,6 +365,7 @@ class CredentialsFragment : Fragment() {
if (hasBusinessProfiles) {
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
}
(requireActivity().application as BasedBankApp).isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -496,6 +498,7 @@ class CredentialsFragment : Fragment() {
app.fahipaySessions[loginId] = session
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
app.isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -1,10 +1,14 @@
package sh.sar.basedbank.ui.login
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.databinding.ActivityLoginBinding
import sh.sar.basedbank.util.CredentialStore
class LoginActivity : AppCompatActivity() {
@@ -12,6 +16,16 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If security is configured and the user hasn't unlocked this session,
// they must authenticate first before being able to add more accounts.
val app = application as BasedBankApp
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
startActivity(Intent(this, LockActivity::class.java))
finish()
return
}
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -13,9 +13,12 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.CredentialStore
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
@@ -25,6 +28,15 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If security is already configured, onboarding is complete. Redirect to lock screen
// to prevent overwriting an existing PIN/pattern via direct activity launch.
if (CredentialStore(this).loadSecurityHash() != null) {
startActivity(Intent(this, LockActivity::class.java))
finish()
return
}
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -97,6 +109,9 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
binding.btnGetStarted.setOnClickListener {
prefs.edit().putBoolean("onboarding_done", true).apply()
// Mark as unlocked so LoginActivity doesn't redirect to LockActivity.
// The user just completed setup — they shouldn't have to re-authenticate immediately.
(application as BasedBankApp).isUnlocked = true
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
@@ -5,6 +5,7 @@ data class AccountListDisplay(
val number: String,
val typeLabel: String,
val balance: String,
val blockedBalance: String? = null, // null when zero or not applicable
val isCard: Boolean = false,
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
val statusLabel: String? = null // null = active; shown as status pill if set
@@ -51,6 +51,21 @@ object ContactsCache {
prefs.apply()
}
/** Merge new MIB contacts into the cached list, deduping by benefNo. Categories
* are replaced wholesale (the latest snapshot is canonical). Safe to call
* repeatedly as pages arrive. */
fun appendContacts(
context: Context,
newContacts: List<BankContact>,
categories: List<BankContactCategory>
) {
if (newContacts.isEmpty() && categories.isEmpty()) return
val existing = loadContacts(context)
val seen = existing.map { it.benefNo }.toHashSet()
val merged = existing + newContacts.filter { seen.add(it.benefNo) }
save(context, merged, categories)
}
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
@@ -26,11 +26,13 @@ object BmlDashboardParser {
statusLabel = if (isActive) null else account.statusDesc
)
} else {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = listBalance(account)
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = listBalance(account),
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
}
@@ -52,9 +54,9 @@ object BmlDashboardParser {
}
}
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
/** Balance shown in the accounts list — available balance, consistent with transfer/contact picker. */
fun listBalance(account: BankAccount): String =
"${account.currencyName} ${account.currentBalance}"
"${account.currencyName} ${account.availableBalance}"
fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
@@ -5,12 +5,16 @@ import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser {
fun displayData(account: BankAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}"
)
fun displayData(account: BankAccount): AccountListDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}",
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
@@ -5,13 +5,16 @@ import sh.sar.basedbank.util.AccountHistoryDisplay
object MibHistoryParser {
fun displayData(account: BankAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
fun displayData(account: BankAccount): AccountHistoryDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/seed_primary" />
<foreground android:drawable="@drawable/ic_shortcut_pay_card_fg" />
<monochrome android:drawable="@drawable/ic_shortcut_pay_card_fg" />
</adaptive-icon>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30"
android:scaleX="2"
android:scaleY="2">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/seed_primary" />
<foreground android:drawable="@drawable/ic_shortcut_scan_qr_fg" />
<monochrome android:drawable="@drawable/ic_shortcut_scan_qr_fg" />
</adaptive-icon>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30"
android:scaleX="2"
android:scaleY="2">
<path
android:fillColor="#FFFFFF"
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" />
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/seed_primary" />
<foreground android:drawable="@drawable/ic_shortcut_transfer_fg" />
<monochrome android:drawable="@drawable/ic_shortcut_transfer_fg" />
</adaptive-icon>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30"
android:scaleX="2"
android:scaleY="2">
<path
android:fillColor="#FFFFFF"
android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z" />
</group>
</vector>
+194
View File
@@ -0,0 +1,194 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<!-- Main content when cards exist -->
<LinearLayout
android:id="@+id/contentLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<!-- Top spacer: pushes card to vertical center -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Horizontal card stack. Width/padding set programmatically for centering + peek. -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:overScrollMode="never" />
<!-- Page indicator dots -->
<LinearLayout
android:id="@+id/pageIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:orientation="horizontal"
android:paddingTop="12dp"
android:paddingBottom="2dp" />
<!-- Selected card type / product name -->
<TextView
android:id="@+id/tvSelectedCardType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingHorizontal="16dp"
android:paddingTop="6dp"
android:paddingBottom="2dp"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="4dp"
android:background="?attr/colorOutlineVariant" />
<!-- Primary pay actions -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanToPay"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_pay_qr"
android:textSize="13sp"
app:icon="@drawable/ic_qr_scan"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTapToPay"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_pay_nfc"
android:textSize="13sp"
app:icon="@drawable/ic_nfc"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout>
<!-- Secondary card management actions -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingTop="4dp"
android:paddingBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangePin"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/card_action_change_pin"
android:textSize="11sp"
app:icon="@drawable/ic_edit"
app:iconSize="16dp"
app:iconPadding="3dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFreeze"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/card_action_freeze"
android:textSize="11sp"
app:icon="@drawable/ic_freeze"
app:iconSize="16dp"
app:iconPadding="3dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBlock"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/card_action_block"
android:textSize="11sp"
app:icon="@drawable/ic_block"
app:iconSize="16dp"
app:iconPadding="3dp" />
</LinearLayout>
</LinearLayout>
<!-- Loading state -->
<LinearLayout
android:id="@+id/loadingView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Empty state -->
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/cards_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
@@ -176,6 +176,103 @@
</LinearLayout>
<!-- Attention row: Blocked + Overdue (hidden when nothing applies) -->
<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/cardBlocked"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
<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:alpha="0.75"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOverdue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:clickable="true"
android:focusable="true"
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_overdue"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvOverdueTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Pending Finances card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardPendingFinances"
@@ -45,22 +45,30 @@
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Quick actions (always active) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvQuickActions"
<!-- Quick actions — shown only when drawer nav is active -->
<LinearLayout
android:id="@+id/sectionQuickActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never"
android:layout_marginBottom="16dp" />
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvQuickActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never" />
</LinearLayout>
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
<LinearLayout
+9
View File
@@ -72,6 +72,15 @@
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvBlocked"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="6dp"
app:cardCornerRadius="16dp"
app:cardElevation="6dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/ivCardImage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scaleType="fitCenter"
android:adjustViewBounds="true"
android:contentDescription="@string/nav_pay_with_card" />
<!-- Bottom gradient for text legibility -->
<View
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_gravity="bottom"
android:background="@drawable/bg_card_overlay_gradient" />
<!-- Status badge (top-right) -->
<TextView
android:id="@+id/tvCardStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:textSize="9sp"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:visibility="gone" />
<!-- Card owner name + masked number (bottom-left) -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tvCardOwner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:shadowColor="#80000000"
android:shadowDx="1"
android:shadowDy="1"
android:shadowRadius="3" />
<TextView
android:id="@+id/tvCardNumber"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="#CCFFFFFF"
android:fontFamily="monospace" />
</LinearLayout>
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
-3
View File
@@ -32,9 +32,6 @@
<item android:id="@+id/nav_pay_with_card"
android:icon="@drawable/ic_nav_card"
android:title="@string/nav_pay_with_card" />
<item android:id="@+id/nav_card_settings"
android:icon="@drawable/ic_nav_card"
android:title="@string/nav_card_settings" />
</group>
<group android:id="@+id/group_system" android:checkableBehavior="single">
-3
View File
@@ -15,9 +15,6 @@
<item android:id="@+id/nav_pay_with_card"
android:icon="@drawable/ic_nav_card"
android:title="@string/nav_pay_with_card" />
<item android:id="@+id/nav_card_settings"
android:icon="@drawable/ic_nav_card"
android:title="@string/nav_card_settings" />
<item android:id="@+id/nav_otp"
android:icon="@drawable/ic_nav_otp"
android:title="@string/nav_otp" />
+2 -3
View File
@@ -72,7 +72,8 @@
<string name="nav_activities">ހަރަކާތްތައް</string>
<string name="nav_transfer_history">ޓްރާންސެކްޝަން ތާރީހް</string>
<string name="nav_finances">ފައިނޭންސް</string>
<string name="nav_card_settings">ކާޑް ސެޓިންގ</string>
<string name="nav_pay_with_card">ކާޑްތައް</string>
<string name="nav_desc_pay_with_card">ކާޑް މެނޭޖްކޮށް ފައިސާ ދައްކާ</string>
<string name="nav_settings">ސެޓިންގ</string>
<string name="nav_desc_accounts">ހުރިހާ ބޭންކް އެކައުންޓްތައް ބަލާ</string>
<string name="nav_desc_contacts">ޓްރާންސްފަ ކޮންޓެކްޓްތައް މެނޭޖް ކުރޭ</string>
@@ -81,8 +82,6 @@
<string name="nav_desc_activities">ފަހުގެ ޓްރާންސްފަތައް ބަލާ</string>
<string name="nav_desc_transfer_history">އެކައުންޓް ތަކުގެ ޓްރާންސެކްޝަން ތާރީހް</string>
<string name="nav_desc_finances">ލޯން އަދި ފައިނޭންސިންގ</string>
<string name="nav_desc_pay_with_card">ކާޑް ބޭނުންކޮށް ފައިސާ ދައްކާ</string>
<string name="nav_desc_card_settings">ކާޑް ސެޓިންގ މެނޭޖް ކުރޭ</string>
<string name="nav_desc_otp">OTP ކޯޑް ތައްޔާރު ކުރޭ</string>
<string name="nav_desc_settings">އެޕްލިކޭޝަންގެ ތަރުތީބު</string>
<string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string>
+7 -5
View File
@@ -93,8 +93,7 @@
<string name="nav_desc_activities">View your recent transfers</string>
<string name="nav_desc_transfer_history">Full transaction history by account</string>
<string name="nav_desc_finances">Loans and financing overview</string>
<string name="nav_desc_pay_with_card">Make a payment using your card</string>
<string name="nav_desc_card_settings">Manage your card preferences</string>
<string name="nav_desc_pay_with_card">Manage and pay with your cards</string>
<string name="nav_desc_otp">Generate OTP codes for authentication</string>
<string name="nav_desc_settings">App preferences and configuration</string>
<string name="nav_open_drawer">Open navigation</string>
@@ -209,6 +208,9 @@
<string name="accounts">Accounts</string>
<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_overdue">Overdue Financing</string>
<!-- Transfer -->
<string name="transfer_tab_quick">Quick Transfer</string>
@@ -311,9 +313,9 @@
<string name="loan_rate_fmt">%.2f%%</string>
<!-- Cards -->
<string name="nav_pay_with_card">Pay with Card</string>
<string name="card_pay_qr">QR Pay</string>
<string name="card_pay_nfc">NFC Pay</string>
<string name="nav_pay_with_card">Cards</string>
<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_action_change_pin">Change PIN</string>
<string name="card_action_freeze">Freeze</string>
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="transfer"
android:enabled="true"
android:icon="@drawable/ic_shortcut_transfer"
android:shortcutShortLabel="@string/transfer"
android:shortcutLongLabel="@string/transfer">
<intent
android:action="sh.sar.basedbank.OPEN_TRANSFER"
android:targetPackage="sh.sar.basedbank"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="scan_qr"
android:enabled="true"
android:icon="@drawable/ic_shortcut_scan_qr"
android:shortcutShortLabel="@string/transfer_scan_qr"
android:shortcutLongLabel="@string/transfer_scan_qr">
<intent
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
android:targetPackage="sh.sar.basedbank"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="pay_with_card"
android:enabled="true"
android:icon="@drawable/ic_shortcut_pay_card"
android:shortcutShortLabel="@string/nav_pay_with_card"
android:shortcutLongLabel="@string/nav_pay_with_card">
<intent
android:action="sh.sar.basedbank.OPEN_PAY_WITH_CARD"
android:targetPackage="sh.sar.basedbank"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>