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())
|
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) }
|
.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) }
|
.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 defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
val ordered = if (defaultNum != null) {
|
val ordered = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
|||||||
notifyDataSetChanged()
|
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()
|
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()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
|
|||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
class FinancingFragment : Fragment() {
|
class FinancingFragment : Fragment() {
|
||||||
|
|
||||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
|||||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
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()
|
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
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.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
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.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
sealed class CardItem {
|
sealed class CardItem {
|
||||||
data class Mib(val card: MibCard) : CardItem()
|
abstract val loginTag: String
|
||||||
data class Bml(val account: BankAccount) : CardItem()
|
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() {
|
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
/** 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())
|
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||||
/** BML loan details keyed by account internalId. */
|
/** BML loan details keyed by account internalId. */
|
||||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
|||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
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)
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
|
|||||||
@@ -86,17 +86,19 @@ class OtpFragment : Fragment() {
|
|||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
val app = requireActivity().application as BasedBankApp
|
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()) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
val name = store.loadMibFullName(loginId)
|
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()) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
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)
|
val adapter = OtpAdapter(entries)
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
|||||||
@@ -904,7 +904,9 @@ class CardsFragment : Fragment() {
|
|||||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||||
val mibInactive = 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
|
// Move default BML card to front
|
||||||
cards = if (defaultNum != null) {
|
cards = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
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.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -359,65 +362,86 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||||
|
|
||||||
private fun buildLoginsSection() {
|
private fun buildLoginsSection() {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val container = binding.loginsContainer
|
val container = binding.loginsContainer
|
||||||
container.removeAllViews()
|
container.removeAllViews()
|
||||||
|
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
val entries = mutableListOf<LoginEntry>()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
|
||||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
for (loginId in store.getMibLoginIds()) {
|
||||||
|
|
||||||
for (loginId in mibLoginIds) {
|
|
||||||
val profile = store.loadMibUserProfile(loginId)
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
val mibProfiles = store.loadMibProfiles(loginId)
|
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)
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
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)
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in fahipayLoginIds) {
|
for (loginId in store.getFahipayLoginIds()) {
|
||||||
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) {
|
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||||
showFahipayLoginDetails(store, loginId, profile)
|
showFahipayLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in mfaisaLoginIds) {
|
for (loginId in store.getMfaisaLoginIds()) {
|
||||||
val profile = store.loadMfaisaUserProfile(loginId)
|
val profile = store.loadMfaisaUserProfile(loginId)
|
||||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
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)
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addLoginRow(
|
||||||
|
container: LinearLayout,
|
||||||
|
logoRes: Int,
|
||||||
|
displayName: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
dragTag: String? = null,
|
||||||
|
onReordered: (List<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
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
|
isClickable = true; isFocusable = true
|
||||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
background = ta.getDrawable(0); ta.recycle()
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
setOnClickListener { onClick() }
|
setOnClickListener { onClick() }
|
||||||
|
tag = dragTag
|
||||||
}
|
}
|
||||||
val logo = ImageView(ctx).apply {
|
val logo = ImageView(ctx).apply {
|
||||||
setImageResource(logoRes)
|
setImageResource(logoRes)
|
||||||
@@ -430,9 +454,82 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
row.addView(logo); row.addView(tvName)
|
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)
|
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(
|
private fun showMibLoginDetails(
|
||||||
store: CredentialStore,
|
store: CredentialStore,
|
||||||
loginId: String,
|
loginId: String,
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
|
|||||||
|
|
||||||
class CredentialStore(context: Context) {
|
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 prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||||
private val keyAlias = "basedbank_credential_key"
|
private val keyAlias = "basedbank_credential_key"
|
||||||
private val transformation = "AES/GCM/NoPadding"
|
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 FahipayCredentials(val idCard: String, val password: String)
|
||||||
data class MfaisaCredentials(val msisdn: String, val pin: 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) ─────
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
|
|
||||||
fun getMibLoginIds(): List<String> {
|
fun getMibLoginIds(): List<String> {
|
||||||
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
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) {
|
private fun addMibLoginId(loginId: String) {
|
||||||
val ids = getMibLoginIds().toMutableList()
|
val ids = getMibLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -160,6 +201,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
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) {
|
private fun addBmlLoginId(loginId: String) {
|
||||||
val ids = getBmlLoginIds().toMutableList()
|
val ids = getBmlLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -316,6 +362,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
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) {
|
private fun addFahipayLoginId(loginId: String) {
|
||||||
val ids = getFahipayLoginIds().toMutableList()
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -473,6 +524,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
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) {
|
private fun addMfaisaLoginId(loginId: String) {
|
||||||
val ids = getMfaisaLoginIds().toMutableList()
|
val ids = getMfaisaLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
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_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp"
|
||||||
|
android:clipToPadding="false">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvLoginsTitle"
|
android:id="@+id/tvLoginsTitle"
|
||||||
@@ -20,10 +21,12 @@
|
|||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/loginsContainer"
|
android:id="@+id/loginsContainer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="-12dp"
|
||||||
android:orientation="vertical" />
|
android:orientation="vertical" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
|
|||||||
@@ -145,4 +145,5 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
||||||
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
||||||
|
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -391,4 +391,5 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
<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="connectivity_server_error">Connectivity issue with %s</string>
|
||||||
|
<string name="drag_to_reorder">Drag to reorder</string>
|
||||||
</resources>
|
</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.
|
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
|
## MIB Session KeepAlive
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Standard RFC 6238 TOTP:
|
|||||||
|
|
||||||
## Supported Banks
|
## 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 |
|
| 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`.
|
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
|
## MIB Deals Section
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ Each profile entry shows:
|
|||||||
- Profile image (circular avatar)
|
- Profile image (circular avatar)
|
||||||
- Visibility toggle switch
|
- 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
|
### 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.
|
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"`
|
- 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`)
|
- 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
|
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
|
||||||
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
|
- 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)`
|
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
|
||||||
- MIB: `viewModel.mibCards`
|
- 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
|
### 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) |
|
| [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) |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |
|
||||||
|
|||||||
Reference in New Issue
Block a user