implement viewing contacts

This commit is contained in:
2026-05-14 05:24:21 +05:00
parent e875163487
commit de29dc627f
12 changed files with 950 additions and 1 deletions
@@ -0,0 +1,110 @@
package sh.sar.basedbank.ui.home
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.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding
class ContactsAdapter(
private val onImageNeeded: (hash: String) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList()
private val imageCache = mutableMapOf<String, Bitmap>()
private var activeCategoryId: String? = null
private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) {
allContacts = contacts
applyFilter()
}
fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact ->
if (contact.customerImgHash == hash) notifyItemChanged(index)
}
}
fun setFilter(categoryId: String?, query: String) {
activeCategoryId = categoryId
searchQuery = query
applyFilter()
}
private fun applyFilter() {
displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery)
matchesCategory && matchesSearch
}
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemContactBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage)
}
override fun getItemCount() = displayed.size
inner class ViewHolder(private val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
binding.tvContactName.text = contact.benefNickName
binding.tvContactBank.text = contact.benefBankName
binding.tvContactAccount.text = "${contact.benefAccount} · ${contact.transferCyDesc}"
if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo)
} else {
binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
)
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = binding.ivContactPhoto.context.resources
.getDimensionPixelSize(android.R.dimen.app_icon_size)
.coerceAtLeast(96)
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 letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
val textY = sizePx / 2f - (metrics.ascent + metrics.descent) / 2f
canvas.drawText(letter, sizePx / 2f, textY, paint)
return bm
}
}
}
@@ -0,0 +1,123 @@
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.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
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.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding
class ContactsFragment : Fragment() {
private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: ContactsAdapter
private val pendingHashes = mutableSetOf<String>()
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private var categories: List<MibBeneficiaryCategory> = emptyList()
private var activeCategoryId: String? = null // null = All
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ContactsAdapter { hash -> fetchImage(hash) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.etSearch.addTextChangedListener { text ->
adapter.setFilter(activeCategoryId, text?.toString() ?: "")
}
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) {}
})
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
categories = cats
rebuildTabs(cats)
}
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
adapter.updateContacts(contacts)
binding.recyclerView.visibility = if (contacts.isEmpty()) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
}
}
private fun rebuildTabs(cats: List<MibBeneficiaryCategory>) {
binding.tabLayout.clearOnTabSelectedListeners()
binding.tabLayout.removeAllTabs()
binding.tabLayout.addTab(
binding.tabLayout.newTab().setText(R.string.contacts_tab_all).apply { tag = null }
)
for (cat in cats) {
binding.tabLayout.addTab(
binding.tabLayout.newTab().setText(cat.categoryName).apply { tag = cat.id }
)
}
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) {}
})
}
private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return
val sess = session ?: return
val client = MibContactsClient()
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = client.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) // allow retry
}
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_contacts)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -17,11 +17,15 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityHomeBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache
@@ -49,6 +53,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> show(DashboardFragment())
R.id.nav_add_account -> startActivity(Intent(this, LoginActivity::class.java))
R.id.nav_accounts -> show(AccountsFragment())
R.id.nav_contacts -> show(ContactsFragment())
R.id.nav_finances -> show(FinancingFragment())
R.id.nav_settings -> show(SettingsFragment())
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
@@ -59,18 +64,27 @@ class HomeActivity : AppCompatActivity() {
// Load data
val app = application as BasedBankApp
if (app.accounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, financing fetched in background
// Came from fresh manual login — accounts ready, rest fetched in background
viewModel.accounts.value = app.accounts
AccountCache.save(this, app.accounts)
val cached = FinancingCache.load(this)
if (cached.isNotEmpty()) viewModel.financing.value = cached
val cachedContacts = ContactsCache.loadContacts(this)
if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts
val cachedCats = ContactsCache.loadCategories(this)
if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats
refreshFinancing(app.mibSession, app.mibProfiles)
refreshContacts(app.mibSession, app.mibProfiles)
} else {
// Came from lock screen — show caches immediately, refresh everything in background
val cached = AccountCache.load(this)
if (cached.isNotEmpty()) viewModel.accounts.value = cached
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedContacts = ContactsCache.loadContacts(this)
if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts
val cachedCats = ContactsCache.loadCategories(this)
if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats
val creds = CredentialStore(this).loadMibCredentials()
if (creds != null) autoRefresh(creds)
}
@@ -117,6 +131,41 @@ class HomeActivity : AppCompatActivity() {
}
val app = application as BasedBankApp
refreshFinancing(app.mibSession, app.mibProfiles)
refreshContacts(app.mibSession, app.mibProfiles)
}
}
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val contactsClient = MibContactsClient()
lifecycleScope.launch {
try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>()
val seenCategories = mutableSetOf<String>()
val contacts = mutableListOf<MibBeneficiary>()
val categories = mutableListOf<MibBeneficiaryCategory>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (cat in contactsClient.fetchCategories(session)) {
if (seenCategories.add(cat.id)) categories.add(cat)
}
for (contact in contactsClient.fetchContacts(session)) {
if (seenContacts.add(contact.benefNo)) contacts.add(contact)
}
} catch (_: Exception) { /* profile has no contacts access */ }
}
Pair(contacts, categories)
}
if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
viewModel.contacts.postValue(allContacts)
viewModel.contactCategories.postValue(allCategories)
}
} catch (_: Exception) { /* keep cached data */ }
}
}
@@ -3,9 +3,13 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibFinanceDeal
class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<MibAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList())
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList())
}