diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt index a7c3a99..1ac6ba7 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -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}") diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt index 3b75296..7863741 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt @@ -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) 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 b5a1278..fd8b0aa 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 @@ -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() { private var allContacts: List = emptyList() private var displayed: List = emptyList() private val imageCache = mutableMapOf() + private val expandedPositions = mutableSetOf() private var activeCategoryId: String? = null private var searchQuery: String = "" fun updateContacts(contacts: List) { 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 } } 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 166105a..edb8d24 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 @@ -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() - 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 = 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) } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 6a22916..8b05f2f 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -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() { diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..7271f37 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..e9d8e9e --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..ab8f088 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.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 a3b2cbd..57bb635 100644 --- a/app/src/main/res/layout/item_contact.xml +++ b/app/src/main/res/layout/item_contact.xml @@ -1,63 +1,132 @@ - + android:orientation="vertical"> - - - + + android:paddingHorizontal="16dp" + android:paddingVertical="10dp"> - + + + + + + + + + + + + + + android:orientation="vertical" + android:paddingHorizontal="16dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" + android:visibility="gone"> - + + - + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09765ef..c31dfdd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,14 @@ Contact already exists: %s Cannot save your own account as a contact + + Edit + Delete + Delete Contact + Remove %s from your contacts? + Contact deleted + Could not delete contact + No financing deals found Total