contacts, contact picker and trnasfer account look up ui

This commit is contained in:
2026-05-14 05:24:23 +05:00
parent c49cce0cf2
commit b452940ed0
19 changed files with 1284 additions and 35 deletions
@@ -0,0 +1,125 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
class ContactPickerAdapter(
private val onItemClick: (accountNumber: String, label: String) -> Unit,
private val onSameAsFrom: () -> Unit,
private val onImageNeeded: ((hash: String) -> Unit)? = null,
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
sealed class PickerItem {
data class Header(val title: String) : PickerItem()
data class Row(
val accountNumber: String,
val displayName: String,
val subtitle: String,
val colorHex: String,
val isSameAsFrom: Boolean = false,
val isManualEntry: Boolean = false,
val imageHash: String? = null
) : PickerItem()
}
private var items: List<PickerItem> = emptyList()
private val imageCache = mutableMapOf<String, Bitmap>()
fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap
val idx = items.indexOfFirst { it is PickerItem.Row && it.imageHash == hash }
if (idx >= 0) notifyItemChanged(idx)
}
fun submitList(newItems: List<PickerItem>) {
items = newItems
notifyDataSetChanged()
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ROW = 1
}
override fun getItemViewType(position: Int) = when (items[position]) {
is PickerItem.Header -> TYPE_HEADER
is PickerItem.Row -> TYPE_ROW
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> HeaderVH(ItemPickerSectionHeaderBinding.inflate(inflater, parent, false))
else -> RowVH(ItemPickerRowBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is PickerItem.Header -> (holder as HeaderVH).bind(item)
is PickerItem.Row -> (holder as RowVH).bind(item)
}
}
override fun getItemCount() = items.size
inner class HeaderVH(private val binding: ItemPickerSectionHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: PickerItem.Header) {
binding.tvHeader.text = item.title
}
}
inner class RowVH(private val binding: ItemPickerRowBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: PickerItem.Row) {
binding.tvPrimary.text = item.displayName
binding.tvSecondary.text = item.subtitle
val cached = item.imageHash?.let { imageCache[it] }
if (cached != null) {
binding.ivIcon.setImageBitmap(cached)
} else {
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
}
binding.root.alpha = if (item.isSameAsFrom) 0.4f else 1.0f
binding.root.setOnClickListener {
if (item.isSameAsFrom) onSameAsFrom()
else onItemClick(item.accountNumber, item.displayName)
}
binding.root.setOnLongClickListener { view ->
onItemLongClick?.invoke(item.accountNumber, view) ?: false
}
}
private fun makeInitialsBitmap(letter: String, colorHex: String, context: Context): Bitmap {
val sizePx = (context.resources.displayMetrics.density * 44).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
}
}
@@ -0,0 +1,222 @@
package sh.sar.basedbank.ui.home
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import android.widget.PopupMenu
import sh.sar.basedbank.databinding.SheetContactPickerBinding
import sh.sar.basedbank.util.RecentsCache
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetContactPickerBinding? = null
private val binding get() = _binding!!
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 profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetContactPickerBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val selectedAccountNumber = arguments?.getString(ARG_FROM_ACCOUNT) ?: ""
adapter = ContactPickerAdapter(
onItemClick = { accountNumber, label ->
setFragmentResult(REQUEST_KEY, bundleOf(
KEY_ACCOUNT_NUMBER to accountNumber,
KEY_LABEL to label
))
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
}
)
binding.sheetRecyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.sheetRecyclerView.adapter = adapter
binding.etSheetSearch.addTextChangedListener { rebuildList(selectedAccountNumber) }
// Tabs: Recents | 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.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 ->
// Remove all tabs after "All" (index 1) and re-add categories
while (binding.sheetCategoryTabs.tabCount > 2) binding.sheetCategoryTabs.removeTabAt(2)
for (cat in cats) {
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(cat.categoryName).apply { tag = cat.id }
)
}
rebuildList(selectedAccountNumber)
}
viewModel.contacts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) }
}
private fun rebuildList(fromNumber: String) {
val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
if (activeCategoryId == RECENTS_TAG) {
val recents = RecentsCache.load(requireContext())
val filtered = if (search.isBlank()) recents else recents.filter {
it.displayName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
}
for (r in filtered) {
if (r.isProfileImage && r.imageHash != null) profileImageHashes.add(r.imageHash)
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = r.accountNumber,
displayName = r.displayName,
subtitle = r.subtitle,
colorHex = r.colorHex,
isSameAsFrom = r.accountNumber == fromNumber,
imageHash = r.imageHash
))
}
adapter.submitList(items)
return
}
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
if (activeCategoryId == null) {
val filtered = if (search.isBlank()) accounts else accounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
}
if (filtered.isNotEmpty()) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.transfer_my_accounts)))
for (acc in filtered) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E",
isSameAsFrom = acc.accountNumber == fromNumber,
imageHash = acc.profileImageHash
))
}
}
}
val filteredContacts = contacts.filter { contact ->
val matchesCat = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
val matchesSearch = search.isBlank() ||
contact.benefNickName.contains(search, ignoreCase = true) ||
contact.benefName.contains(search, ignoreCase = true) ||
contact.benefAccount.contains(search)
matchesCat && matchesSearch
}
if (filteredContacts.isNotEmpty()) {
if (activeCategoryId == null) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.nav_contacts)))
}
for (contact in filteredContacts) {
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
isSameAsFrom = contact.benefAccount == fromNumber,
imageHash = contact.customerImgHash
))
}
}
adapter.submitList(items)
}
private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return
val sess = session ?: return
lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (hash in profileImageHashes) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) {
adapter.updateImage(hash, bitmap)
}
} catch (_: Exception) {
pendingHashes.remove(hash)
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val REQUEST_KEY = "contact_picker"
const val KEY_ACCOUNT_NUMBER = "accountNumber"
const val KEY_LABEL = "label"
private const val ARG_FROM_ACCOUNT = "fromAccount"
private const val RECENTS_TAG = "__recents__"
fun newInstance(fromAccountNumber: String) = ContactPickerSheetFragment().apply {
arguments = bundleOf(ARG_FROM_ACCOUNT to fromAccountNumber)
}
}
}
@@ -1,18 +1,36 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibTransferClient
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
class TransferFragment : Fragment() {
@@ -21,6 +39,7 @@ class TransferFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferBinding.inflate(inflater, container, false)
@@ -28,8 +47,18 @@ class TransferFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
setupAccountDropdown(accounts)
setupFromDropdown()
setupAccountLookup()
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
prefillToFromContact(accountNumber, label)
}
binding.btnPickContact.setOnClickListener {
val sheet = ContactPickerSheetFragment.newInstance(selectedAccount?.accountNumber ?: "")
sheet.show(childFragmentManager, "contact_picker")
}
binding.btnTransfer.setOnClickListener {
@@ -37,21 +66,188 @@ class TransferFragment : Fragment() {
}
}
private fun setupAccountDropdown(accounts: List<MibAccount>) {
val adapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter)
private fun setupFromDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val adapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter)
if (accounts.isNotEmpty() && selectedAccount == null) {
selectedAccount = accounts[0]
binding.actvFrom.setText(accounts[0].toDisplayString(), false)
if (accounts.isNotEmpty() && selectedAccount == null) {
selectedAccount = accounts[0]
binding.actvFrom.setText(accounts[0].toDisplayString(), false)
}
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
selectedAccount = accounts[position]
binding.actvFrom.setText(accounts[position].toDisplayString(), false)
}
}
}
private fun setupAccountLookup() {
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
binding.btnClearToInfo.setOnClickListener {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.tilTo.error = null
}
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
selectedAccount = accounts[position]
binding.actvFrom.setText(accounts[position].toDisplayString(), false)
binding.etTo.addTextChangedListener {
binding.tilTo.error = null
if (binding.cardToInfo.visibility == View.VISIBLE) {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
}
}
}
private fun lookupAccount() {
val accountNumber = binding.etTo.text?.toString()?.trim() ?: ""
if (accountNumber.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return
}
val sess = session
if (sess == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
binding.tilTo.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null
val info = withContext(Dispatchers.IO) {
try {
MibTransferClient().lookup(sess, accountNumber)
} catch (e: MibLookupException) {
errorMsg = e.message
null
} catch (_: Exception) {
errorMsg = getString(R.string.transfer_account_not_found)
null
}
}
binding.tilTo.isEnabled = true
if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
val matchedContact = contacts.firstOrNull { it.benefAccount == info.accountNumber }
val displayName = matchedAcc?.accountBriefName
?: matchedContact?.benefNickName
?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedContact?.bankColor ?: "#607D8B"
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
saveToRecents(info)
when {
matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedContact?.customerImgHash != null ->
loadToPhoto(matchedContact.customerImgHash, isProfile = false)
}
} else {
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
}
}
}
private fun prefillToFromContact(accountNumber: String, label: String) {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.tilTo.error = null
binding.etTo.setText(accountNumber)
lookupAccount()
}
private fun loadToPhoto(hash: String, isProfile: Boolean) {
val sess = session ?: return
val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) {
if (_binding != null) binding.ivToPhoto.setImageBitmap(bitmap)
}
} catch (_: Exception) { }
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
private fun saveToRecents(info: MibIpsAccountInfo) {
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val acc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
if (acc != null) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E",
imageHash = acc.profileImageHash,
isProfileImage = true
))
return
}
val contact = contacts.firstOrNull { it.benefAccount == info.accountNumber }
if (contact != null) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
imageHash = contact.customerImgHash,
isProfileImage = false
))
return
}
// Manual entry not in contacts/accounts — save with resolved info
RecentsCache.save(requireContext(), RecentPick(
accountNumber = info.accountNumber,
displayName = info.accountName,
subtitle = "${info.accountNumber} · ${info.bankId}",
colorHex = "#607D8B",
imageHash = null,
isProfileImage = false
))
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.transfer)