delete and transfer contacts

This commit is contained in:
2026-05-14 05:45:51 +05:00
parent 5805b4cb51
commit 4c91a1aa0e
10 changed files with 348 additions and 69 deletions

View File

@@ -311,6 +311,22 @@ class BmlLoginFlow {
return parseContacts(json)
}
fun deleteContact(session: BmlSession, contactId: String): Boolean {
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/contacts/$contactId")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
}
}
private fun apiRequest(session: BmlSession, url: String) =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")

View File

@@ -182,6 +182,19 @@ class MibContactsClient {
}
}
fun deleteContact(session: MibSession, benefNo: String): Boolean {
val body = FormBody.Builder().add("benefNo", benefNo).build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/deleteBeneficiary")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
}
}
fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? {
val body = FormBody.Builder()
.add("imageHash", imageHash)

View File

@@ -1,28 +1,38 @@
package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
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.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding
class ContactsAdapter(
private val onImageNeeded: (hash: String) -> Unit
private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList()
private val imageCache = mutableMapOf<String, Bitmap>()
private val expandedPositions = mutableSetOf<Int>()
private var activeCategoryId: String? = null
private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) {
allContacts = contacts
expandedPositions.clear()
applyFilter()
}
@@ -36,6 +46,7 @@ class ContactsAdapter(
fun setFilter(categoryId: String?, query: String) {
activeCategoryId = categoryId
searchQuery = query
expandedPositions.clear()
applyFilter()
}
@@ -61,15 +72,42 @@ class ContactsAdapter(
val cachedImage = contact.customerImgHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage)
holder.bind(contact, cachedImage, position in expandedPositions)
holder.binding.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnClickListener
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
notifyItemChanged(pos)
}
holder.binding.root.setOnLongClickListener {
val ctx = it.context
val clipboard = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", contact.benefAccount))
Toast.makeText(ctx, contact.benefAccount, Toast.LENGTH_SHORT).show()
true
}
holder.binding.btnTransferContact.setOnClickListener {
onTransferClick(contact)
}
holder.binding.btnEditContact.setOnClickListener {
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
holder.binding.btnDeleteContact.setOnClickListener {
onDeleteClick(contact)
}
}
override fun getItemCount() = displayed.size
inner class ViewHolder(private val binding: ItemContactBinding) :
inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
fun bind(contact: MibBeneficiary, photo: Bitmap?, expanded: Boolean) {
binding.tvContactName.text = contact.benefNickName
binding.tvContactBank.text = contact.benefBankName
binding.tvContactAccount.text = "${contact.benefAccount} · ${contact.transferCyDesc}"
@@ -81,6 +119,10 @@ class ContactsAdapter(
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
)
}
val vis = if (expanded) View.VISIBLE else View.GONE
binding.dividerExpand.visibility = vis
binding.expandedSection.visibility = vis
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
@@ -88,22 +130,17 @@ class ContactsAdapter(
.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)
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
}

View File

@@ -6,6 +6,8 @@ import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -17,9 +19,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
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.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactsCache
class ContactsFragment : Fragment() {
@@ -29,10 +34,11 @@ class ContactsFragment : Fragment() {
private lateinit var adapter: ContactsAdapter
private val pendingHashes = mutableSetOf<String>()
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
private var categories: List<MibBeneficiaryCategory> = emptyList()
private var activeCategoryId: String? = null // null = All
private var activeCategoryId: String? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentContactsBinding.inflate(inflater, container, false)
@@ -40,7 +46,20 @@ class ContactsFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ContactsAdapter { hash -> fetchImage(hash) }
adapter = ContactsAdapter(
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact ->
val fragment = TransferFragment.newInstance(
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
@@ -97,6 +116,60 @@ class ContactsFragment : Fragment() {
})
}
private fun confirmDelete(contact: MibBeneficiary) {
AlertDialog.Builder(requireContext())
.setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName))
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteContact(contact: MibBeneficiary) {
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
}
if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact)
} else {
Toast.makeText(requireContext(), R.string.contact_delete_failed, Toast.LENGTH_SHORT).show()
}
}
}
private fun deleteBml(contact: MibBeneficiary): Boolean {
val sess = app.bmlSession ?: return false
val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: MibBeneficiary): Boolean {
val sess = session ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.benefNo)
} catch (_: Exception) { false }
}
private fun removeFromViewModel(contact: MibBeneficiary) {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") {
ContactsCache.saveBml(requireContext(), updated.filter { it.benefCategoryId == "BML" })
} else {
ContactsCache.save(
requireContext(),
updated.filter { it.benefCategoryId != "BML" },
viewModel.contactCategories.value ?: emptyList()
)
}
}
private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return
val sess = session ?: return
@@ -106,11 +179,9 @@ class ContactsFragment : Fragment() {
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)
}
withContext(Dispatchers.Main) { adapter.updateImage(hash, bitmap) }
} catch (_: Exception) {
pendingHashes.remove(hash) // allow retry
pendingHashes.remove(hash)
}
}
}

