Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe67aba6e
|
||
|
|
5cba468781
|
||
|
|
ed2054fb81
|
||
|
|
e9583f0580
|
||
|
|
a32841a319
|
||
|
|
7a66dd836c
|
||
|
|
68dd49b90c
|
||
|
|
8d09e760a8
|
||
|
|
62ccae602d
|
||
|
|
9011ef2f5a
|
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Thijooree Debug</string>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 468 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 36 KiB |
@@ -264,6 +264,13 @@ class LockActivity : AppCompatActivity() {
|
||||
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||
finish()
|
||||
} else {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -923,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())
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
companion object {
|
||||
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
||||
"51" -> "cards/mib/faisa.png"
|
||||
"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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -208,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>
|
||||
|
||||