9 Commits
Author SHA1 Message Date
shihaam 105518e147 release version 1.0.6
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m52s
2026-05-21 23:24:14 +05:00
shihaam 38570615dd optmize dashboard (seperate credit section, bars for spending limits
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 23:23:45 +05:00
shihaam e82218e897 added support for BML loans
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 22:58:58 +05:00
shihaam 50150b826f remove auto lock off and optimize session keepalive for mib
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-21 22:31:58 +05:00
shihaam 2d705457f8 animate lock and eye icons in action bar (top)
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 01:37:37 +05:00
shihaam f03e23062b you can now hold to copy text from recipts even in full screen mode
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-21 01:04:23 +05:00
shihaam 58f1b9fd6f release v1.0.5
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m58s
2026-05-21 00:44:12 +05:00
shihaam 240d04ad74 Optmize business profile logins.. save privacy mode settings on launch
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 00:43:23 +05:00
shihaam fe507073b1 Optmize business profile logins.. save privacy mode settings on launch 2026-05-21 00:43:05 +05:00
38 changed files with 1717 additions and 458 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 5
versionName = "1.0.4" versionName = "1.0.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -49,6 +49,30 @@ class BmlAccountClient {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
val code = resp.code
val json = resp.body?.string() ?: return null
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val p = root.optJSONObject("payload") ?: return null
BmlLoanDetail(
loanAmount = p.optDouble("loanAmount", 0.0),
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
repayAmount = p.optDouble("repayAmount", 0.0),
intRate = p.optDouble("intRate", 0.0),
loanStatus = p.optString("loanStatus"),
startDate = p.optString("startDate"),
endDate = p.optString("endDate"),
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
overdueAmount = p.optDouble("overdueAmount", 0.0)
)
} catch (_: Exception) { null }
}
private fun parseDashboard( private fun parseDashboard(
json: String, json: String,
loginTag: String, loginTag: String,
@@ -61,6 +85,7 @@ class BmlAccountClient {
val casaAccounts = mutableListOf<BankAccount>() val casaAccounts = mutableListOf<BankAccount>()
val prepaidCards = mutableListOf<BankAccount>() val prepaidCards = mutableListOf<BankAccount>()
val loanAccounts = mutableListOf<BankAccount>()
for (i in 0 until dashboard.length()) { for (i in 0 until dashboard.length()) {
val item = dashboard.getJSONObject(i) val item = dashboard.getJSONObject(i)
@@ -91,6 +116,26 @@ class BmlAccountClient {
profileId = profileId, profileId = profileId,
internalId = internalId internalId = internalId
)) ))
} else if (accountType == "Loan") {
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
loanAccounts.add(BankAccount(
bank = "BML",
profileName = profileName,
profileType = "BML_LOAN",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(outstanding),
currentBalance = "%.2f".format(outstanding),
blockedAmount = "0.00",
mvrBalance = "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Card") { } else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false) val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue if (!isVisible) continue
@@ -119,6 +164,6 @@ class BmlAccountClient {
} }
} }
return casaAccounts + prepaidCards return casaAccounts + prepaidCards + loanAccounts
} }
} }
@@ -50,6 +50,18 @@ data class BmlTransferResult(
val errorMessage: String = "" val errorMessage: String = ""
) )
data class BmlLoanDetail(
val loanAmount: Double,
val outstandingAmt: Double, // negative as returned by API
val repayAmount: Double,
val intRate: Double,
val loanStatus: String,
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
val endDate: String,
val noOfRepayOverdue: Int,
val overdueAmount: Double
)
data class BmlForeignLimit( data class BmlForeignLimit(
val type: String, val type: String,
val used: Double, val used: Double,
@@ -68,6 +68,7 @@ class MibTransferClient {
.withWvHeaders(session) .withWvHeaders(session)
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code == 419) throw SessionExpiredException()
val bodyStr = response.body?.string() ?: "" val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null } val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) { if (json == null || !json.optBoolean("success")) {
@@ -118,6 +118,14 @@ class AccountHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.setRefreshing(true) (activity as? HomeActivity)?.setRefreshing(true)
loadNextPage() loadNextPage()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
} }
override fun onResume() { override fun onResume() {
@@ -135,6 +143,17 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
} }
private fun resetAndReload() {
allTransactions.clear()
pendingImageNames.clear()
pendingIconUrls.clear()
firstPageDone = false
fetcher = HistoryFetcher(account)
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
loadNextPage()
}
private fun loadNextPage() { private fun loadNextPage() {
if (isLoading || !fetcher.hasMore()) return if (isLoading || !fetcher.hasMore()) return
isLoading = true isLoading = true
@@ -153,6 +172,7 @@ class AccountHistoryFragment : Fragment() {
if (!firstPageDone) { if (!firstPageDone) {
firstPageDone = true firstPageDone = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
binding.swipeRefresh.isRefreshing = false
} }
if (transactions.isNotEmpty()) { if (transactions.isNotEmpty()) {
@@ -45,6 +45,11 @@ class AccountsFragment : Fragment() {
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) } viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) } viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
} }
override fun onResume() { override fun onResume() {
@@ -48,6 +48,7 @@ class ContactsFragment : Fragment() {
private var currentSearch: String = "" private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter private lateinit var pagerAdapter: ContactsPagerAdapter
private var contactsRefreshing = false
private data class TabPage(val categoryId: String?, val label: String) private data class TabPage(val categoryId: String?, val label: String)
@@ -134,6 +135,11 @@ class ContactsFragment : Fragment() {
(activity as? HomeActivity)?.loadAllContacts() (activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.setOnRefreshListener {
contactsRefreshing = true
(activity as? HomeActivity)?.loadAllContacts()
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
rebuildPager(cats) rebuildPager(cats)
} }
@@ -143,6 +149,10 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateContacts(allContacts) pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
if (contactsRefreshing) {
contactsRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
} }
} }
@@ -14,6 +14,7 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -30,14 +31,20 @@ class DashboardFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) } viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) } viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) { viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList()) updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances(viewModel.financing.value ?: emptyList()) updatePendingFinances()
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList()) updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
} }
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false) val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
@@ -70,57 +77,132 @@ class DashboardFragment : Fragment() {
private fun updateBalances(accounts: List<BankAccount>) { private fun updateBalances(accounts: List<BankAccount>) {
val hide = viewModel.hideAmounts.value ?: false val hide = viewModel.hideAmounts.value ?: false
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" }
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
if (hide) { if (hide) {
binding.tvMvrBalance.text = "MVR ••••••" binding.tvMvrBalance.text = "MVR ••••••"
binding.tvUsdBalance.text = "USD ••••••" binding.tvUsdBalance.text = "USD ••••••"
if (creditAccounts.isNotEmpty()) {
binding.rowCreditCards.visibility = View.VISIBLE
val hasMvrCredit = creditAccounts.any { it.currencyName.equals("MVR", ignoreCase = true) }
val hasUsdCredit = creditAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
binding.cardMvrCredit.visibility = if (hasMvrCredit) View.VISIBLE else View.GONE
binding.cardUsdCredit.visibility = if (hasUsdCredit) View.VISIBLE else View.GONE
binding.tvMvrCredit.text = "MVR ••••••"
binding.tvUsdCredit.text = "USD ••••••"
} else {
binding.rowCreditCards.visibility = View.GONE
}
return return
} }
val mvrTotal = accounts
val mvrTotal = nonCreditAccounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) } .filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 } .sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
val usdTotal = accounts val usdTotal = nonCreditAccounts
.filter { it.currencyName.equals("USD", ignoreCase = true) } .filter { it.currencyName.equals("USD", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 } .sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal) binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal) binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
val mvrCredit = creditAccounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
val usdCredit = creditAccounts
.filter { it.currencyName.equals("USD", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
if (creditAccounts.isNotEmpty()) {
binding.rowCreditCards.visibility = View.VISIBLE
binding.cardMvrCredit.visibility = if (mvrCredit > 0) View.VISIBLE else View.GONE
binding.cardUsdCredit.visibility = if (usdCredit > 0) View.VISIBLE else View.GONE
binding.tvMvrCredit.text = "MVR %,.2f".format(mvrCredit)
binding.tvUsdCredit.text = "USD %,.2f".format(usdCredit)
} else {
binding.rowCreditCards.visibility = View.GONE
} }
}
private val expandedLimits = mutableSetOf<Int>()
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) { private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
val hide = viewModel.hideAmounts.value ?: false val hide = viewModel.hideAmounts.value ?: false
binding.containerForeignLimits.removeAllViews() binding.containerForeignLimits.removeAllViews()
var cardIndex = 0
for (entry in entries) { for (entry in entries) {
for (limit in entry.limits) { for (limit in entry.limits) {
val idx = cardIndex++
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false) val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" } bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
card.tvLimitType.text = limit.type card.root.setOnClickListener {
if (hide) { if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
card.tvLimitGeneral.text = "USD ••••••" updateForeignLimits(entries)
card.tvLimitMedical.text = "USD ••••••"
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
card.tvLimitEcom.text = "USD ••••••"
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
} else {
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
else
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.tvLimitPos.text = if (!limit.isPosEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
else
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
} }
binding.containerForeignLimits.addView(card.root) binding.containerForeignLimits.addView(card.root)
} }
} }
} }
private fun updatePendingFinances(deals: List<MibFinanceDeal>) { private fun bindLimitCard(
card: ItemForeignLimitBinding,
userName: String,
limit: BmlForeignLimit,
hide: Boolean,
expanded: Boolean
) {
card.tvLimitUserName.text = userName.ifBlank { "BML" }
card.tvLimitType.text = limit.type
// ECOM (always visible)
card.tvLimitEcom.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.progressEcom.progress = if (hide || limit.ecomLimit <= 0) 0
else ((limit.ecomRemaining / limit.ecomLimit) * 100).toInt().coerceIn(0, 100)
// General (always visible)
card.tvLimitGeneral.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.progressGeneral.progress = if (hide || limit.generalCap <= 0) 0
else ((limit.generalRemaining / limit.generalCap) * 100).toInt().coerceIn(0, 100)
// Expanded section
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
card.dividerLimitDetails.visibility = detailsVisible
card.detailsGroup.visibility = detailsVisible
if (expanded) {
// ATM
if (!limit.isAtmEnabled) card.tvAtmLabel.append(" (Disabled)")
card.tvLimitAtm.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.progressAtm.progress = if (hide || limit.atmLimit <= 0) 0
else ((limit.atmRemaining / limit.atmLimit) * 100).toInt().coerceIn(0, 100)
// POS
if (!limit.isPosEnabled) card.tvPosLabel.append(" (Disabled)")
card.tvLimitPos.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.posRemaining, limit.posLimit)
card.progressPos.progress = if (hide || limit.posLimit <= 0) 0
else ((limit.posRemaining / limit.posLimit) * 100).toInt().coerceIn(0, 100)
// Medical
card.tvLimitMedical.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.medicalRemaining, limit.totalLimit)
card.progressMedical.progress = if (hide || limit.totalLimit <= 0) 0
else ((limit.medicalRemaining / limit.totalLimit) * 100).toInt().coerceIn(0, 100)
}
}
private fun updatePendingFinances() {
val hide = viewModel.hideAmounts.value ?: false val hide = viewModel.hideAmounts.value ?: false
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount }) val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
val total = mibTotal + bmlTotal
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -5,56 +5,97 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.mib.MibFinancingClient import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
import sh.sar.basedbank.databinding.ItemFinanceDealBinding import sh.sar.basedbank.databinding.ItemFinanceDealBinding
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
class FinancingAdapter(private var deals: List<MibFinanceDeal>) : class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() { RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class Mib(val deal: MibFinanceDeal) : Item()
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
}
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
private var hideAmounts: Boolean = false private var hideAmounts: Boolean = false
private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun setHideAmounts(hide: Boolean) { fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return if (hideAmounts == hide) return
hideAmounts = hide hideAmounts = hide
notifyDataSetChanged() notifyDataSetChanged()
} }
private val expandedPositions = mutableSetOf<Int>() fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun updateDeals(newDeals: List<MibFinanceDeal>) {
deals = newDeals
expandedPositions.clear() expandedPositions.clear()
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { // Legacy compatibility — used on initial empty construction
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false) fun updateDeals(newDeals: List<MibFinanceDeal>) {
return ViewHolder(binding) expandedPositions.clear()
val bmlItems = items.filterIsInstance<Item.Bml>()
items = newDeals.map { Item.Mib(it) } + bmlItems
notifyDataSetChanged()
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
holder.bind(deals[position], position in expandedPositions) expandedPositions.clear()
holder.binding.root.setOnClickListener { val mibItems = items.filterIsInstance<Item.Mib>()
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
}
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.Mib -> TYPE_MIB
is Item.Bml -> TYPE_BML
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val expanded = position in expandedPositions
when (val item = items[position]) {
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
}
holder.itemView.setOnClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos) if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
notifyItemChanged(pos) notifyItemChanged(pos)
} }
} }
override fun getItemCount() = deals.size override fun getItemCount() = items.size
inner class ViewHolder(val binding: ItemFinanceDealBinding) : // ── MIB ViewHolder ────────────────────────────────────────────────────────
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(deal: MibFinanceDeal, expanded: Boolean) { fun bind(deal: MibFinanceDeal, expanded: Boolean) {
@@ -69,25 +110,22 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}" binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}" binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0) val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100) ((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0 else 0
binding.progressBar.progress = if (hide) 0 else progress binding.progressBar.progress = if (hide) 0 else progress
// Completion estimate binding.tvCompletion.text = mibCompletionText(deal, ctx)
binding.tvCompletion.text = completionText(deal, ctx)
// Expanded details
val detailsVisible = if (expanded) View.VISIBLE else View.GONE val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.dividerDetails.visibility = detailsVisible binding.dividerDetails.visibility = detailsVisible
binding.detailsGroup.visibility = detailsVisible binding.detailsGroup.visibility = detailsVisible
if (expanded) { if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate) binding.tvDealDate.text = formatMibDate(deal.dealDate)
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}" binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvNumInstallments.text = deal.noOfInstallments.toString() binding.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate) binding.tvLastPaidDate.text = formatMibDate(deal.lastPaidDate)
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}" binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
if (deal.overdueAmount > 0) { if (deal.overdueAmount > 0) {
@@ -99,7 +137,7 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
} }
} }
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String { private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done) if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
val remaining = MibFinancingClient.remainingMonths(deal) val remaining = MibFinancingClient.remainingMonths(deal)
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done) if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
@@ -109,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
return ctx.getString(R.string.financing_completion_fmt, month) return ctx.getString(R.string.financing_completion_fmt, month)
} }
private fun formatDate(raw: String): String { private fun formatMibDate(raw: String): String {
return try { return try {
outputDateFmt.format(inputDateFmt.parse(raw)!!) outputDateFmt.format(mibDateFmt.parse(raw)!!)
} catch (_: Exception) { } catch (_: Exception) { raw.take(10) }
raw.take(10)
} }
} }
// ── BML ViewHolder ────────────────────────────────────────────────────────
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
val ctx = binding.root.context
val currency = account.currencyName
val hide = hideAmounts
binding.tvLoanProduct.text = account.accountTypeName
.trim().lowercase().split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
binding.tvLoanAccount.text = account.accountNumber
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
val loanAmt = detail?.loanAmount ?: 0.0
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
binding.loanProgressBar.progress = if (hide) 0 else progress
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.loanDividerDetails.visibility = detailsVisible
binding.loanDetailsGroup.visibility = detailsVisible
if (expanded && detail != null) {
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
if (detail.overdueAmount > 0) {
binding.loanRowOverdue.visibility = View.VISIBLE
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
} else {
binding.loanRowOverdue.visibility = View.GONE
}
}
}
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
if (detail == null) return ""
val outstanding = abs(detail.outstandingAmt)
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
return ctx.getString(R.string.financing_completion_done)
val remaining = ceil(outstanding / detail.repayAmount).toInt()
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, remaining)
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
return ctx.getString(R.string.financing_completion_fmt, month)
}
private fun formatIsoDate(raw: String): String {
return try {
outputDateFmt.format(isoDateFmt.parse(raw)!!)
} catch (_: Exception) { raw.take(10) }
}
}
companion object {
private const val TYPE_MIB = 0
private const val TYPE_BML = 1
} }
} }
@@ -10,6 +10,9 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentFinancingBinding import sh.sar.basedbank.databinding.FragmentFinancingBinding
class FinancingFragment : Fragment() { class FinancingFragment : Fragment() {
@@ -18,6 +21,10 @@ class FinancingFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: FinancingAdapter private lateinit var adapter: FinancingAdapter
private var financingRefreshing = false
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentFinancingBinding.inflate(inflater, container, false) _binding = FragmentFinancingBinding.inflate(inflater, container, false)
@@ -38,15 +45,41 @@ class FinancingFragment : Fragment() {
insets insets
} }
binding.swipeRefresh.setOnRefreshListener {
financingRefreshing = true
(activity as? HomeActivity)?.triggerRefreshFinancing()
}
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
viewModel.financing.observe(viewLifecycleOwner) { deals -> viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals) latestMibDeals = deals
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE rebuildAdapter()
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE }
binding.loadingView.visibility = View.GONE viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
latestBmlLoanDetails = details
rebuildAdapter()
} }
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) } viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
} }
private fun rebuildAdapter() {
val accounts = viewModel.accounts.value ?: emptyList()
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
adapter.update(latestMibDeals, bmlLoans)
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
if (financingRefreshing) {
financingRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.nav_finances) requireActivity().title = getString(R.string.nav_finances)
@@ -40,6 +40,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlContactsClient import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.bml.BmlProfile import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayAccountClient import sh.sar.basedbank.api.fahipay.FahipayAccountClient
@@ -170,6 +171,8 @@ class HomeActivity : AppCompatActivity() {
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
@@ -178,6 +181,7 @@ class HomeActivity : AppCompatActivity() {
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId)) refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
} }
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshBmlLoanDetails()
} else { } else {
// Came from lock screen — show caches immediately, refresh everything in background // Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this) val store = CredentialStore(this)
@@ -188,14 +192,15 @@ class HomeActivity : AppCompatActivity() {
if (merged.isNotEmpty()) viewModel.accounts.value = merged if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
autoRefresh(store) autoRefresh(store)
} }
// hideAmounts is always false on launch; eye feature just needs to be enabled viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
viewModel.hideAmounts.value = false
// Show dashboard on first create // Show dashboard on first create
if (savedInstanceState == null) { if (savedInstanceState == null) {
@@ -325,6 +330,19 @@ fun applyNavLabelVisibility() {
} }
} }
fun triggerRefresh() {
autoRefresh(CredentialStore(this))
}
fun triggerRefreshFinancing() {
val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlLoanDetails()
}
fun setRefreshing(visible: Boolean) { fun setRefreshing(visible: Boolean) {
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
} }
@@ -338,11 +356,12 @@ fun applyNavLabelVisibility() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Returning from LockActivity — skip the elapsed check and reset state. // Returning from LockActivity — refresh sessions since they may have expired.
if (isLocked) { if (isLocked) {
isLocked = false isLocked = false
pauseTime = 0L pauseTime = 0L
resetAutolockTimer() resetAutolockTimer()
autoRefresh(CredentialStore(this))
return return
} }
// If we were away long enough to have hit the autolock timeout (e.g. while // If we were away long enough to have hit the autolock timeout (e.g. while
@@ -355,6 +374,9 @@ fun applyNavLabelVisibility() {
lock() lock()
return return
} }
if (elapsed > 45_000L) {
autoRefresh(CredentialStore(this))
}
} }
resetAutolockTimer() resetAutolockTimer()
} }
@@ -428,13 +450,24 @@ fun applyNavLabelVisibility() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) { if (item.itemId == R.id.action_lock) {
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
if (avd != null) {
item.icon = avd
avd.start()
Handler(Looper.getMainLooper()).postDelayed({ lock() }, 200)
} else {
lock() lock()
}
return true return true
} }
if (item.itemId == R.id.action_hide_amounts) { if (item.itemId == R.id.action_hide_amounts) {
val newHidden = !(viewModel.hideAmounts.value ?: false) val newHidden = !(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.value = newHidden viewModel.hideAmounts.value = newHidden
invalidateOptionsMenu() getSharedPreferences("prefs", MODE_PRIVATE).edit().putBoolean("hide_amounts", newHidden).apply()
val avd = getDrawable(if (newHidden) R.drawable.avd_hide_amounts else R.drawable.avd_show_amounts)
as? android.graphics.drawable.AnimatedVectorDrawable
item.icon = avd
avd?.start()
return true return true
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
@@ -635,6 +668,7 @@ fun applyNavLabelVisibility() {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId)) refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
} }
refreshBmlLoanDetails()
} }
} }
@@ -872,6 +906,32 @@ fun applyNavLabelVisibility() {
} }
} }
private fun refreshBmlLoanDetails() {
val app = application as BasedBankApp
val loanAccounts = app.bmlAccounts.filter { it.profileType == "BML_LOAN" }
if (loanAccounts.isEmpty()) return
lifecycleScope.launch {
try {
val details = withContext(Dispatchers.IO) {
val map = mutableMapOf<String, BmlLoanDetail>()
for (acc in loanAccounts) {
val session = app.bmlSessionFor(acc) ?: continue
try {
val detail = BmlAccountClient().fetchLoanDetail(session, acc.internalId)
if (detail != null) map[acc.internalId] = detail
} catch (_: Exception) { /* keep existing */ }
}
map
}
if (details.isNotEmpty()) {
val merged = (viewModel.bmlLoanDetails.value ?: emptyMap()) + details
FinancingCache.saveBmlLoans(this@HomeActivity, merged)
viewModel.bmlLoanDetails.postValue(merged)
}
} catch (_: Exception) { /* keep cached data */ }
}
}
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) { private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId) val flow = (application as BasedBankApp).mibFlowFor(loginId)
@@ -3,6 +3,7 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory import sh.sar.basedbank.api.models.BankContactCategory
@@ -11,6 +12,8 @@ import sh.sar.basedbank.api.mib.MibFinanceDeal
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<BankAccount>>(emptyList()) val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList()) val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
/** BML loan details keyed by account internalId. */
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
val contacts = MutableLiveData<List<BankContact>>(emptyList()) val contacts = MutableLiveData<List<BankContact>>(emptyList())
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList()) val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
@@ -11,6 +11,7 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.materialswitch.MaterialSwitch
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
@@ -27,11 +28,21 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import androidx.lifecycle.lifecycleScope
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
class SettingsLoginsFragment : Fragment() { class SettingsLoginsFragment : Fragment() {
private var _binding: FragmentSettingsLoginsBinding? = null private var _binding: FragmentSettingsLoginsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false) _binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
@@ -89,13 +100,15 @@ class SettingsLoginsFragment : Fragment() {
val profile = store.loadFahipayUserProfile(loginId) val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) { addLoginRow(container, R.drawable.fahipay_logo, displayName) {
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
showLoginDetails( showLoginDetails(
title = getString(R.string.fahipay_name), title = getString(R.string.fahipay_name),
details = buildString { details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}") if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}") if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}") if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}") if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
}.trim(), }.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } } onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
) )
@@ -258,8 +271,16 @@ class SettingsLoginsFragment : Fragment() {
) { ) {
val ctx = requireContext() val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenBmlProfileIds(loginId) val hidden = store.getHiddenBmlProfileIds(loginId).toMutableSet()
val hidden = originalHidden.toMutableSet() // Business profiles with no saved session were skipped during login — ensure they start hidden
val needsActivation = bmlProfiles
.filter { it.profileType == "business" && store.loadBmlProfileSession(it.profileId) == null }
.map { it.profileId }
.toMutableSet()
for (id in needsActivation) {
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
}
val originalHidden = hidden.toSet()
val scroll = android.widget.ScrollView(ctx) val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply { val container = LinearLayout(ctx).apply {
@@ -269,12 +290,14 @@ class SettingsLoginsFragment : Fragment() {
} }
scroll.addView(container) scroll.addView(container)
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
listOfNotNull( listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" }, profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" }, profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }, profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: $it" }, profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: ${if (hide) masked else it}" },
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: $it" } profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
).forEach { line -> ).forEach { line ->
container.addView(TextView(ctx).apply { container.addView(TextView(ctx).apply {
text = line text = line
@@ -357,16 +380,229 @@ class SettingsLoginsFragment : Fragment() {
toggleRows.forEach { (p, toggle) -> toggleRows.forEach { (p, toggle) ->
toggle.setOnCheckedChangeListener { _, checked -> toggle.setOnCheckedChangeListener { _, checked ->
if (checked && p.profileId in needsActivation) {
toggle.isChecked = false // revert — enabling requires OTP
viewLifecycleOwner.lifecycleScope.launch {
val success = activateBmlBusinessProfile(store, loginId, p)
if (success) {
needsActivation.remove(p.profileId)
toggle.isChecked = true // listener re-fires, removes from hidden
}
}
} else {
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId) if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
updateToggleStates(saveBtn) updateToggleStates(saveBtn)
} }
} }
}
saveBtn.setOnClickListener { saveBtn.setOnClickListener {
store.setHiddenBmlProfileIds(loginId, hidden) store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx) clearAllCaches(ctx)
dialog.dismiss() dialog.dismiss()
(activity as? HomeActivity)?.applyProfileVisibility() (activity as? HomeActivity)?.relogin()
}
}
private suspend fun activateBmlBusinessProfile(
store: CredentialStore,
loginId: String,
profile: BmlProfile
): Boolean {
val creds = store.loadBmlCredentials(loginId) ?: run {
showSimpleError("Credentials not found — please log out and log in again")
return false
}
val progressDialog = MaterialAlertDialogBuilder(requireContext())
.setMessage("Connecting to BML\u2026")
.setCancelable(false)
.show()
val flow = BmlLoginFlow()
val loginTag = "bml_$loginId"
val activationResult = try {
withContext(Dispatchers.IO) {
flow.login(creds.username, creds.password, creds.otpSeed)
flow.activateProfile(profile, loginTag)
}
} catch (e: Exception) {
progressDialog.dismiss()
showSimpleError(e.message ?: "Authentication failed")
return false
}
progressDialog.dismiss()
return when (activationResult) {
is BmlActivationResult.Success -> {
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
true
}
is BmlActivationResult.NeedsBusinessOtp ->
continueBmlBusinessOtpFlow(store, loginId, profile, flow, loginTag, activationResult.channels)
}
}
private suspend fun continueBmlBusinessOtpFlow(
store: CredentialStore,
loginId: String,
profile: BmlProfile,
flow: BmlLoginFlow,
loginTag: String,
channels: List<BmlOtpChannel>
): Boolean {
val selectedChannel = showBmlChannelSelectionDialog(profile.name, channels) ?: return false
val channelObj = channels.first { it.channel == selectedChannel }
val sendProgress = MaterialAlertDialogBuilder(requireContext())
.setMessage("Sending OTP\u2026")
.setCancelable(false)
.show()
try {
withContext(Dispatchers.IO) { flow.requestBusinessOtp(selectedChannel) }
} catch (e: Exception) {
sendProgress.dismiss()
showSimpleError(e.message ?: "Failed to send OTP")
return false
}
sendProgress.dismiss()
var otpError: String? = null
while (true) {
val code = showBmlOtpInputDialog(profile.name, channelObj, otpError) ?: return false
val verifyProgress = MaterialAlertDialogBuilder(requireContext())
.setMessage("Verifying\u2026")
.setCancelable(false)
.show()
try {
val (session, _) = withContext(Dispatchers.IO) {
flow.submitBusinessOtp(selectedChannel, code, profile, loginTag)
}
verifyProgress.dismiss()
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
return true
} catch (e: Exception) {
verifyProgress.dismiss()
if (e.message?.contains("Invalid OTP") == true) {
otpError = e.message
} else {
showSimpleError(e.message ?: "Verification failed")
return false
}
}
}
}
private fun showSimpleError(message: String) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(message)
.setPositiveButton(R.string.close, null)
.show()
}
private suspend fun showBmlChannelSelectionDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val vp = (8 * dp).toInt()
setPadding(0, vp, 0, vp)
}
for (channel in channels) {
val iconRes = when (channel.channel) {
"Email" -> R.drawable.ic_channel_email
"Mobile" -> R.drawable.ic_channel_sms
"WhatsApp" -> R.drawable.ic_channel_whatsapp
else -> R.drawable.ic_channel_sms
}
val iconSize = (24 * dp).toInt()
val iconView = ImageView(ctx).apply {
setImageResource(iconRes)
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = (12 * dp).toInt()
}
}
textCol.addView(TextView(ctx).apply {
text = channel.description
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
})
textCol.addView(TextView(ctx).apply {
text = channel.masked
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
val hp = (24 * dp).toInt(); val vp = (12 * dp).toInt()
setPadding(hp, vp, hp, vp)
}
row.addView(iconView, LinearLayout.LayoutParams(iconSize, iconSize))
row.addView(textCol)
list.addView(row)
}
val d = MaterialAlertDialogBuilder(ctx)
.setTitle("Send verification code")
.setView(list)
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
.setCancelable(false)
.show()
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
// Wire up row clicks after dialog is created so we can dismiss it first
val rows = list.run { (0 until childCount).map { getChildAt(it) } }
rows.forEachIndexed { i, row ->
row.setOnClickListener {
d.dismiss()
if (cont.isActive) cont.resume(channels[i].channel)
}
}
}
private suspend fun showBmlOtpInputDialog(
profileName: String,
channel: BmlOtpChannel,
errorMsg: String? = null
): String? = suspendCancellableCoroutine { cont ->
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val input = android.widget.EditText(ctx).apply {
hint = "Enter OTP"
inputType = android.text.InputType.TYPE_CLASS_NUMBER
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
setPadding((24 * dp).toInt(), (8 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
}
val msg = buildString {
append(getString(R.string.bml_business_otp_sent, channel.description))
append(" (${channel.masked})")
if (errorMsg != null) append("\n\n$errorMsg")
}
val d = MaterialAlertDialogBuilder(ctx)
.setTitle("Enter verification code")
.setMessage(msg)
.setView(input)
.setPositiveButton(R.string.verify, null)
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
.setCancelable(false)
.show()
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
d.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val code = input.text.toString().trim()
if (code.length != 6) {
input.error = "Enter 6 digits"
} else {
d.dismiss()
if (cont.isActive) cont.resume(code)
}
} }
} }
@@ -60,7 +60,6 @@ class SettingsSecurityFragment : Fragment() {
// Auto-lock // Auto-lock
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) { binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
0L -> R.id.btnAutolockOff
30_000L -> R.id.btnAutolock30s 30_000L -> R.id.btnAutolock30s
180_000L -> R.id.btnAutolock3m 180_000L -> R.id.btnAutolock3m
300_000L -> R.id.btnAutolock5m 300_000L -> R.id.btnAutolock5m
@@ -69,7 +68,6 @@ class SettingsSecurityFragment : Fragment() {
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
val timeout = when (checkedId) { val timeout = when (checkedId) {
R.id.btnAutolockOff -> 0L
R.id.btnAutolock30s -> 30_000L R.id.btnAutolock30s -> 30_000L
R.id.btnAutolock3m -> 180_000L R.id.btnAutolock3m -> 180_000L
R.id.btnAutolock5m -> 300_000L R.id.btnAutolock5m -> 300_000L
@@ -652,7 +652,7 @@ class TransferFragment : Fragment() {
ReceiptStore.save(requireContext(), receipt) ReceiptStore.save(requireContext(), receipt)
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
activity.refreshBalances(src) activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) { } else if (!ok) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
@@ -138,6 +138,14 @@ class TransferHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.setRefreshing(true) (activity as? HomeActivity)?.setRefreshing(true)
loadNextPages() loadNextPages()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
} }
override fun onResume() { override fun onResume() {
@@ -145,6 +153,19 @@ class TransferHistoryFragment : Fragment() {
requireActivity().title = getString(R.string.nav_transfer_history) requireActivity().title = getString(R.string.nav_transfer_history)
} }
private fun resetAndReload() {
allTransactions.clear()
pendingImageNames.clear()
pendingIconUrls.clear()
firstBatchDone = false
val accounts = accountStates.map { it.account }
accountStates.clear()
accounts.forEach { accountStates.add(AccountState(it)) }
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
loadNextPages()
}
private fun loadNextPages() { private fun loadNextPages() {
val activeStates = accountStates.filter { it.hasMore() } val activeStates = accountStates.filter { it.hasMore() }
if (isLoading || activeStates.isEmpty()) return if (isLoading || activeStates.isEmpty()) return
@@ -250,6 +271,7 @@ class TransferHistoryFragment : Fragment() {
if (!firstBatchDone) { if (!firstBatchDone) {
firstBatchDone = true firstBatchDone = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
binding.swipeRefresh.isRefreshing = false
} }
if (newTransactions.isNotEmpty()) { if (newTransactions.isNotEmpty()) {
@@ -330,17 +330,30 @@ class TransferReceiptFragment : Fragment() {
} }
private fun showFullScreenReceipt() { private fun showFullScreenReceipt() {
captureReceiptBitmap { bitmap ->
if (bitmap == null) return@captureReceiptBitmap
val ctx = requireContext() val ctx = requireContext()
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen) val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val iv = android.widget.ImageView(ctx).apply {
setImageBitmap(bitmap) val scrollView = android.widget.ScrollView(ctx).apply {
scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
} }
iv.setOnClickListener { dialog.dismiss() }
dialog.setContentView(iv) val cardView = if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
bindMib(binding)
binding.receiptCard
} else {
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
bindBml(binding)
binding.receiptCard
}
(cardView.parent as? ViewGroup)?.removeView(cardView)
cardView.setOnClickListener { dialog.dismiss() }
scrollView.addView(cardView)
scrollView.setOnTouchListener { _, _ -> dialog.dismiss(); true }
dialog.setContentView(scrollView)
val actWin = requireActivity().window val actWin = requireActivity().window
val prevColor = actWin.statusBarColor val prevColor = actWin.statusBarColor
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView) val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
@@ -356,13 +369,12 @@ class TransferReceiptFragment : Fragment() {
dialog.show() dialog.show()
dialog.window?.let { win -> dialog.window?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false) androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
androidx.core.view.WindowInsetsControllerCompat(win, iv).apply { androidx.core.view.WindowInsetsControllerCompat(win, scrollView).apply {
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} }
} }
} }
}
private fun copyOnLongClick(vararg views: android.widget.TextView) { private fun copyOnLongClick(vararg views: android.widget.TextView) {
for (tv in views) { for (tv in views) {
@@ -21,8 +21,6 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.fahipay.FahipayAccountClient import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
@@ -34,8 +32,6 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
class CredentialsFragment : Fragment() { class CredentialsFragment : Fragment() {
@@ -60,10 +56,6 @@ class CredentialsFragment : Fragment() {
private var bmlFlow: BmlLoginFlow? = null private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = "" private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>() private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
private var bmlCurrentBusinessProfile: BmlProfile? = null
private var bmlSelectedChannel: String? = null
private var bmlAwaitingBusinessOtp = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false) _binding = FragmentCredentialsBinding.inflate(inflater, container, false)
@@ -170,11 +162,7 @@ class CredentialsFragment : Fragment() {
private fun attemptLogin() { private fun attemptLogin() {
when (bankType) { when (bankType) {
"BML" -> { "BML" -> { attemptBmlLogin(); return }
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
else attemptBmlLogin()
return
}
"FAHIPAY" -> { attemptFahipayLogin(); return } "FAHIPAY" -> { attemptFahipayLogin(); return }
} }
@@ -257,10 +245,6 @@ class CredentialsFragment : Fragment() {
bmlLoginId = username bmlLoginId = username
bmlAccumulatedAccounts.clear() bmlAccumulatedAccounts.clear()
bmlPendingBusinessProfiles.clear()
bmlCurrentBusinessProfile = null
bmlSelectedChannel = null
bmlAwaitingBusinessOtp = false
val flow = BmlLoginFlow().also { bmlFlow = it } val flow = BmlLoginFlow().also { bmlFlow = it }
val loginTag = "bml_$username" val loginTag = "bml_$username"
@@ -272,24 +256,22 @@ class CredentialsFragment : Fragment() {
} }
if (profiles.isEmpty()) throw Exception("No profiles found for this account") if (profiles.isEmpty()) throw Exception("No profiles found for this account")
// Activate each profile; personal profiles are immediate, business ones need OTP var hasBusinessProfiles = false
for (profile in profiles) { for (profile in profiles) {
if (profile.profileType == "business") {
hasBusinessProfiles = true
continue // skip — user can enable in Settings → Logins
}
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) } val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
when (result) { if (result is BmlActivationResult.Success) {
is BmlActivationResult.Success -> {
bmlAccumulatedAccounts += result.accounts bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId) store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session app.bmlSessions[profile.profileId] = result.session
} }
is BmlActivationResult.NeedsBusinessOtp -> {
bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels))
}
}
} }
// Save credentials and profile list now (before business OTP prompts)
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed) store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
store.saveBmlProfiles(bmlLoginId, profiles) store.saveBmlProfiles(bmlLoginId, profiles)
@@ -300,11 +282,7 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) { finishBmlLogin(hasBusinessProfiles)
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} catch (e: Exception) { } catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed" binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE binding.tvError.visibility = View.VISIBLE
@@ -314,115 +292,7 @@ class CredentialsFragment : Fragment() {
} }
} }
private suspend fun processNextBmlBusinessProfile() { private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull()
?: run { finishBmlLogin(); return }
bmlCurrentBusinessProfile = profile
// Show channel selection dialog
val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run {
// User skipped this profile — move on
processNextBmlBusinessProfile()
return
}
bmlSelectedChannel = selectedChannel
// Request OTP
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
try {
withContext(Dispatchers.IO) {
bmlFlow!!.requestBusinessOtp(selectedChannel)
}
} catch (e: Exception) {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
binding.tvError.text = e.message ?: "Failed to send OTP"
binding.tvError.visibility = View.VISIBLE
processNextBmlBusinessProfile()
return
}
// Show OTP input — disable credential fields (same pattern as Fahipay TOTP step)
bmlAwaitingBusinessOtp = true
binding.etUsername.isEnabled = false
binding.etPassword.isEnabled = false
binding.etOtpSeed.isEnabled = false
binding.progressBar.visibility = View.GONE
binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name)
binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel)
binding.tilTotpCode.visibility = View.VISIBLE
binding.etTotpCode.text?.clear()
binding.btnLogin.text = getString(R.string.verify)
binding.btnLogin.isEnabled = true
binding.tvError.visibility = View.GONE
}
private fun submitBmlBusinessOtp() {
val code = binding.etTotpCode.text.toString().trim()
if (code.length != 6) {
binding.tvError.text = getString(R.string.fahipay_totp_hint)
binding.tvError.visibility = View.VISIBLE
return
}
binding.tvError.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val profile = bmlCurrentBusinessProfile ?: return
val channel = bmlSelectedChannel ?: return
val loginTag = "bml_$bmlLoginId"
viewLifecycleOwner.lifecycleScope.launch {
try {
val (session, accounts) = withContext(Dispatchers.IO) {
bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
}
bmlAccumulatedAccounts += accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = session
bmlAwaitingBusinessOtp = false
binding.tilTotpCode.visibility = View.GONE
binding.btnLogin.text = getString(R.string.login)
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} catch (e: Exception) {
binding.tvError.text = e.message ?: "OTP verification failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun showBmlChannelDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray()
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.bml_business_otp_title, profileName))
.setItems(options) { _, which ->
if (cont.isActive) cont.resume(channels[which].channel)
}
.setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int ->
if (cont.isActive) cont.resume(null as String?)
}
.show()
dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) }
}
private suspend fun finishBmlLogin() {
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
val accounts = bmlAccumulatedAccounts.toList() val accounts = bmlAccumulatedAccounts.toList()
@@ -479,6 +349,9 @@ class CredentialsFragment : Fragment() {
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
if (hasBusinessProfiles) {
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
}
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -3,12 +3,14 @@ package sh.sar.basedbank.util
import android.content.Context import android.content.Context
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
object FinancingCache { object FinancingCache {
private const val PREFS = "financing_cache" private const val PREFS = "financing_cache"
private const val KEY_MIB = "mib_financing" private const val KEY_MIB = "mib_financing"
private const val KEY_BML_LOANS = "bml_loans"
fun save(context: Context, deals: List<MibFinanceDeal>) { fun save(context: Context, deals: List<MibFinanceDeal>) {
val arr = JSONArray() val arr = JSONArray()
@@ -34,6 +36,52 @@ object FinancingCache {
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
} }
fun saveBmlLoans(context: Context, loans: Map<String, BmlLoanDetail>) {
val arr = JSONArray()
for ((internalId, d) in loans) {
arr.put(JSONObject().apply {
put("internalId", internalId)
put("loanAmount", d.loanAmount)
put("outstandingAmt", d.outstandingAmt)
put("repayAmount", d.repayAmount)
put("intRate", d.intRate)
put("loanStatus", d.loanStatus)
put("startDate", d.startDate)
put("endDate", d.endDate)
put("noOfRepayOverdue", d.noOfRepayOverdue)
put("overdueAmount", d.overdueAmount)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_BML_LOANS, CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBmlLoans(context: Context): Map<String, BmlLoanDetail> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML_LOANS, null) ?: return emptyMap()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
buildMap {
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val id = o.optString("internalId")
if (id.isNotBlank()) put(id, BmlLoanDetail(
loanAmount = o.optDouble("loanAmount", 0.0),
outstandingAmt = o.optDouble("outstandingAmt", 0.0),
repayAmount = o.optDouble("repayAmount", 0.0),
intRate = o.optDouble("intRate", 0.0),
loanStatus = o.optString("loanStatus"),
startDate = o.optString("startDate"),
endDate = o.optString("endDate"),
noOfRepayOverdue = o.optInt("noOfRepayOverdue", 0),
overdueAmount = o.optDouble("overdueAmount", 0.0)
))
}
}
} catch (_: Exception) { emptyMap() }
}
fun clear(context: Context) { fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
} }
@@ -22,6 +22,7 @@ class HistoryFetcher(private val account: BankAccount) {
private val isMib get() = account.bank == "MIB" private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private val isBmlLoan get() = account.profileType == "BML_LOAN"
private val isFahipay get() = account.bank == "FAHIPAY" private val isFahipay get() = account.bank == "FAHIPAY"
// MIB pagination // MIB pagination
@@ -40,6 +41,7 @@ class HistoryFetcher(private val account: BankAccount) {
private var fahipayTotal = -1 private var fahipayTotal = -1
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
isBmlLoan -> false
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3 isBmlCard -> cardMonthOffset < 3
@@ -47,6 +49,7 @@ class HistoryFetcher(private val account: BankAccount) {
} }
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when { suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
isBmlLoan -> emptyList()
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) } isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } } isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) } isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
@@ -10,7 +10,8 @@ object BmlDashboardParser {
* Returns all display fields for an account/card row in the accounts list. * Returns all display fields for an account/card row in the accounts list.
* Handles both BML CASA accounts and BML prepaid/credit cards. * Handles both BML CASA accounts and BML prepaid/credit cards.
*/ */
fun displayData(account: BankAccount): AccountListDisplay { fun displayData(account: BankAccount): AccountListDisplay? {
if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) { return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) val isActive = account.statusDesc.equals("Active", ignoreCase = true)
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
android:tint="?attr/colorControlNormal">
<path
android:name="strike_through"
android:pathData="M3.27,4.27 L19.74,20.74"
android:strokeColor="@android:color/white"
android:strokeLineCap="square"
android:strokeWidth="1.8"
android:trimPathEnd="0"/>
<group>
<clip-path
android:name="eye_mask"
android:pathData="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
<path
android:name="eye"
android:fillColor="@android:color/white"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</group>
</vector>
</aapt:attr>
<target android:name="eye_mask">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="320"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:propertyName="pathData"
android:valueFrom="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueTo="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueType="pathType"/>
</aapt:attr>
</target>
<target android:name="strike_through">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="320"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:propertyName="trimPathEnd"
android:valueFrom="0"
android:valueTo="1"/>
</aapt:attr>
</target>
</animated-vector>
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
android:tint="?attr/colorControlNormal">
<!--
Shackle drawn first (behind body) so it appears to slot into the body.
Starts translateY=-4 (open/raised), animates to 0 (locked).
-->
<group
android:name="shackle"
android:translateY="-4">
<path
android:fillColor="@android:color/transparent"
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1V10"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeWidth="2.2"/>
</group>
<!--
Body on top — covers the shackle legs once they slide inside.
Even-odd fill cuts out the keyhole.
-->
<path
android:fillColor="@android:color/white"
android:fillType="evenOdd"
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
</vector>
</aapt:attr>
<target android:name="shackle">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="320"
android:interpolator="@android:interpolator/overshoot"
android:propertyName="translateY"
android:valueFrom="-4"
android:valueTo="0"/>
</aapt:attr>
</target>
</animated-vector>
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
android:tint="?attr/colorControlNormal">
<path
android:name="strike_through"
android:pathData="M3.27,4.27 L19.74,20.74"
android:strokeColor="@android:color/white"
android:strokeLineCap="square"
android:strokeWidth="1.8"/>
<group>
<clip-path
android:name="eye_mask"
android:pathData="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
<path
android:name="eye"
android:fillColor="@android:color/white"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</group>
</vector>
</aapt:attr>
<target android:name="eye_mask">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="200"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="pathData"
android:valueFrom="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueTo="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
android:valueType="pathType"/>
</aapt:attr>
</target>
<target android:name="strike_through">
<aapt:attr name="android:animation">
<objectAnimator
android:duration="200"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:propertyName="trimPathEnd"
android:valueFrom="1"
android:valueTo="0"/>
</aapt:attr>
</target>
</animated-vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5V6l8,5 8,-5v2z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM9,11H7V9h2v2zM13,11h-2V9h2v2zM17,11h-2V9h2v2z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M26.576 5.363c-2.69-2.69-6.406-4.354-10.511-4.354-8.209 0-14.865 6.655-14.865 14.865 0 2.732 0.737 5.291 2.022 7.491l-0.038-0.070-2.109 7.702 7.879-2.067c2.051 1.139 4.498 1.809 7.102 1.809h0.006c8.209-0.003 14.862-6.659 14.862-14.868 0-4.103-1.662-7.817-4.349-10.507zM16.062 28.228h-0.005c-2.319 0-4.489-0.64-6.342-1.753l0.056 0.031-0.451-0.267-4.675 1.227 1.247-4.559-0.294-0.467c-1.185-1.862-1.889-4.131-1.889-6.565 0-6.822 5.531-12.353 12.353-12.353s12.353 5.531 12.353 12.353c0 6.822-5.53 12.353-12.353 12.353zM22.838 18.977c-0.371-0.186-2.197-1.083-2.537-1.208-0.341-0.124-0.589-0.185-0.837 0.187-0.246 0.371-0.958 1.207-1.175 1.455-0.216 0.249-0.434 0.279-0.805 0.094-1.15-0.466-2.138-1.087-2.997-1.852l0.010 0.009c-0.799-0.74-1.484-1.587-2.037-2.521l-0.028-0.052c-0.216-0.371-0.023-0.572 0.162-0.757 0.167-0.166 0.372-0.434 0.557-0.65 0.146-0.179 0.271-0.384 0.366-0.604l0.006-0.017c0.043-0.087 0.068-0.188 0.068-0.296 0-0.131-0.037-0.253-0.101-0.357l0.002 0.003c-0.094-0.186-0.836-2.014-1.145-2.758-0.302-0.724-0.609-0.625-0.836-0.637-0.216-0.010-0.464-0.012-0.712-0.012-0.395 0.010-0.746 0.188-0.988 0.463l-0.001 0.002c-0.802 0.761-1.3 1.834-1.3 3.023 0 0.026 0 0.053 0.001 0.079c0.131 1.467 0.681 2.784 1.527 3.857l-0.012-0.015c1.604 2.379 3.742 4.282 6.251 5.564l0.094 0.043c0.548 0.248 1.25 0.513 1.968 0.74l0.149 0.041c0.442 0.14 0.951 0.221 1.479 0.221 0.303 0 0.601-0.027 0.889-0.078l-0.031 0.004c1.069-0.223 1.956-0.868 2.497-1.749l0.009-0.017c0.165-0.366 0.261-0.793 0.261-1.242 0-0.185-0.016-0.366-0.047-0.542l0.003 0.019c-0.092-0.155-0.34-0.247-0.712-0.434z"/>
</vector>
+13 -1
View File
@@ -5,7 +5,19 @@
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24" android:viewportHeight="24"
android:tint="?attr/colorControlNormal"> android:tint="?attr/colorControlNormal">
<!-- Shackle (behind body) -->
<path
android:fillColor="@android:color/transparent"
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1V10"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeWidth="2.2"/>
<!-- Body + keyhole cutout -->
<path <path
android:fillColor="@android:color/white" android:fillColor="@android:color/white"
android:pathData="M18,8h-1V6c0-2.76-2.24-5-5-5S7,3.24 7,6v2H6c-1.1,0-2,0.9-2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1-0.9,-2-2,-2zm-6,9c-1.1,0-2,-0.9-2,-2s0.9,-2 2,-2 2,0.9 2,2-0.9,2-2,2zm3.1,-9H8.9V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z" /> android:fillType="evenOdd"
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
</vector> </vector>
@@ -31,11 +31,16 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -55,4 +60,6 @@
</FrameLayout> </FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout> </LinearLayout>
+10 -3
View File
@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="16dp" android:paddingBottom="16dp"
android:clipToPadding="false" android:clipToPadding="false" />
android:background="?attr/colorSurface" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
@@ -41,11 +41,16 @@
app:tabMode="scrollable" app:tabMode="scrollable"
app:tabGravity="start" /> app:tabGravity="start" />
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2 <androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager" android:id="@+id/viewPager"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -70,6 +75,8 @@
</FrameLayout> </FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout> </LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton <com.google.android.material.floatingactionbutton.FloatingActionButton
+87 -1
View File
@@ -7,11 +7,16 @@
android:orientation="vertical" android:orientation="vertical"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -92,6 +97,85 @@
</LinearLayout> </LinearLayout>
<!-- Available Credit row (hidden when no credit cards) -->
<LinearLayout
android:id="@+id/rowCreditCards"
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/cardMvrCredit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<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/balance_mvr_credit"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvMvrCredit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardUsdCredit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<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/balance_usd_credit"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvUsdCredit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Pending Finances card --> <!-- Pending Finances card -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -172,6 +256,8 @@
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Quick actions fixed at bottom --> <!-- Quick actions fixed at bottom -->
<LinearLayout <LinearLayout
android:id="@+id/buttonBar" android:id="@+id/buttonBar"
@@ -1,10 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -38,4 +43,6 @@
android:textColor="?attr/colorOnSurfaceVariant" android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" /> android:visibility="gone" />
</FrameLayout> </FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
@@ -112,14 +112,6 @@
app:singleSelection="true" app:singleSelection="true"
app:selectionRequired="true"> app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolockOff"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_off" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock30s" android:id="@+id/btnAutolock30s"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
@@ -31,11 +31,16 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -56,4 +61,6 @@
</FrameLayout> </FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout> </LinearLayout>
+304
View File
@@ -0,0 +1,304 @@
<?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_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Header row: product name + status chip -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvLoanProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLoanAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<TextView
android:id="@+id/tvLoanStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
</LinearLayout>
<!-- Loan amount -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/financing_total"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanTotal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginStart="8dp" />
</LinearLayout>
<!-- Progress bar -->
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loanProgressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- Paid / Outstanding row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/financing_paid"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanPaid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="end">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loan_outstanding"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanOutstanding"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="@color/color_unpaid"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Completion estimate -->
<TextView
android:id="@+id/tvLoanCompletion"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Divider -->
<View
android:id="@+id/loanDividerDetails"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginTop="16dp"
android:layout_marginBottom="12dp"
android:visibility="gone" />
<!-- Expanded details -->
<LinearLayout
android:id="@+id/loanDetailsGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<!-- Monthly repayment -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_monthly_repayment"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanRepayment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Interest rate -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_interest_rate"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanIntRate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Start date -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_start_date"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanStartDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- End date -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_end_date"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvLoanEndDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Overdue (only shown when > 0) -->
<LinearLayout
android:id="@+id/loanRowOverdue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/loan_overdue_payments"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="@color/color_unpaid" />
<TextView
android:id="@+id/tvLoanOverdue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="@color/color_unpaid" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+145 -94
View File
@@ -12,14 +12,15 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="20dp">
<!-- Header: name + type chip --> <!-- Header: name + type chip -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
@@ -31,7 +32,7 @@
android:id="@+id/tvLimitUserName" android:id="@+id/tvLimitUserName"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium" android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" /> android:textColor="?attr/colorOnSurface" />
<TextView <TextView
@@ -49,103 +50,19 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="10dp" android:paddingHorizontal="10dp"
android:paddingVertical="5dp" android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall" android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSecondaryContainer" />
android:background="@drawable/pill_segment_bg" />
</LinearLayout> </LinearLayout>
<View <!-- ECOM bar (always visible) -->
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="?attr/colorOutlineVariant" />
<!-- General -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_marginBottom="6dp"> android:layout_marginBottom="4dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="General"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLimitGeneral"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Medical -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Medical"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLimitMedical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="12dp"
android:background="?attr/colorOutlineVariant" />
<!-- ATM -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="6dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ATM"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLimitAtm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- ECOM -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="6dp">
<TextView <TextView
android:layout_width="0dp" android:layout_width="0dp"
@@ -164,13 +81,105 @@
</LinearLayout> </LinearLayout>
<!-- POS --> <com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressEcom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- General bar (always visible) -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal"
android:layout_marginBottom="4dp">
<TextView <TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="General"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLimitGeneral"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressGeneral"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- Divider (visible when expanded) -->
<View
android:id="@+id/dividerLimitDetails"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginTop="16dp"
android:layout_marginBottom="12dp"
android:visibility="gone" />
<!-- Expanded details -->
<LinearLayout
android:id="@+id/detailsGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<!-- ATM bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="4dp">
<TextView
android:id="@+id/tvAtmLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ATM"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLimitAtm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressAtm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- POS bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="4dp">
<TextView
android:id="@+id/tvPosLabel"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
@@ -187,6 +196,48 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressPos"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- Medical bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="4dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Medical"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvLimitMedical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressMedical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
+11 -3
View File
@@ -95,6 +95,8 @@
<string name="dashboard_quick_actions">Quick Actions</string> <string name="dashboard_quick_actions">Quick Actions</string>
<string name="balance_mvr">MVR Total</string> <string name="balance_mvr">MVR Total</string>
<string name="balance_usd">USD Total</string> <string name="balance_usd">USD Total</string>
<string name="balance_mvr_credit">MVR Available Credit</string>
<string name="balance_usd_credit">USD Available Credit</string>
<string name="card_support_wip">Card Support</string> <string name="card_support_wip">Card Support</string>
<string name="transfer">Transfer</string> <string name="transfer">Transfer</string>
<string name="pay_mv_qr">PayMV QR</string> <string name="pay_mv_qr">PayMV QR</string>
@@ -175,10 +177,7 @@
<string name="verify">Verify</string> <string name="verify">Verify</string>
<!-- BML business OTP --> <!-- BML business OTP -->
<string name="bml_business_otp_title">Activate %s profile</string>
<string name="bml_business_otp_hint">%s OTP code</string>
<string name="bml_business_otp_sent">OTP sent via %s</string> <string name="bml_business_otp_sent">OTP sent via %s</string>
<string name="bml_business_otp_skip">Skip</string>
<!-- Home --> <!-- Home -->
<string name="transfer_same_account">This is your source account</string> <string name="transfer_same_account">This is your source account</string>
@@ -264,4 +263,13 @@
<string name="financing_completion_done">Fully paid</string> <string name="financing_completion_done">Fully paid</string>
<string name="financing_deal_no_fmt">Deal #%s</string> <string name="financing_deal_no_fmt">Deal #%s</string>
<string name="financing_completion_fmt">Completes %s</string> <string name="financing_completion_fmt">Completes %s</string>
<!-- BML Loans -->
<string name="loan_outstanding">Outstanding</string>
<string name="loan_monthly_repayment">Monthly Repayment</string>
<string name="loan_interest_rate">Interest Rate</string>
<string name="loan_start_date">Start Date</string>
<string name="loan_end_date">End Date</string>
<string name="loan_overdue_payments">Overdue Payments</string>
<string name="loan_rate_fmt">%.2f%%</string>
</resources> </resources>