View File

@@ -47,6 +47,30 @@ class TransferFragment : Fragment() {
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession
companion object {
private const val ARG_ACCOUNT = "contact_account"
private const val ARG_NAME = "contact_name"
private const val ARG_SUBTITLE = "contact_subtitle"
private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash"
fun newInstance(
accountNumber: String,
displayName: String,
subtitle: String,
colorHex: String,
imageHash: String?
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, subtitle)
putString(ARG_COLOR, colorHex)
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
}
}
}
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
@@ -94,6 +118,17 @@ class TransferFragment : Fragment() {
binding.btnTransfer.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
// Pre-select contact if navigated from contacts page
arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly(
accountNumber = account,
displayName = arguments?.getString(ARG_NAME) ?: account,
subtitle = arguments?.getString(ARG_SUBTITLE) ?: account,
colorHex = arguments?.getString(ARG_COLOR) ?: "#607D8B",
imageHash = arguments?.getString(ARG_IMAGE_HASH)
)
}
}
private fun setupFromDropdown() {

View File

@@ -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="@android:color/black"
android:pathData="M16,1H4C2.9,1 2,1.9 2,3v14h2V3h12V1zM19,5H8C6.9,5 6,5.9 6,7v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2V7C22,5.9 21.1,5 19,5zM19,21H8V7h11V21z" />
</vector>

View File

@@ -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="@android:color/black"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>

View File

@@ -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="@android:color/black"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View File

@@ -1,63 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
android:orientation="vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivContactPhoto"
android:layout_width="48dp"
android:layout_height="48dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/tvContactName"
android:layout_width="0dp"
<!-- Header row — same look as before -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/ivContactPhoto" />
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<TextView
android:id="@+id/tvContactBank"
android:layout_width="0dp"
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivContactPhoto"
android:layout_width="48dp"
android:layout_height="48dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/tvContactName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/ivContactPhoto" />
<TextView
android:id="@+id/tvContactBank"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvContactName" />
<TextView
android:id="@+id/tvContactAccount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="1dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvContactBank"
app:layout_constraintBottom_toBottomOf="@id/ivContactPhoto" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Divider + expanded section, hidden by default -->
<View
android:id="@+id/dividerExpand"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="16dp"
android:background="?attr/colorOutlineVariant"
android:visibility="gone" />
<LinearLayout
android:id="@+id/expandedSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvContactName" />
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:visibility="gone">
<TextView
android:id="@+id/tvContactAccount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="1dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivContactPhoto"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvContactBank"
app:layout_constraintBottom_toBottomOf="@id/ivContactPhoto" />
<!-- Action buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTransferContact"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/transfer"
app:icon="@drawable/ic_nav_generic" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnEditContact"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:text="@string/contact_edit"
app:icon="@drawable/ic_edit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDeleteContact"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:text="@string/contact_delete"
android:textColor="?attr/colorError"
app:strokeColor="?attr/colorError"
app:icon="@drawable/ic_delete"
app:iconTint="?attr/colorError" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -140,6 +140,14 @@
<string name="contact_already_exists">Contact already exists: %s</string>
<string name="contact_own_account">Cannot save your own account as a contact</string>
<!-- Contact expand/delete -->
<string name="contact_edit">Edit</string>
<string name="contact_delete">Delete</string>
<string name="contact_delete_title">Delete Contact</string>
<string name="contact_delete_message">Remove %s from your contacts?</string>
<string name="contact_deleted">Contact deleted</string>
<string name="contact_delete_failed">Could not delete contact</string>
<!-- Financing -->
<string name="financing_empty">No financing deals found</string>
<string name="financing_total">Total</string>