add swipe for contact picker and contacts page

This commit is contained in:
2026-05-15 03:54:16 +05:00
parent 97ab31301e
commit deedd16ba2
6 changed files with 265 additions and 183 deletions
@@ -1,26 +1,28 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.util.Base64 import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import android.widget.PopupMenu
import sh.sar.basedbank.databinding.SheetContactPickerBinding import sh.sar.basedbank.databinding.SheetContactPickerBinding
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
@@ -30,106 +32,148 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
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: ContactPickerAdapter
// null = All, RECENTS_TAG = Recents, else = category id
private var activeCategoryId: String? = RECENTS_TAG
private val pendingHashes = mutableSetOf<String>() private val pendingHashes = mutableSetOf<String>()
private val profileImageHashes = mutableSetOf<String>() private val profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.mibSession
private var fromAccountNumber: String = ""
private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: PickerPagerAdapter
private data class TabDef(val tag: String?, val label: String)
private inner class PickerPagerAdapter(val pages: List<TabDef>) :
RecyclerView.Adapter<PickerPagerAdapter.PageHolder>() {
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
ContactPickerAdapter(
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
onSameAsFrom = {},
onImageNeeded = { hash -> fetchImage(hash) },
onItemLongClick = { accountNumber, anchor ->
if (page.tag == RECENTS_TAG) {
val menu = PopupMenu(requireContext(), anchor)
menu.menu.add(getString(R.string.recents_remove))
menu.setOnMenuItemClickListener {
RecentsCache.remove(requireContext(), accountNumber)
rebuildPage(i)
true
}
menu.show()
true
} else false
}
)
}
fun rebuildAll() = pages.indices.forEach { rebuildPage(it) }
fun rebuildPage(position: Int) {
pageAdapters[position].submitList(buildPageItems(pages[position].tag))
}
fun updateImage(hash: String, bitmap: Bitmap) =
pageAdapters.forEach { it.updateImage(hash, bitmap) }
inner class PageHolder(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
override fun getItemCount() = pages.size
override fun getItemViewType(position: Int) = position
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageHolder {
val rv = RecyclerView(parent.context).apply {
layoutManager = LinearLayoutManager(context)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
clipToPadding = false
val p24 = (24 * resources.displayMetrics.density).toInt()
setPadding(0, 0, 0, p24)
adapter = pageAdapters[viewType]
}
return PageHolder(rv)
}
override fun onBindViewHolder(holder: PageHolder, position: Int) {
rebuildPage(position)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetContactPickerBinding.inflate(inflater, container, false) _binding = SheetContactPickerBinding.inflate(inflater, container, false)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val selectedAccountNumber = arguments?.getString(ARG_FROM_ACCOUNT) ?: "" fromAccountNumber = arguments?.getString(ARG_FROM_ACCOUNT) ?: ""
adapter = ContactPickerAdapter( val initialPages = listOf(
onItemClick = { accountNumber, label -> TabDef(RECENTS_TAG, getString(R.string.contacts_tab_recents)),
val contacts = viewModel.contacts.value ?: emptyList() TabDef(MY_ACCOUNTS_TAG, getString(R.string.transfer_my_accounts)),
val accounts = viewModel.accounts.value ?: emptyList() TabDef(null, getString(R.string.contacts_tab_all))
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
when {
account?.profileType == "BML_PREPAID" -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E")
}
contact != null && !contact.transferCyDesc.equals("MVR", ignoreCase = true) -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
bundle.putString(KEY_SUBTITLE, "${contact.benefBankName} · ${contact.benefAccount}")
bundle.putString(KEY_COLOR, contact.bankColor)
contact.customerImgHash?.let { bundle.putString(KEY_IMAGE_HASH, it) }
}
}
setFragmentResult(REQUEST_KEY, bundle)
dismiss()
},
onSameAsFrom = {},
onImageNeeded = { hash -> fetchImage(hash) },
onItemLongClick = { accountNumber, anchor ->
if (activeCategoryId == RECENTS_TAG) {
val menu = PopupMenu(requireContext(), anchor)
menu.menu.add(getString(R.string.recents_remove))
menu.setOnMenuItemClickListener {
RecentsCache.remove(requireContext(), accountNumber)
rebuildList(selectedAccountNumber)
true
}
menu.show()
true
} else false
}
) )
pagerAdapter = PickerPagerAdapter(initialPages)
binding.viewPager.adapter = pagerAdapter
binding.viewPager.offscreenPageLimit = 1
attachMediator(initialPages)
binding.sheetRecyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() }
binding.sheetRecyclerView.adapter = adapter
binding.etSheetSearch.addTextChangedListener { rebuildList(selectedAccountNumber) }
// Tabs: Recents | My Accounts | All | <categories...>
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_recents).apply { tag = RECENTS_TAG }
)
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.transfer_my_accounts).apply { tag = MY_ACCOUNTS_TAG }
)
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_all).apply { tag = null }
)
binding.sheetCategoryTabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
activeCategoryId = tab.tag as? String
rebuildList(selectedAccountNumber)
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
// Remove all tabs after "All" (index 2) and re-add categories val pages = buildList {
while (binding.sheetCategoryTabs.tabCount > 3) binding.sheetCategoryTabs.removeTabAt(3) add(TabDef(RECENTS_TAG, getString(R.string.contacts_tab_recents)))
for (cat in cats) { add(TabDef(MY_ACCOUNTS_TAG, getString(R.string.transfer_my_accounts)))
binding.sheetCategoryTabs.addTab( add(TabDef(null, getString(R.string.contacts_tab_all)))
binding.sheetCategoryTabs.newTab().setText(cat.categoryName).apply { tag = cat.id } cats.forEach { add(TabDef(it.id, it.categoryName)) }
)
} }
rebuildList(selectedAccountNumber) val savedPosition = binding.viewPager.currentItem
pagerAdapter = PickerPagerAdapter(pages)
binding.viewPager.adapter = pagerAdapter
attachMediator(pages)
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
} }
viewModel.contacts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) } viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) } viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
} }
private fun rebuildList(fromNumber: String) { private fun attachMediator(pages: List<TabDef>) {
mediator?.detach()
mediator = TabLayoutMediator(binding.sheetCategoryTabs, binding.viewPager) { tab, position ->
tab.text = pages[position].label
}.also { it.attach() }
}
private fun handlePickerSelection(accountNumber: String, label: String) {
val contacts = viewModel.contacts.value ?: emptyList()
val accounts = viewModel.accounts.value ?: emptyList()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
when {
account?.profileType == "BML_PREPAID" -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E")
}
contact != null && !contact.transferCyDesc.equals("MVR", ignoreCase = true) -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
bundle.putString(KEY_SUBTITLE, "${contact.benefBankName} · ${contact.benefAccount}")
bundle.putString(KEY_COLOR, contact.bankColor)
contact.customerImgHash?.let { bundle.putString(KEY_IMAGE_HASH, it) }
}
}
setFragmentResult(REQUEST_KEY, bundle)
dismiss()
}
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
val search = binding.etSheetSearch.text?.toString()?.trim() ?: "" val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
val items = mutableListOf<ContactPickerAdapter.PickerItem>() val items = mutableListOf<ContactPickerAdapter.PickerItem>()
if (activeCategoryId == RECENTS_TAG) { if (tabTag == RECENTS_TAG) {
val recents = RecentsCache.load(requireContext()) val recents = RecentsCache.load(requireContext())
val filtered = if (search.isBlank()) recents else recents.filter { val filtered = if (search.isBlank()) recents else recents.filter {
it.displayName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) it.displayName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -138,25 +182,24 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
if (r.isProfileImage && r.imageHash != null) profileImageHashes.add(r.imageHash) if (r.isProfileImage && r.imageHash != null) profileImageHashes.add(r.imageHash)
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = r.accountNumber, accountNumber = r.accountNumber,
displayName = r.displayName, displayName = r.displayName,
subtitle = r.subtitle, subtitle = r.subtitle,
colorHex = r.colorHex, colorHex = r.colorHex,
isSameAsFrom = r.accountNumber == fromNumber, isSameAsFrom = r.accountNumber == fromAccountNumber,
imageHash = r.imageHash imageHash = r.imageHash
)) ))
} }
adapter.submitList(items) return items
return
} }
val accounts = viewModel.accounts.value ?: emptyList() val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList() val contacts = viewModel.contacts.value ?: emptyList()
val fromAccount = accounts.find { it.accountNumber == fromNumber } val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: "" val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: "" val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" val fromIsCard = fromAccount?.profileType == "BML_PREPAID"
if (activeCategoryId == MY_ACCOUNTS_TAG) { if (tabTag == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" } val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" } val cards = accounts.filter { it.profileType == "BML_PREPAID" }
@@ -167,14 +210,14 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.accounts))) items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.accounts)))
for (acc in filteredRegular) { for (acc in filteredRegular) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromNumber val isSame = acc.accountNumber == fromAccountNumber
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash,
inactiveReason = if (isSame) null inactiveReason = if (isSame) null
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName) else currencyMismatchReason(fromCurrency, acc.currencyName)
@@ -189,15 +232,15 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.cards))) items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.cards)))
for (acc in filteredCards) { for (acc in filteredCards) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromNumber val isSame = acc.accountNumber == fromAccountNumber
val isActive = acc.statusDesc.equals("Active", ignoreCase = true) val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash,
inactiveReason = if (isSame) null inactiveReason = if (isSame) null
else if (!isActive) acc.statusDesc else if (!isActive) acc.statusDesc
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
@@ -205,35 +248,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
)) ))
} }
} }
return items
adapter.submitList(items)
return
} }
val filteredContacts = contacts.filter { contact -> val filteredContacts = contacts.filter { contact ->
val matchesCat = activeCategoryId == null || contact.benefCategoryId == activeCategoryId val matchesCat = tabTag == null || contact.benefCategoryId == tabTag
val matchesSearch = search.isBlank() || val matchesSearch = search.isBlank() ||
contact.benefNickName.contains(search, ignoreCase = true) || contact.benefNickName.contains(search, ignoreCase = true) ||
contact.benefName.contains(search, ignoreCase = true) || contact.benefName.contains(search, ignoreCase = true) ||
contact.benefAccount.contains(search) contact.benefAccount.contains(search)
matchesCat && matchesSearch matchesCat && matchesSearch
} }
for (contact in filteredContacts) {
if (filteredContacts.isNotEmpty()) { items.add(ContactPickerAdapter.PickerItem.Row(
for (contact in filteredContacts) { accountNumber = contact.benefAccount,
items.add(ContactPickerAdapter.PickerItem.Row( displayName = contact.benefNickName,
accountNumber = contact.benefAccount, subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
displayName = contact.benefNickName, colorHex = contact.bankColor,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}", isSameAsFrom = contact.benefAccount == fromAccountNumber,
colorHex = contact.bankColor, imageHash = contact.customerImgHash,
isSameAsFrom = contact.benefAccount == fromNumber, inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
imageHash = contact.customerImgHash, ))
inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
))
}
} }
return items
adapter.submitList(items)
} }
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
@@ -249,7 +286,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
adapter.updateImage(hash, bitmap) pagerAdapter.updateImage(hash, bitmap)
} }
} catch (_: Exception) { } catch (_: Exception) {
pendingHashes.remove(hash) pendingHashes.remove(hash)
@@ -120,6 +120,8 @@ class ContactsAdapter(
) )
} }
binding.tvRealName.text = contact.benefName
val vis = if (expanded) View.VISIBLE else View.GONE val vis = if (expanded) View.VISIBLE else View.GONE
binding.dividerExpand.visibility = vis binding.dividerExpand.visibility = vis
binding.expandedSection.visibility = vis binding.expandedSection.visibility = vis
@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.util.Base64 import android.util.Base64
@@ -13,7 +14,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.tabs.TabLayout import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -31,14 +33,65 @@ class ContactsFragment : Fragment() {
private var _binding: FragmentContactsBinding? = null private var _binding: FragmentContactsBinding? = null
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: ContactsAdapter
private val pendingHashes = mutableSetOf<String>() private val pendingHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.mibSession
private var categories: List<MibBeneficiaryCategory> = emptyList() private var allContacts: List<MibBeneficiary> = emptyList()
private var activeCategoryId: String? = null private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter
private data class TabPage(val categoryId: String?, val label: String)
private inner class ContactsPagerAdapter(val pages: List<TabPage>) :
RecyclerView.Adapter<ContactsPagerAdapter.PageHolder>() {
private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter(
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) }
).also { a ->
a.setFilter(page.categoryId, currentSearch)
a.updateContacts(allContacts)
}
}
fun updateContacts(contacts: List<MibBeneficiary>) =
contactAdapters.forEach { it.updateContacts(contacts) }
fun updateSearch(query: String) =
pages.forEachIndexed { i, page -> contactAdapters[i].setFilter(page.categoryId, query) }
fun updateImage(hash: String, bitmap: Bitmap) =
contactAdapters.forEach { it.updateImage(hash, bitmap) }
inner class PageHolder(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
override fun getItemCount() = pages.size
override fun getItemViewType(position: Int) = position
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageHolder {
val rv = RecyclerView(parent.context).apply {
layoutManager = LinearLayoutManager(context)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
clipToPadding = false
val p4 = (4 * density).toInt()
val p80 = (80 * density).toInt()
setPadding(0, p4, 0, p80)
adapter = contactAdapters[viewType]
}
return PageHolder(rv)
}
override fun onBindViewHolder(holder: PageHolder, position: Int) {}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false) _binding = FragmentContactsBinding.inflate(inflater, container, false)
@@ -46,74 +99,61 @@ class ContactsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ContactsAdapter( pagerAdapter = ContactsPagerAdapter(listOf(TabPage(null, getString(R.string.contacts_tab_all))))
onImageNeeded = { hash -> fetchImage(hash) }, binding.viewPager.adapter = pagerAdapter
onDeleteClick = { contact -> confirmDelete(contact) }, binding.viewPager.offscreenPageLimit = 1
onTransferClick = { contact ->
val fragment = TransferFragment.newInstance( attachMediator(pagerAdapter.pages)
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
imageHash = contact.customerImgHash
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.etSearch.addTextChangedListener { text -> binding.etSearch.addTextChangedListener { text ->
adapter.setFilter(activeCategoryId, text?.toString() ?: "") currentSearch = text?.toString() ?: ""
pagerAdapter.updateSearch(currentSearch)
} }
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
activeCategoryId = tab.tag as? String
adapter.setFilter(activeCategoryId, binding.etSearch.text?.toString() ?: "")
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
binding.fabAddContact.setOnClickListener { binding.fabAddContact.setOnClickListener {
AddContactSheetFragment().show(childFragmentManager, "add_contact") AddContactSheetFragment().show(childFragmentManager, "add_contact")
} }
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
categories = cats rebuildPager(cats)
rebuildTabs(cats)
} }
viewModel.contacts.observe(viewLifecycleOwner) { contacts -> viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
adapter.updateContacts(contacts) allContacts = contacts
binding.recyclerView.visibility = if (contacts.isEmpty()) View.GONE else View.VISIBLE pagerAdapter.updateContacts(contacts)
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
} }
} }
private fun rebuildTabs(cats: List<MibBeneficiaryCategory>) { private fun attachMediator(pages: List<TabPage>) {
binding.tabLayout.clearOnTabSelectedListeners() mediator?.detach()
binding.tabLayout.removeAllTabs() mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.text = pages[position].label
}.also { it.attach() }
}
binding.tabLayout.addTab( private fun rebuildPager(cats: List<MibBeneficiaryCategory>) {
binding.tabLayout.newTab().setText(R.string.contacts_tab_all).apply { tag = null } val pages = buildList {
) add(TabPage(null, getString(R.string.contacts_tab_all)))
for (cat in cats) { cats.forEach { add(TabPage(it.id, it.categoryName)) }
binding.tabLayout.addTab(
binding.tabLayout.newTab().setText(cat.categoryName).apply { tag = cat.id }
)
} }
val savedPosition = binding.viewPager.currentItem
pagerAdapter = ContactsPagerAdapter(pages)
binding.viewPager.adapter = pagerAdapter
attachMediator(pages)
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
}
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { private fun openTransfer(contact: MibBeneficiary) {
override fun onTabSelected(tab: TabLayout.Tab) { val fragment = TransferFragment.newInstance(
activeCategoryId = tab.tag as? String accountNumber = contact.benefAccount,
adapter.setFilter(activeCategoryId, binding.etSearch.text?.toString() ?: "") displayName = contact.benefNickName,
} subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
override fun onTabUnselected(tab: TabLayout.Tab) {} colorHex = contact.bankColor,
override fun onTabReselected(tab: TabLayout.Tab) {} imageHash = contact.customerImgHash
}) )
(requireActivity() as HomeActivity).showWithBackStack(fragment)
} }
private fun confirmDelete(contact: MibBeneficiary) { private fun confirmDelete(contact: MibBeneficiary) {
@@ -179,7 +219,7 @@ class ContactsFragment : Fragment() {
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) { adapter.updateImage(hash, bitmap) } withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
} catch (_: Exception) { } catch (_: Exception) {
pendingHashes.remove(hash) pendingHashes.remove(hash)
} }
@@ -46,13 +46,10 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView <androidx.viewpager2.widget.ViewPager2
android:id="@+id/recyclerView" android:id="@+id/viewPager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent" />
android:clipToPadding="false"
android:paddingVertical="4dp"
android:paddingBottom="80dp" />
<ProgressBar <ProgressBar
android:id="@+id/loadingView" android:id="@+id/loadingView"
+8
View File
@@ -87,6 +87,14 @@
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:visibility="gone"> android:visibility="gone">
<TextView
android:id="@+id/tvRealName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:paddingBottom="8dp" />
<!-- Action buttons --> <!-- Action buttons -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -37,11 +37,9 @@
app:tabMode="scrollable" app:tabMode="scrollable"
app:tabGravity="start" /> app:tabGravity="start" />
<androidx.recyclerview.widget.RecyclerView <androidx.viewpager2.widget.ViewPager2
android:id="@+id/sheetRecyclerView" android:id="@+id/viewPager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="400dp" android:layout_height="400dp" />
android:clipToPadding="false"
android:paddingBottom="24dp" />
</LinearLayout> </LinearLayout>