Merge branch 'feat/login-sort-order'
Auto Tag on Version Change / check-version (push) Failing after 4s
Auto Tag on Version Change / check-version (push) Failing after 4s
This commit is contained in:
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = bmlItems + mibItems
|
||||
val rank = credStore.loginRank()
|
||||
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
|
||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||
val ordered = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
|
||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
|
||||
expandedPositions.clear()
|
||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
val mibItems = mibDeals.map { Item.Mib(it) }
|
||||
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.util.CredentialStore
|
||||
|
||||
class FinancingFragment : Fragment() {
|
||||
|
||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||
|
||||
adapter.update(latestMibDeals, bmlLoans)
|
||||
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
|
||||
val order = CredentialStore(requireContext()).getLoginOrder()
|
||||
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
|
||||
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
|
||||
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
|
||||
|
||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
sealed class CardItem {
|
||||
data class Mib(val card: MibCard) : CardItem()
|
||||
data class Bml(val account: BankAccount) : CardItem()
|
||||
abstract val loginTag: String
|
||||
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
|
||||
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
|
||||
}
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
|
||||
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
|
||||
override fun setValue(value: List<BankAccount>?) {
|
||||
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||
}
|
||||
}
|
||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||
/** BML loan details keyed by account internalId. */
|
||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||
|
||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
||||
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
|
||||
override fun setValue(value: List<MibCard>?) {
|
||||
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||
}
|
||||
}
|
||||
|
||||
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
|
||||
|
||||
/** Re-applies the login order after the user changes it. */
|
||||
fun resortByLoginOrder() {
|
||||
accounts.value = accounts.value
|
||||
mibCards.value = mibCards.value
|
||||
}
|
||||
|
||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||
|
||||
|
||||
@@ -86,17 +86,19 @@ class OtpFragment : Fragment() {
|
||||
val store = CredentialStore(requireContext())
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
val entries = mutableListOf<OtpEntry>()
|
||||
val rank = store.loginRank()
|
||||
val tagged = mutableListOf<Pair<String, OtpEntry>>()
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||
val name = store.loadMibFullName(loginId)
|
||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||
}
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
||||
}
|
||||
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
|
||||
|
||||
val adapter = OtpAdapter(entries)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
|
||||
@@ -904,7 +904,9 @@ class CardsFragment : Fragment() {
|
||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
||||
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
|
||||
val rank = store.loginRank()
|
||||
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
|
||||
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
|
||||
// Move default BML card to front
|
||||
cards = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
|
||||
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
@@ -359,65 +362,86 @@ class SettingsLoginsFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||
|
||||
private fun buildLoginsSection() {
|
||||
val ctx = requireContext()
|
||||
val store = CredentialStore(ctx)
|
||||
val container = binding.loginsContainer
|
||||
container.removeAllViews()
|
||||
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
val entries = mutableListOf<LoginEntry>()
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
for (loginId in mibLoginIds) {
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val profile = store.loadMibUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||
val mibProfiles = store.loadMibProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
|
||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in bmlLoginIds) {
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val profile = store.loadBmlUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
|
||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in fahipayLoginIds) {
|
||||
for (loginId in store.getFahipayLoginIds()) {
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in mfaisaLoginIds) {
|
||||
for (loginId in store.getMfaisaLoginIds()) {
|
||||
val profile = store.loadMfaisaUserProfile(loginId)
|
||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
|
||||
showMfaisaLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
val rank = store.loginRank()
|
||||
val sorted = entries.sortedBy { rank(it.key) }
|
||||
val draggable = sorted.size > 1
|
||||
for (entry in sorted) {
|
||||
addLoginRow(
|
||||
container, entry.logoRes, entry.displayName, entry.onClick,
|
||||
dragTag = entry.key.takeIf { draggable },
|
||||
onReordered = { order ->
|
||||
store.setLoginOrder(order)
|
||||
viewModel.resortByLoginOrder()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||
private fun addLoginRow(
|
||||
container: LinearLayout,
|
||||
logoRes: Int,
|
||||
displayName: String,
|
||||
onClick: () -> Unit,
|
||||
dragTag: String? = null,
|
||||
onReordered: (List<String>) -> Unit = {}
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
||||
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
|
||||
isClickable = true; isFocusable = true
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
setOnClickListener { onClick() }
|
||||
tag = dragTag
|
||||
}
|
||||
val logo = ImageView(ctx).apply {
|
||||
setImageResource(logoRes)
|
||||
@@ -430,9 +454,82 @@ class SettingsLoginsFragment : Fragment() {
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
row.addView(logo); row.addView(tvName)
|
||||
if (dragTag != null) {
|
||||
val handle = ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_reorder_lines)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
contentDescription = getString(R.string.drag_to_reorder)
|
||||
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
|
||||
}
|
||||
row.addView(handle)
|
||||
attachDragHandle(handle, row, container, onReordered)
|
||||
}
|
||||
container.addView(row)
|
||||
}
|
||||
|
||||
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
|
||||
val ctx = handle.context
|
||||
val lift = 8 * ctx.resources.displayMetrics.density
|
||||
val dragBg = android.graphics.drawable.ColorDrawable(
|
||||
com.google.android.material.color.MaterialColors.getColor(
|
||||
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
|
||||
var lastY = 0f
|
||||
var startIndex = 0
|
||||
var savedBg: android.graphics.drawable.Drawable? = null
|
||||
|
||||
handle.setOnTouchListener { v, ev ->
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
v.parent.requestDisallowInterceptTouchEvent(true)
|
||||
lastY = ev.rawY
|
||||
startIndex = group.indexOfChild(row)
|
||||
row.animate().cancel()
|
||||
savedBg = row.background
|
||||
row.background = dragBg
|
||||
row.translationZ = lift
|
||||
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
row.translationY += ev.rawY - lastY
|
||||
lastY = ev.rawY
|
||||
var index = group.indexOfChild(row)
|
||||
while (index < group.childCount - 1) {
|
||||
val next = group.getChildAt(index + 1)
|
||||
if (row.translationY <= next.height / 2f) break
|
||||
group.removeView(next); group.addView(next, index)
|
||||
row.translationY -= next.height
|
||||
next.translationY = row.height.toFloat()
|
||||
next.animate().translationY(0f).setDuration(150).start()
|
||||
index++
|
||||
}
|
||||
while (index > 0) {
|
||||
val prev = group.getChildAt(index - 1)
|
||||
if (row.translationY >= -prev.height / 2f) break
|
||||
group.removeView(prev); group.addView(prev, index)
|
||||
row.translationY += prev.height
|
||||
prev.translationY = -row.height.toFloat()
|
||||
prev.animate().translationY(0f).setDuration(150).start()
|
||||
index--
|
||||
}
|
||||
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
|
||||
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
v.parent.requestDisallowInterceptTouchEvent(false)
|
||||
row.animate().translationY(0f).translationZ(0f).setDuration(150)
|
||||
.withEndAction { row.background = savedBg }
|
||||
.start()
|
||||
if (group.indexOfChild(row) != startIndex) {
|
||||
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMibLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
|
||||
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class CredentialStore(context: Context) {
|
||||
|
||||
companion object {
|
||||
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
|
||||
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||
private val keyAlias = "basedbank_credential_key"
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
|
||||
data class FahipayCredentials(val idCard: String, val password: String)
|
||||
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
||||
|
||||
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
|
||||
|
||||
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
|
||||
fun getLoginOrder(): List<String> {
|
||||
val all = getMibLoginIds().map { loginKey("mib", it) } +
|
||||
getBmlLoginIds().map { loginKey("bml", it) } +
|
||||
getFahipayLoginIds().map { loginKey("fahipay", it) } +
|
||||
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
|
||||
val saved = try {
|
||||
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
return saved.filter { it in all } + all.filter { it !in saved }
|
||||
}
|
||||
|
||||
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
|
||||
fun loginRank(): (String) -> Int {
|
||||
val order = getLoginOrder()
|
||||
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
|
||||
}
|
||||
|
||||
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
|
||||
fun setLoginOrder(keys: List<String>) {
|
||||
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
|
||||
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
|
||||
setMibLoginIds(idsFor("mib"))
|
||||
setBmlLoginIds(idsFor("bml"))
|
||||
setFahipayLoginIds(idsFor("fahipay"))
|
||||
setMfaisaLoginIds(idsFor("mfaisa"))
|
||||
}
|
||||
|
||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||
|
||||
fun getMibLoginIds(): List<String> {
|
||||
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||
|
||||
fun setMibLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMibLoginIds().toSet()) return
|
||||
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMibLoginId(loginId: String) {
|
||||
val ids = getMibLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -160,6 +201,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||
|
||||
fun setBmlLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getBmlLoginIds().toSet()) return
|
||||
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addBmlLoginId(loginId: String) {
|
||||
val ids = getBmlLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -316,6 +362,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||
|
||||
fun setFahipayLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getFahipayLoginIds().toSet()) return
|
||||
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addFahipayLoginId(loginId: String) {
|
||||
val ids = getFahipayLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -473,6 +524,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||
|
||||
fun setMfaisaLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMfaisaLoginIds().toSet()) return
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
|
||||
@@ -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="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
|
||||
</vector>
|
||||
@@ -9,7 +9,8 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="16dp"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLoginsTitle"
|
||||
@@ -20,10 +21,12 @@
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
|
||||
<LinearLayout
|
||||
android:id="@+id/loginsContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="-12dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
||||
@@ -145,4 +145,5 @@
|
||||
<!-- Connectivity banner -->
|
||||
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
||||
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
||||
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
|
||||
</resources>
|
||||
|
||||
@@ -391,4 +391,5 @@
|
||||
<!-- Connectivity banner -->
|
||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
||||
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
||||
<string name="drag_to_reorder">Drag to reorder</string>
|
||||
</resources>
|
||||
|
||||
@@ -155,6 +155,15 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
|
||||
|
||||
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
|
||||
|
||||
### Login Order
|
||||
|
||||
The user sets the order of their logins in [Settings → Logins](14-settings.md#login-order). It applies everywhere accounts are listed:
|
||||
|
||||
- `HomeViewModel.accounts` and `HomeViewModel.mibCards` sort themselves by `CredentialStore.loginRank()` on every `setValue` / `postValue`, so every observer (accounts list, transfer dropdown, contact picker "My accounts", PayMV QR, BML QR pay, transfer history, BML loans in Financing) gets the same order with no per-screen sorting. The sort is stable, so accounts from the same login keep the order the bank returned them in.
|
||||
- Combined MIB + BML card lists (dashboard stack, [Cards](22-cards.md)) are sorted by `CardItem.loginTag`.
|
||||
- [Financing](13-financing.md) places the MIB and BML sections by whichever bank's first login ranks higher.
|
||||
- The [OTP Screen](10-otp-screen.md) sorts its entries by login.
|
||||
|
||||
---
|
||||
|
||||
## MIB Session KeepAlive
|
||||
|
||||
@@ -30,7 +30,7 @@ Standard RFC 6238 TOTP:
|
||||
|
||||
## Supported Banks
|
||||
|
||||
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt:93-98`). Seeds are per-`loginId` in `CredentialStore`.
|
||||
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
|
||||
|
||||
| Bank | Seed source | Card label |
|
||||
|---|---|---|
|
||||
|
||||
@@ -8,6 +8,8 @@ Aggregates financing products across banks — MIB promotional deals and BML loa
|
||||
|
||||
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
|
||||
|
||||
MIB deals carry no login tag, so the two sections are ordered as blocks: MIB first if the first MIB login ranks above the first BML login in the user's [login order](00-app-overview.md#login-order), otherwise BML first. BML loans within their section follow `HomeViewModel.accounts`, which is already in login order.
|
||||
|
||||
---
|
||||
|
||||
## MIB Deals Section
|
||||
|
||||
@@ -33,6 +33,20 @@ Each profile entry shows:
|
||||
- Profile image (circular avatar)
|
||||
- Visibility toggle switch
|
||||
|
||||
### Login Order
|
||||
|
||||
All logins are shown as one list, across banks. When there is more than one login, each row gets a three-line handle (`ic_reorder_lines`) on the right; press and drag it to move the login up or down (`SettingsLoginsFragment.attachDragHandle()`). Tapping the rest of the row still opens the login details.
|
||||
|
||||
On drop the order is saved with `CredentialStore.setLoginOrder()`, which:
|
||||
- writes the combined order to the `login_order` pref as a JSON array of login tags (`mib_<id>`, `bml_<id>`, `fahipay_<id>`, `mfaisa_<id>` — the same format as `BankAccount.loginTag`)
|
||||
- rewrites each bank's own list (`mib_login_ids`, `bml_login_ids`, …) in the same relative order, so code that walks one bank's logins stays consistent
|
||||
|
||||
It then calls `HomeViewModel.resortByLoginOrder()` so already-open screens re-sort immediately.
|
||||
|
||||
`CredentialStore.getLoginOrder()` returns the effective order: saved tags that still exist, followed by any logins not yet ordered (e.g. newly added) in the default MIB → BML → Fahipay → M-Faisa order. `loginRank()` turns that into a sort key; unknown tags sort last.
|
||||
|
||||
See [Login Order](00-app-overview.md#login-order) for where the order is applied.
|
||||
|
||||
### Visibility Toggle
|
||||
|
||||
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
|
||||
|
||||
@@ -29,6 +29,7 @@ A horizontal `RecyclerView` with `LinearSnapHelper` that paginates BML cards + M
|
||||
|
||||
- BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"`
|
||||
- MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`)
|
||||
- Cards are sorted by the user's [login order](00-app-overview.md#login-order)
|
||||
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
|
||||
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ A horizontal `RecyclerView` driven by `CardStackAdapter` with a `PagerSnapHelper
|
||||
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
|
||||
- MIB: `viewModel.mibCards`
|
||||
|
||||
The combined order is `bmlActive + mibActive + bmlInactive + mibInactive`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
|
||||
Active cards come first, then inactive ones; within each group cards are sorted by the user's [login order](00-app-overview.md#login-order) via `CardItem.loginTag`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
|
||||
|
||||
### Card Art
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
||||
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
|
||||
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
|
||||
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
|
||||
| [14 — Settings](14-settings.md) | Settings hub: Logins, Appearance, Privacy & Security, Notifications, Storage, About |
|
||||
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
|
||||
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
|
||||
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
|
||||
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |
|
||||
|
||||
Reference in New Issue
Block a user