From 5210e6207fb08513beeb2bf1b9de0a496b9e28a6 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 25 Sep 2026 06:35:39 +0500 Subject: [PATCH] redegsigned contacts page: removed delete and edit buttons from contacts list, made a new contacts sheet and moved buttons there --- .../ui/home/ContactDetailsSheetFragment.kt | 171 ++++++++ .../sar/basedbank/ui/home/ContactsAdapter.kt | 54 ++- .../sar/basedbank/ui/home/ContactsFragment.kt | 38 +- .../sh/sar/basedbank/util/ContactDisplay.kt | 2 + .../basedbank/util/bmlapi/BmlContactParser.kt | 2 + .../util/fahipayapi/FahipayContactParser.kt | 2 + .../basedbank/util/mibapi/MibContactParser.kt | 2 + app/src/main/res/drawable/ic_currency.xml | 10 + app/src/main/res/layout/item_contact.xml | 20 +- .../main/res/layout/sheet_contact_details.xml | 378 ++++++++++++++++++ app/src/main/res/values/strings.xml | 6 + app/src/main/res/values/themes.xml | 10 + 12 files changed, 640 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt create mode 100644 app/src/main/res/drawable/ic_currency.xml create mode 100644 app/src/main/res/layout/sheet_contact_details.xml diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt new file mode 100644 index 0000000..c5b2940 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt @@ -0,0 +1,171 @@ +package sh.sar.basedbank.ui.home + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.os.bundleOf +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.R +import sh.sar.basedbank.databinding.SheetContactDetailsBinding +import sh.sar.basedbank.util.ContactDisplay +import sh.sar.basedbank.util.ContactImageCache +import sh.sar.basedbank.util.ContactListParser +import sh.sar.basedbank.util.CredentialStore +import sh.sar.basedbank.util.TransferNetwork + +/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */ +class ContactDetailsSheetFragment : BottomSheetDialogFragment() { + + private var _binding: SheetContactDetailsBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + + private val contactsFragment get() = parentFragment as? ContactsFragment + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = SheetContactDetailsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val args = requireArguments() + val contactId = args.getString(ARG_CONTACT_ID) + val profileId = args.getString(ARG_PROFILE_ID) + viewModel.contacts.observe(viewLifecycleOwner) { contacts -> + val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId } + ?.let { ContactListParser.from(it) } + if (contact == null) dismissAllowingStateLoss() else bind(contact) + } + } + + private fun bind(contact: ContactDisplay) { + val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2 + val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) } + binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx)) + + binding.tvName.text = contact.name + + binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE + binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE + binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE + binding.actionRow.visibility = + if (contact.canTransfer || contact.canEdit || contact.canDelete) View.VISIBLE else View.GONE + + binding.btnTransfer.setOnClickListener { + val parent = contactsFragment + dismiss() + parent?.openTransfer(contact) + } + binding.btnEdit.setOnClickListener { + Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() + } + binding.btnDelete.setOnClickListener { + val parent = contactsFragment + dismiss() + parent?.confirmDelete(contact) + } + + binding.tvAccount.text = contact.accountNumber + val copy = View.OnClickListener { copyAccount(contact.accountNumber) } + binding.rowAccount.setOnClickListener(copy) + binding.btnCopyAccount.setOnClickListener(copy) + + val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name + binding.tvRealName.text = contact.realName + binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE + + binding.tvCurrency.text = contact.currency ?: "" + binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE + + binding.tvBank.text = contact.bankName ?: "" + binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE + val bankLogo = bankLogoRes(contact) + if (bankLogo != null) { + binding.ivBankIcon.setImageResource(bankLogo) + binding.ivBankIcon.imageTintList = null + } + + binding.sourceSection.visibility = View.GONE + viewLifecycleOwner.lifecycleScope.launch { + val appContext = requireContext().applicationContext + val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) } + val b = _binding ?: return@launch + if (source == null) return@launch + b.ivSourceIcon.setImageResource(source.logoRes) + b.tvSource.text = source.name + b.tvSourceBank.setText(source.bankNameRes) + b.sourceSection.visibility = View.VISIBLE + } + } + + /** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */ + private fun bankLogoRes(contact: ContactDisplay): Int? = when { + contact.network == TransferNetwork.MIB -> R.drawable.mib_logo + contact.accountNumber.matches(Regex("^7\\d{12}$")) -> R.drawable.bml_logo_vector + contact.accountNumber.matches(Regex("^9\\d{16}$")) -> R.drawable.mib_logo + else -> null + } + + private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String) + + /** Which of the user's own bank profiles this contact is saved under. */ + private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? { + val store = CredentialStore(context) + return when (contact.network) { + TransferNetwork.BML -> { + // BML contacts carry either the loginId or a BML profileId + val loginId = store.getBmlLoginIds().firstOrNull { loginId -> + loginId == contact.profileId || + store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId } + } ?: return null + val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId } + val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } + val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name) + ?: return null + SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name) + } + TransferNetwork.FAHIPAY -> { + val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId -> + store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } + } ?: return null + SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name) + } + else -> { + val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId -> + store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId } + } ?: return null + SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name) + } + } + } + + private fun copyAccount(account: String) { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("account", account)) + Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val ARG_CONTACT_ID = "contact_id" + private const val ARG_PROFILE_ID = "profile_id" + + fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply { + arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId) + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt index 957c1bb..ed8e988 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsAdapter.kt @@ -11,14 +11,13 @@ import android.view.LayoutInflater import android.view.ViewGroup import android.widget.Toast import androidx.recyclerview.widget.RecyclerView -import sh.sar.basedbank.R import sh.sar.basedbank.databinding.ItemContactBinding import sh.sar.basedbank.util.ContactDisplay class ContactsAdapter( private val imageCache: MutableMap, private val onImageNeeded: (hash: String) -> Unit, - private val onDeleteClick: (ContactDisplay) -> Unit, + private val onContactClick: (ContactDisplay) -> Unit, private val onTransferClick: (ContactDisplay) -> Unit ) : RecyclerView.Adapter() { @@ -66,12 +65,9 @@ class ContactsAdapter( val pos = holder.bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos]) } - binding.btnEditContact.setOnClickListener { - Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() - } - binding.btnDeleteContact.setOnClickListener { + binding.root.setOnClickListener { val pos = holder.bindingAdapterPosition - if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos]) + if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos]) } binding.root.setOnLongClickListener { val pos = holder.bindingAdapterPosition @@ -107,37 +103,33 @@ class ContactsAdapter( if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE binding.btnTransferContact.visibility = if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE - binding.btnEditContact.visibility = - if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE - binding.btnDeleteContact.visibility = - if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE if (photo != null) { binding.ivContactPhoto.setImageBitmap(photo) } else { + val sizePx = binding.ivContactPhoto.context.resources + .getDimensionPixelSize(android.R.dimen.app_icon_size) + .coerceAtLeast(96) binding.ivContactPhoto.setImageBitmap( - makeInitialsBitmap(contact.name, contact.bankColor) + contactInitialsBitmap(contact.name, contact.bankColor, sizePx) ) } } - - 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 - canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) - return bm - } } } + +internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap { + 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 + canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) + return bm +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt index 72e3e26..f1cb4a0 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt @@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Typeface +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan import android.os.Bundle import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder +import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.widget.addTextChangedListener @@ -59,7 +66,7 @@ class ContactsFragment : Fragment() { ContactsAdapter( imageCache = sharedImageCache, onImageNeeded = { hash -> fetchImage(hash) }, - onDeleteClick = { contact -> confirmDelete(contact) }, + onContactClick = { contact -> showDetails(contact) }, onTransferClick = { contact -> openTransfer(contact) } ).also { a -> a.setFilter(page.categoryId, currentSearch) @@ -170,7 +177,12 @@ class ContactsFragment : Fragment() { binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false) } - private fun openTransfer(contact: ContactDisplay) { + private fun showDetails(contact: ContactDisplay) { + if (childFragmentManager.findFragmentByTag("contact_details") != null) return + ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details") + } + + internal fun openTransfer(contact: ContactDisplay) { val fragment = TransferFragment.newInstance( accountNumber = contact.accountNumber, displayName = contact.name, @@ -181,10 +193,26 @@ class ContactsFragment : Fragment() { (requireActivity() as HomeActivity).showWithBackStack(fragment) } - private fun confirmDelete(contact: ContactDisplay) { - MaterialAlertDialogBuilder(requireContext()) + internal fun confirmDelete(contact: ContactDisplay) { + val ctx = requireContext() + val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError) + val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) } + + val prompt = getString(R.string.contact_delete_message, contact.name) + val message = SpannableStringBuilder(prompt).apply { + val start = prompt.indexOf(contact.name) + if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + append("\n").append(contact.accountNumber) + append("\n\n") + val warnStart = length + append(getString(R.string.contact_delete_warning)) + setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog) + .setIcon(icon) .setTitle(R.string.contact_delete_title) - .setMessage(getString(R.string.contact_delete_message, contact.name)) + .setMessage(message) .setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) } .setNegativeButton(R.string.cancel, null) .show() diff --git a/app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt b/app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt index 0a6d455..e4e491e 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt @@ -13,6 +13,8 @@ data class ContactDisplay( val network: TransferNetwork, val bankColor: String, val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row + val bankName: String?, // shown in the contact details sheet; null = hide row + val currency: String?, // shown in the contact details sheet; null = hide row val imageHash: String?, val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager) val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen diff --git a/app/src/main/java/sh/sar/basedbank/util/bmlapi/BmlContactParser.kt b/app/src/main/java/sh/sar/basedbank/util/bmlapi/BmlContactParser.kt index ef1734a..da17c7f 100644 --- a/app/src/main/java/sh/sar/basedbank/util/bmlapi/BmlContactParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/bmlapi/BmlContactParser.kt @@ -15,6 +15,8 @@ object BmlContactParser { network = TransferNetwork.BML, bankColor = contact.bankColor, detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}", + bankName = contact.benefBankName.takeIf { it.isNotBlank() }, + currency = contact.transferCyDesc.takeIf { it.isNotBlank() }, imageHash = contact.customerImgHash, profileId = contact.profileId, transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}", diff --git a/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt b/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt index 3078094..ba9ee2e 100644 --- a/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt @@ -15,6 +15,8 @@ object FahipayContactParser { network = TransferNetwork.FAHIPAY, bankColor = contact.bankColor, detail = null, // Fahipay contacts show no detail line + bankName = contact.benefBankName.takeIf { it.isNotBlank() }, + currency = contact.transferCyDesc.takeIf { it.isNotBlank() }, imageHash = contact.customerImgHash, profileId = contact.profileId, transferSubtitle = contact.benefAccount, diff --git a/app/src/main/java/sh/sar/basedbank/util/mibapi/MibContactParser.kt b/app/src/main/java/sh/sar/basedbank/util/mibapi/MibContactParser.kt index 3f75c1f..76e7415 100644 --- a/app/src/main/java/sh/sar/basedbank/util/mibapi/MibContactParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/mibapi/MibContactParser.kt @@ -21,6 +21,8 @@ object MibContactParser { network = network, bankColor = contact.bankColor, detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}", + bankName = contact.benefBankName.takeIf { it.isNotBlank() }, + currency = contact.transferCyDesc.takeIf { it.isNotBlank() }, imageHash = contact.customerImgHash, profileId = contact.profileId, transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}", diff --git a/app/src/main/res/drawable/ic_currency.xml b/app/src/main/res/drawable/ic_currency.xml new file mode 100644 index 0000000..3565c0d --- /dev/null +++ b/app/src/main/res/drawable/ic_currency.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/item_contact.xml b/app/src/main/res/layout/item_contact.xml index 48b3312..4aac1b3 100644 --- a/app/src/main/res/layout/item_contact.xml +++ b/app/src/main/res/layout/item_contact.xml @@ -4,6 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" android:paddingHorizontal="16dp" android:paddingVertical="10dp"> @@ -26,25 +27,6 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96ab07e..a551826 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -332,6 +332,12 @@ Remove %s from your contacts? Contact deleted Could not delete contact + Account number + Account name + Bank + This can\'t be undone. + Copy account number + Account number copied No financing deals found diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 249db22..d58d0ad 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,6 +1,16 @@ + + +