contacts, contact picker and trnasfer account look up ui

This commit is contained in:
2026-05-13 02:15:53 +05:00
parent c49cce0cf2
commit b452940ed0
19 changed files with 1284 additions and 35 deletions

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
@@ -15,6 +16,10 @@ class BasedBankApp : Application() {
var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList()
val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
}
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)

View File

@@ -263,7 +263,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc")
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage
)
)
}
@@ -283,11 +284,22 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
name = p.optString("name"),
cifType = p.optString("cifType"),
profileType = p.optString("profileType"),
color = p.optString("color")
color = p.optString("color"),
customerImage = p.optString("customerImage").takeIf { it.isNotBlank() }
)
}
}
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
val payload = baseData(session, "P41").apply {
put("imageHash", imageHash)
}
val resp = doRequest(session, payload, "n")
if (!resp.optBoolean("success", false)) return null
return resp.optString("profileImage").takeIf { it.isNotBlank() }
}
private fun post(body: FormBody): String {
val request = Request.Builder()
.url(BASE_URL)

View File

@@ -15,7 +15,8 @@ data class MibProfile(
val name: String,
val cifType: String,
val profileType: String,
val color: String
val color: String,
val customerImage: String?
)
data class MibAccount(
@@ -29,7 +30,8 @@ data class MibAccount(
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String
val statusDesc: String,
val profileImageHash: String?
)
data class MibBeneficiaryCategory(
@@ -53,6 +55,12 @@ data class MibBeneficiary(
val benefCategoryId: String // "0" = uncategorized
)
data class MibIpsAccountInfo(
val accountName: String,
val accountNumber: String,
val bankId: String
)
data class MibFinanceDeal(
val dealNo: String,
val productDesc: String,

View File

@@ -0,0 +1,143 @@
package sh.sar.basedbank.api.mib
import android.util.Log
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibLookupException(message: String) : Exception(message)
class MibTransferClient {
private val TAG = "MibTransferClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withWvHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/transfer/quick")
/**
* Routes the lookup to the correct endpoint based on input format:
*
* - 15 digits starting with 7 → IPS account lookup (BML / local bank)
* - 17 digits starting with 9 → MIB internal account name lookup
* - 7 digits starting with 7 or 9,
* A + 6 digits,
* or email address → Favara alias lookup
*/
fun lookup(session: MibSession, input: String): MibIpsAccountInfo {
val trimmed = input.trim()
return when {
trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed)
trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed)
trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.contains("@") -> lookupAlias(session, trimmed)
else -> throw MibLookupException("Unrecognized account number format")
}
}
/** BML / local bank: 15-digit account starting with 7. */
private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("benefAccount", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getIPSAccount")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
Log.d(TAG, "lookupIpsAccount: HTTP ${response.code}")
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
MibIpsAccountInfo(
accountName = json.optString("accountName").trim(),
accountNumber = accountNumber,
bankId = json.optString("bankBic")
)
}
}
/** MIB internal: 17-digit account starting with 9. */
private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("accountNo", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getAccountName")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
Log.d(TAG, "lookupAccountName: HTTP ${response.code}")
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
// accountName may be at root or inside a "data" object
val name = json.optString("accountName").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("accountName") ?: ""
MibIpsAccountInfo(
accountName = name.trim(),
accountNumber = accountNumber,
bankId = "MADVMVMV" // MIB
)
}
}
/** Favara alias: 7-digit shortcode (7/9 prefix), A-format ID, or email. */
private fun lookupAlias(session: MibSession, aliasName: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("aliasName", aliasName).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getAlias")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
Log.d(TAG, "lookupAlias: HTTP ${response.code}")
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
val data = json.getJSONObject("data")
val cdtrAcct = data.getJSONObject("CdtrAcct")
MibIpsAccountInfo(
accountName = data.optString("BfyNm").trim(),
accountNumber = cdtrAcct.optString("Acct"),
bankId = cdtrAcct.optString("FinInstnId")
)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -25,6 +25,7 @@ object AccountCache {
put("blockedAmount", acc.blockedAmount)
put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc)
if (acc.profileImageHash != null) put("profileImageHash", acc.profileImageHash)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -49,7 +50,8 @@ object AccountCache {
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc")
statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() }
)
}
} catch (e: Exception) {

View File

@@ -0,0 +1,80 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
data class RecentPick(
val accountNumber: String,
val displayName: String,
val subtitle: String,
val colorHex: String,
val imageHash: String?,
val isProfileImage: Boolean
)
object RecentsCache {
private const val PREFS = "recents_cache"
private const val KEY = "contact_recents"
private const val MAX = 10
fun save(context: Context, pick: RecentPick) {
val existing = load(context).toMutableList()
existing.removeAll { it.accountNumber == pick.accountNumber }
existing.add(0, pick)
if (existing.size > MAX) existing.subList(MAX, existing.size).clear()
val arr = JSONArray()
for (r in existing) {
arr.put(JSONObject().apply {
put("accountNumber", r.accountNumber)
put("displayName", r.displayName)
put("subtitle", r.subtitle)
put("colorHex", r.colorHex)
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
}
fun remove(context: Context, accountNumber: String) {
val updated = load(context).filter { it.accountNumber != accountNumber }
val arr = JSONArray()
for (r in updated) {
arr.put(JSONObject().apply {
put("accountNumber", r.accountNumber)
put("displayName", r.displayName)
put("subtitle", r.subtitle)
put("colorHex", r.colorHex)
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
}
fun load(context: Context): List<RecentPick> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RecentPick(
accountNumber = o.getString("accountNumber"),
displayName = o.getString("displayName"),
subtitle = o.getString("subtitle"),
colorHex = o.getString("colorHex"),
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
isProfileImage = o.optBoolean("isProfileImage", false)
)
}
} catch (_: Exception) {
emptyList()
}
}
}