contacts, contact picker and trnasfer account look up ui
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt
Normal file
143
app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt
Normal 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
80
app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt
Normal file
80
app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/src/main/res/drawable/ic_contacts.xml
Normal file
9
app/src/main/res/drawable/ic_contacts.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<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="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
|
||||
</vector>
|
||||
@@ -6,13 +6,6 @@
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:tabMode="scrollable"
|
||||
app:tabGravity="start" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -37,6 +30,13 @@
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:tabMode="scrollable"
|
||||
app:tabGravity="start" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
|
||||
@@ -86,6 +86,39 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Pending Finances card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cardElevation="1dp"
|
||||
app:cardCornerRadius="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/dashboard_pending_finances"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPendingFinances"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="MVR —"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Card support WIP -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -45,23 +45,111 @@
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- To account number -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilTo"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
<!-- To field row: input + pick contact button -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/transfer_to"
|
||||
android:layout_marginBottom="16dp">
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etTo"
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilTo"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/transfer_to"
|
||||
app:endIconMode="custom"
|
||||
app:endIconDrawable="@android:drawable/ic_menu_search"
|
||||
app:endIconContentDescription="@string/transfer_lookup_account">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etTo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPickContact"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
app:icon="@drawable/ic_contacts"
|
||||
android:contentDescription="@string/transfer_pick_contact" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Confirmed account info card (shown after successful lookup) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardToInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="4dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorPrimary">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLines="1" />
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivToPhoto"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToAccountName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToBankBic"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnClearToInfo"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/transfer_clear_recipient" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<View
|
||||
android:id="@+id/spacerTo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="16dp" />
|
||||
|
||||
<!-- Amount -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
|
||||
50
app/src/main/res/layout/item_picker_row.xml
Normal file
50
app/src/main/res/layout/item_picker_row.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp"
|
||||
android:minHeight="64dp"
|
||||
android:gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivIcon"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPrimary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSecondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
12
app/src/main/res/layout/item_picker_section_header.xml
Normal file
12
app/src/main/res/layout/item_picker_section_header.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/tvHeader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:letterSpacing="0.08" />
|
||||
47
app/src/main/res/layout/sheet_contact_picker.xml
Normal file
47
app/src/main/res/layout/sheet_contact_picker.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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:orientation="vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
app:startIconDrawable="@android:drawable/ic_menu_search"
|
||||
app:boxCornerRadiusTopStart="24dp"
|
||||
app:boxCornerRadiusTopEnd="24dp"
|
||||
app:boxCornerRadiusBottomStart="24dp"
|
||||
app:boxCornerRadiusBottomEnd="24dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etSheetSearch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/contacts_search_hint"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:imeOptions="actionSearch" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/sheetCategoryTabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:tabMode="scrollable"
|
||||
app:tabGravity="start" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/sheetRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="400dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -74,6 +74,7 @@
|
||||
<string name="work_in_progress">Work in progress</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_pending_finances">Pending Finances</string>
|
||||
<string name="balance_mvr">MVR Total</string>
|
||||
<string name="balance_usd">USD Total</string>
|
||||
<string name="card_support_wip">Card Support</string>
|
||||
@@ -94,14 +95,26 @@
|
||||
<string name="available_balance">Available Balance</string>
|
||||
|
||||
<!-- Transfer -->
|
||||
<string name="transfer_tab_quick">Quick Transfer</string>
|
||||
<string name="transfer_tab_contacts">Contacts</string>
|
||||
<string name="transfer_from">From account</string>
|
||||
<string name="transfer_to">To account number</string>
|
||||
<string name="transfer_to">Account Number or Favara ID</string>
|
||||
<string name="transfer_my_accounts">My Accounts</string>
|
||||
<string name="transfer_same_as_from">This is the same account as the sender</string>
|
||||
<string name="transfer_lookup_account">Look up account</string>
|
||||
<string name="transfer_clear_recipient">Clear recipient</string>
|
||||
<string name="transfer_pick_contact">Pick contact</string>
|
||||
<string name="transfer_enter_account_first">Enter an account number first</string>
|
||||
<string name="transfer_account_not_found">Account not found</string>
|
||||
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>
|
||||
<string name="transfer_amount">Amount</string>
|
||||
<string name="transfer_remarks">Remarks</string>
|
||||
|
||||
<!-- Contacts -->
|
||||
<string name="contacts_empty">No contacts found</string>
|
||||
<string name="contacts_search_hint">Search contacts</string>
|
||||
<string name="contacts_tab_recents">Recents</string>
|
||||
<string name="recents_remove">Remove from recents</string>
|
||||
<string name="contacts_tab_all">All</string>
|
||||
|
||||
<!-- Financing -->
|
||||
|
||||
123
docs/mibapi/accountlookup.md
Normal file
123
docs/mibapi/accountlookup.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# MIB Account Lookup Routing
|
||||
|
||||
Before initiating a transfer, the recipient input must be resolved to a verified account name and
|
||||
account number. Three different endpoints are used depending on the format of the input.
|
||||
|
||||
## Input Format Routing
|
||||
|
||||
| Input format | Endpoint | Body field |
|
||||
|-------------------------------------------|---------------------------------------|-----------------|
|
||||
| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` |
|
||||
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` |
|
||||
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` |
|
||||
| Email address (contains `@`) | `AjaxAlias/getAlias` | `aliasName` |
|
||||
|
||||
All endpoints share the same WebView session auth (see `contacts.md` for cookie format) and use
|
||||
`Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick`.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Details
|
||||
|
||||
### 1. IPS Account Lookup — Local / BML accounts (13 digits, starts with 7)
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount`
|
||||
|
||||
Body: `benefAccount=7700000000000` (13 digits)
|
||||
|
||||
**Success response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Request Successful. Account Found",
|
||||
"accountName": "ACCOUNT HOLDER NAME",
|
||||
"bankBic": "MALBMVMV"
|
||||
}
|
||||
```
|
||||
|
||||
Fields used:
|
||||
- `accountName` — account holder name
|
||||
- `bankBic` — bank SWIFT/BIC code
|
||||
|
||||
The account number is already known from the input; it is not returned in the response.
|
||||
|
||||
---
|
||||
|
||||
### 2. MIB Internal Account Name Lookup — MIB accounts (17 digits, starts with 9)
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName`
|
||||
|
||||
Body: `accountNo=90100000000000000` (17 digits)
|
||||
|
||||
**Success response** (exact structure to be confirmed):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonText": "Account found",
|
||||
"accountName": "ACCOUNT HOLDER NAME"
|
||||
}
|
||||
```
|
||||
|
||||
Fields used:
|
||||
- `accountName` — account holder name (check at root level or inside `data` object)
|
||||
|
||||
The account number is already known from the input; bank is always MIB (`MADVMVMV`).
|
||||
|
||||
---
|
||||
|
||||
### 3. Favara Alias Lookup — Shortcodes, A-IDs, emails
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
|
||||
|
||||
Body: `aliasName=<alias>`
|
||||
|
||||
Accepted alias formats:
|
||||
- `7` or `9` followed by 6 digits → e.g. `7012345`, `9198026`
|
||||
- `A` followed by 6 digits → e.g. `A123456`
|
||||
- Email address → e.g. `user@example.com`
|
||||
|
||||
**Success response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"reasonCode": "203",
|
||||
"reasonText": " Favara ID found",
|
||||
"data": {
|
||||
"TxId": "BANK00001",
|
||||
"CdtrAcct": {
|
||||
"Acct": "90100000000000000",
|
||||
"FinInstnId": "MADVMVMV"
|
||||
},
|
||||
"BfyNm": "Account Holder Name",
|
||||
"RegDtTm": "2023-01-01T00:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields used from `data`:
|
||||
- `BfyNm` — beneficiary name (trim whitespace)
|
||||
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
||||
- `CdtrAcct.FinInstnId` — bank institution ID
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
All three endpoints return `"success": false` on failure with a human-readable `reasonText`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"responseCode": "0",
|
||||
"reasonText": "Account not found"
|
||||
}
|
||||
```
|
||||
|
||||
- Always show `reasonText` directly to the user as the error message.
|
||||
- For non-200 HTTP responses, also attempt to parse `reasonText` from the body before falling back to a generic error.
|
||||
- If the input does not match any known format, reject it client-side before making any request.
|
||||
81
docs/mibapi/transfer.md
Normal file
81
docs/mibapi/transfer.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# MIB Transfer API
|
||||
|
||||
Transfer endpoints are served from the MIB WebView subdomain, using the same session-cookie auth as
|
||||
financing and contacts.
|
||||
|
||||
## Authentication
|
||||
|
||||
```
|
||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||
```
|
||||
|
||||
All AJAX POST requests also require:
|
||||
|
||||
```
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Origin: https://faisamobilex-wv.mib.com.mv
|
||||
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Look Up Recipient by Favara Alias
|
||||
|
||||
Resolves a Favara ID (alias) to the account holder name and account number before initiating a transfer.
|
||||
|
||||
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
|
||||
|
||||
**Request body (form-urlencoded):**
|
||||
|
||||
| Field | Description |
|
||||
|-------------|------------------------------------------|
|
||||
| `aliasName` | The recipient's Favara ID / alias number |
|
||||
|
||||
**Success response (`responseCode: "2"`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "2",
|
||||
"reasonCode": "203",
|
||||
"reasonText": " Favara ID found",
|
||||
"data": {
|
||||
"TxId": "BANK00001",
|
||||
"CreDtTm": "...",
|
||||
"Resp": {
|
||||
"Rslt": true,
|
||||
"RsltDtls": null
|
||||
},
|
||||
"CdtrAcct": {
|
||||
"Acct": "90100000000000000",
|
||||
"FinInstnId": "MADVMVMV"
|
||||
},
|
||||
"BfyNm": "Account Holder Name",
|
||||
"RegDtTm": "2023-01-01T00:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Not found / error response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"responseCode": "0",
|
||||
"reasonCode": "400",
|
||||
"reasonText": "Alias not found"
|
||||
}
|
||||
```
|
||||
|
||||
Key fields from `data`:
|
||||
- `BfyNm` — beneficiary full name (trim whitespace)
|
||||
- `CdtrAcct.Acct` — resolved account number to use for the transfer
|
||||
- `CdtrAcct.FinInstnId` — bank institution ID (e.g. `MADVMVMV`, `MALBMVMV`)
|
||||
|
||||
**Notes:**
|
||||
- Use `success` (not `responseCode`) to determine if the lookup succeeded.
|
||||
- Show `BfyNm` + `CdtrAcct.Acct` to the user as confirmation before proceeding.
|
||||
- The `reasonText` from error responses should be shown directly to the user.
|
||||
Reference in New Issue
Block a user