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 e623f50..a7c3a99 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 @@ -203,6 +203,107 @@ class BmlLoginFlow { } catch (_: Exception) { "" } } + fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? { + val resp = apiClient.newCall( + Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input") + .header("Authorization", "Bearer ${session.accessToken}") + .header("User-Agent", APP_USER_AGENT) + .header("x-app-version", APP_VERSION) + .header("Accept", "application/json") + .build() + ).execute() + val json = resp.body?.string() ?: return null + resp.close() + return try { + val root = JSONObject(json) + if (!root.optBoolean("success")) return null + val payload = root.optJSONObject("payload") ?: return null + val trnType = payload.optString("trnType", "") + val validationType = payload.optString("validationType", "") + if (validationType == "alias") { + val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null + BmlAccountValidation( + trnType = trnType, + validationType = validationType, + account = cdtrAcct.optString("Acct"), + originalInput = input, + name = payload.optString("contact_name").trim(), + alias = null, + currency = payload.optString("currency", "MVR"), + agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() } + ) + } else { + BmlAccountValidation( + trnType = trnType, + validationType = validationType, + account = payload.optString("account"), + originalInput = input, + name = payload.optString("name"), + alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" }, + currency = payload.optString("currency", "MVR") + ) + } + } catch (_: Exception) { null } + } + + fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? { + val resp = apiClient.newCall( + Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB") + .header("Authorization", "Bearer ${session.accessToken}") + .header("User-Agent", APP_USER_AGENT) + .header("x-app-version", APP_VERSION) + .header("Accept", "application/json") + .build() + ).execute() + val json = resp.body?.string() ?: return null + resp.close() + return try { + val root = JSONObject(json) + if (!root.optBoolean("success")) return null + BmlAccountValidation( + trnType = "DOT", + validationType = "MIB", + account = root.optString("account"), + originalInput = account, + name = root.optString("name"), + alias = null, + currency = "MVR", + agnt = root.optString("agnt").takeIf { it.isNotBlank() } + ) + } catch (_: Exception) { null } + } + + fun saveContact( + session: BmlSession, + contactType: String, + account: String, + alias: String, + currency: String? = null, + name: String? = null, + swift: String? = null + ): Boolean { + val bodyObj = JSONObject().apply { + put("contact_type", contactType) + put("account", account) + put("alias", alias) + if (currency != null) put("currency", currency) + if (name != null) put("name", name) + if (swift != null) put("swift", swift) + } + val resp = apiClient.newCall( + Request.Builder().url("$BASE_URL/api/mobile/contacts") + .post(bodyObj.toString().toRequestBody("application/json".toMediaType())) + .header("Authorization", "Bearer ${session.accessToken}") + .header("User-Agent", APP_USER_AGENT) + .header("x-app-version", APP_VERSION) + .header("Accept", "application/json") + .build() + ).execute() + val json = resp.body?.string() ?: return false + resp.close() + return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false } + } + fun fetchContacts(session: BmlSession): List { val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute() val json = resp.body?.string() ?: return emptyList() diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt index ab44ba6..706d7b3 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt @@ -5,6 +5,17 @@ data class BmlSession( val deviceId: String ) +data class BmlAccountValidation( + val trnType: String, // IAT, QTR, DOT + val validationType: String, // BML, alias, MIB + val account: String, // resolved account number + val originalInput: String, // original user input (alias/Favara for QTR) + val name: String, + val alias: String?, + val currency: String, + val agnt: String? = null // BIC for DOT (MIB account on BML) +) + data class BmlForeignLimit( val type: String, val used: Double, 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 46ad1e7..3b75296 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 @@ -126,6 +126,62 @@ class MibContactsClient { } } + fun createContact( + session: MibSession, + benefType: String, // "I" = MIB internal, "L" = local/BML + bankNo: Int, // 2 = MIB, 3 = BML + benefAccount: String, + benefName: String, + nickName: String, + transferCy: String = "462", + categoryId: String = "0", + imageBase64: String = "" + ): Boolean { + val body = FormBody.Builder() + .add("benefType", benefType) + .add("imageSet", if (imageBase64.isNotBlank()) "1" else "0") + .add("benefAccount", benefAccount) + .add("benefIban", "") + .add("benefName", benefName) + .add("nickName", nickName) + .add("transferCy", transferCy) + .add("transferCySwift", "840") + .add("benefAddress", "") + .add("benefCity", "") + .add("benefCountry", "4") + .add("benefBankSwift", "") + .add("bankNo", bankNo.toString()) + .add("benefBankName", "") + .add("benefBankBranch", "") + .add("benefBankAddress", "") + .add("benefBankCity", "") + .add("benefBankCountry", "4") + .add("intBankSwift", "") + .add("intBankName", "") + .add("intBankAddress", "") + .add("intBankBranch", "") + .add("intBankCity", "") + .add("intBankCountry", "4") + .add("categoryId", categoryId) + .add("email", "") + .add("contactNumber", "") + .add("website", "") + .add("image", imageBase64) + .build() + + val request = Request.Builder() + .url("$BASE_WV_URL/ajaxBeneficiary/createLocalBeneficiary") + .post(body) + .withSessionHeaders(session) + .header("Referer", "$BASE_WV_URL/beneficiary/createNew") + .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/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index 04ca95f..9fe4c04 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -232,7 +232,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { mvrBalance = a.optString("mvrBalance"), statusDesc = a.optString("statusDesc"), profileImageHash = profile.customerImage, - loginTag = loginTag + loginTag = loginTag, + profileId = profile.profileId ) ) } diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt index b8b7062..d835bd1 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt @@ -32,7 +32,8 @@ data class MibAccount( val mvrBalance: String, val statusDesc: String, val profileImageHash: String?, - val loginTag: String = "" + val loginTag: String = "", + val profileId: String = "" // MIB profile ID; empty for BML accounts ) data class MibBeneficiaryCategory( @@ -53,7 +54,8 @@ data class MibBeneficiary( val benefStatus: String, val transferCyDesc: String, val customerImgHash: String?, - val benefCategoryId: String // "0" = uncategorized + val benefCategoryId: String, // "0" = uncategorized + val profileId: String = "" // MIB profile ID; empty for BML contacts ) data class MibIpsAccountInfo( diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt new file mode 100644 index 0000000..527036b --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AddContactSheetFragment.kt @@ -0,0 +1,459 @@ +package sh.sar.basedbank.ui.home + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.net.Uri +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.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import sh.sar.basedbank.util.ContactsCache +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.bml.BmlAccountValidation +import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.mib.MibBeneficiaryCategory +import sh.sar.basedbank.api.mib.MibContactsClient +import sh.sar.basedbank.api.mib.MibProfile +import sh.sar.basedbank.api.mib.MibTransferClient +import sh.sar.basedbank.databinding.SheetAddContactBinding +import java.io.ByteArrayOutputStream + +class AddContactSheetFragment : BottomSheetDialogFragment() { + + private var _binding: SheetAddContactBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + + private val app get() = requireActivity().application as BasedBankApp + + private data class DestinationOption( + val label: String, + val isBml: Boolean, + val mibProfile: MibProfile? = null + ) + + private var destinations: List = emptyList() + private var selectedDest: DestinationOption? = null + + // Holds the resolved lookup result (for BML dest) or null (MIB uses account number directly) + private var bmlLookup: BmlAccountValidation? = null + private var mibLookupAccount: String? = null // resolved account for MIB save + + private var selectedImageBase64: String = "" + private var selectedCategoryId: String = "0" + private var categories: List = emptyList() + + private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + uri ?: return@registerForActivityResult + encodeImage(uri) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = SheetAddContactBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + destinations = buildDestinations() + + setupDestinationDropdown() + setupAccountSearch() + setupImagePicker() + setupSaveButton() + + viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> + categories = cats.filter { it.id != "BML" } + if (selectedDest?.isBml == false) setupCategoryDropdown() + } + } + + private fun buildDestinations(): List { + val list = mutableListOf() + for (profile in app.mibProfiles) { + list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile)) + } + if (app.bmlSession != null) { + list.add(DestinationOption("BML · Personal", isBml = true)) + } + return list + } + + private fun setupDestinationDropdown() { + val labels = destinations.map { it.label } + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels) + binding.actvDestination.setAdapter(adapter) + binding.actvDestination.setOnItemClickListener { _, _, position, _ -> + selectedDest = destinations[position] + clearLookupResult() + updateMibOnlyVisibility() + binding.btnSave.isEnabled = false + } + if (destinations.size == 1) { + selectedDest = destinations[0] + binding.actvDestination.setText(destinations[0].label, false) + updateMibOnlyVisibility() + } + } + + private fun updateMibOnlyVisibility() { + val isMib = selectedDest?.isBml == false + binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE + binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE + if (isMib) setupCategoryDropdown() + } + + private fun setupCategoryDropdown() { + val items = mutableListOf(getString(R.string.contact_no_group)) + items.addAll(categories.map { it.categoryName }) + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, items) + binding.actvGroup.setAdapter(adapter) + binding.actvGroup.setOnItemClickListener { _, _, position, _ -> + selectedCategoryId = if (position == 0) "0" else categories[position - 1].id + } + if (binding.actvGroup.text.isNullOrBlank()) { + binding.actvGroup.setText(getString(R.string.contact_no_group), false) + } + } + + private fun setupAccountSearch() { + binding.tilAccount.setEndIconOnClickListener { performLookup() } + } + + private fun performLookup() { + val dest = selectedDest ?: run { + Toast.makeText(requireContext(), R.string.contact_select_destination, Toast.LENGTH_SHORT).show() + return + } + val input = binding.etAccount.text?.toString()?.trim() ?: "" + if (input.isBlank()) { + Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show() + return + } + + // Check own accounts and existing contacts before any network call + val isBmlDest = dest.isBml + val isOwnAccountPre = viewModel.accounts.value?.any { acc -> + acc.accountNumber == input && when { + isBmlDest -> acc.loginTag.startsWith("bml_") + else -> acc.profileId == (dest.mibProfile?.profileId ?: "") + } + } == true + if (isOwnAccountPre) { + Toast.makeText(requireContext(), R.string.contact_own_account, Toast.LENGTH_SHORT).show() + return + } + val existing = viewModel.contacts.value?.firstOrNull { contact -> + contact.benefAccount == input && when { + isBmlDest -> contact.benefCategoryId == "BML" + else -> contact.profileId == (dest.mibProfile?.profileId ?: "") + } + } + if (existing != null) { + Toast.makeText(requireContext(), getString(R.string.contact_already_exists, existing.benefNickName), Toast.LENGTH_SHORT).show() + return + } + + binding.tilAccount.isEnabled = false + binding.tilDestination.isEnabled = false + binding.btnSave.isEnabled = false + + viewLifecycleOwner.lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input) + } + binding.tilAccount.isEnabled = true + binding.tilDestination.isEnabled = true + if (result != null) { + showLookupResult(result, input) + } else { + Toast.makeText(requireContext(), R.string.contact_lookup_failed, Toast.LENGTH_SHORT).show() + } + } + } + + private fun lookupForBml(input: String): BmlAccountValidation? { + val bmlSess = app.bmlSession ?: return null + val bmlFlow = BmlLoginFlow() + + // 1) Try BML validate + val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null } + if (validated != null) return validated + + // 2) Try BML MIB verify + val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null } + if (mibVerified != null) return mibVerified + + // 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML) + val mibSess = app.mibSession ?: return null + return try { + val info = MibTransferClient().lookup(mibSess, input) + BmlAccountValidation( + trnType = "DOT", + validationType = "MIB", + account = info.accountNumber, + originalInput = input, + name = info.accountName, + alias = null, + currency = "MVR", + agnt = "MADVMVMV" + ) + } catch (_: Exception) { null } + } + + private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? { + val mibSess = app.mibSession ?: return null + val profile = dest.mibProfile ?: return null + + val mibResult = try { + app.mibLoginFlow.switchProfile(mibSess, profile) + val info = MibTransferClient().lookup(mibSess, input) + BmlAccountValidation( + trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL", + validationType = "MIB", + account = info.accountNumber, + originalInput = input, + name = info.accountName, + alias = null, + currency = "MVR", + agnt = info.bankId + ) + } catch (_: Exception) { null } + + if (mibResult != null) return mibResult + + // MIB lookup failed (e.g. BML USD account) — fall back to BML validate + val bmlSess = app.bmlSession ?: return null + return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null } + } + + private fun showLookupResult(validation: BmlAccountValidation, input: String) { + // Re-check resolved account (input may have been a Favara ID) + val isBmlDest = selectedDest?.isBml == true + val isOwnAccountPost = viewModel.accounts.value?.any { acc -> + acc.accountNumber == validation.account && when { + isBmlDest -> acc.loginTag.startsWith("bml_") + else -> acc.profileId == (selectedDest?.mibProfile?.profileId ?: "") + } + } == true + if (isOwnAccountPost) { + Toast.makeText(requireContext(), R.string.contact_own_account, Toast.LENGTH_SHORT).show() + return + } + val existing = viewModel.contacts.value?.firstOrNull { contact -> + contact.benefAccount == validation.account && when { + isBmlDest -> contact.benefCategoryId == "BML" + else -> contact.profileId == (selectedDest?.mibProfile?.profileId ?: "") + } + } + if (existing != null) { + Toast.makeText(requireContext(), getString(R.string.contact_already_exists, existing.benefNickName), Toast.LENGTH_SHORT).show() + return + } + + bmlLookup = validation + mibLookupAccount = validation.account + + val displayName = validation.name.ifBlank { input } + val bankName = when { + validation.account.matches(Regex("^9\\d{16}$")) -> "MIB" + validation.account.matches(Regex("^7\\d{12}$")) -> "BML" + else -> validation.account + } + val bankLabel = "$bankName · ${validation.account}" + + binding.tvResultName.text = displayName + binding.tvResultBank.text = bankLabel + binding.ivResultAvatar.setImageBitmap(makeInitialsBitmap(displayName, "#607D8B")) + + binding.cardResult.visibility = View.VISIBLE + binding.tilAccount.visibility = View.GONE + + // Auto-fill alias with existing alias or name + if (binding.etAlias.text.isNullOrBlank()) { + binding.etAlias.setText(validation.name) + } + binding.etCurrency.setText(validation.currency) + + binding.btnClearResult.setOnClickListener { clearLookupResult() } + binding.btnSave.isEnabled = true + } + + private fun clearLookupResult() { + bmlLookup = null + mibLookupAccount = null + binding.cardResult.visibility = View.GONE + binding.tilAccount.visibility = View.VISIBLE + binding.btnSave.isEnabled = false + } + + private fun setupImagePicker() { + binding.btnPickImage.setOnClickListener { + imagePicker.launch("image/*") + } + } + + private fun encodeImage(uri: Uri) { + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + try { + val stream = requireContext().contentResolver.openInputStream(uri) ?: return@launch + var bmp = BitmapFactory.decodeStream(stream) + stream.close() + // Resize to max 256x256 + if (bmp.width > 256 || bmp.height > 256) { + val scale = 256f / maxOf(bmp.width, bmp.height) + bmp = Bitmap.createScaledBitmap(bmp, (bmp.width * scale).toInt(), (bmp.height * scale).toInt(), true) + } + val out = ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, 85, out) + selectedImageBase64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + withContext(Dispatchers.Main) { + if (_binding != null) binding.ivContactImage.setImageBitmap(bmp) + } + } catch (_: Exception) { } + } + } + + private fun setupSaveButton() { + binding.btnSave.setOnClickListener { performSave() } + } + + private fun performSave() { + val dest = selectedDest ?: return + val alias = binding.etAlias.text?.toString()?.trim() ?: "" + if (alias.isBlank()) { + binding.tilAlias.error = "Alias is required" + return + } + binding.tilAlias.error = null + + binding.btnSave.isEnabled = false + + viewLifecycleOwner.lifecycleScope.launch { + val success = withContext(Dispatchers.IO) { + if (dest.isBml) saveToBml(alias) else saveToMib(alias) + } + if (success) { + Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show() + reloadSavedProfileContacts(dest) + dismiss() + } else { + binding.btnSave.isEnabled = true + Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show() + } + } + } + + private fun saveToBml(alias: String): Boolean { + val bmlSess = app.bmlSession ?: return false + val lookup = bmlLookup ?: return false + val bmlFlow = BmlLoginFlow() + val account = lookup.account + return when { + account.matches(Regex("^7\\d{12}$")) -> + // BML account → IAT + bmlFlow.saveContact(bmlSess, "IAT", account, alias) + account.matches(Regex("^9\\d{16}$")) -> + // MIB internal → DOT; swift is BML's internal UUID for MIB bank + bmlFlow.saveContact(bmlSess, "DOT", account, alias, + currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML) + else -> false + } + } + + private fun saveToMib(alias: String): Boolean { + val mibSess = app.mibSession ?: return false + val dest = selectedDest ?: return false + val profile = dest.mibProfile ?: return false + val account = mibLookupAccount ?: return false + val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR" + val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462" + + val isMibInternal = account.matches(Regex("^9\\d{16}$")) + val benefType = if (isMibInternal) "I" else "L" + val bankNo = if (isMibInternal) 2 else 3 + val name = bmlLookup?.name ?: "" + + return try { + app.mibLoginFlow.switchProfile(mibSess, profile) + MibContactsClient().createContact( + session = mibSess, + benefType = benefType, + bankNo = bankNo, + benefAccount = account, + benefName = name, + nickName = alias, + transferCy = transferCy, + categoryId = selectedCategoryId, + imageBase64 = selectedImageBase64 + ) + } catch (_: Exception) { false } + } + + 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 reloadSavedProfileContacts(dest: DestinationOption) { + // Use activity scope so the reload continues after sheet dismisses + requireActivity().lifecycleScope.launch(Dispatchers.IO) { + try { + if (dest.isBml) { + val bmlSess = app.bmlSession ?: return@launch + val fresh = BmlLoginFlow().fetchContacts(bmlSess) + val existing = viewModel.contacts.value ?: emptyList() + val merged = existing.filter { it.benefCategoryId != "BML" } + fresh + viewModel.contacts.postValue(merged) + ContactsCache.saveBml(requireContext(), fresh) + } else { + val profile = dest.mibProfile ?: return@launch + val mibSess = app.mibSession ?: return@launch + app.mibLoginFlow.switchProfile(mibSess, profile) + val fresh = MibContactsClient().fetchContacts(mibSess) + .map { it.copy(profileId = profile.profileId) } + val existing = viewModel.contacts.value ?: emptyList() + val merged = existing.filter { it.profileId != profile.profileId } + fresh + viewModel.contacts.postValue(merged) + val allMib = merged.filter { it.benefCategoryId != "BML" } + ContactsCache.save(requireContext(), allMib, viewModel.contactCategories.value ?: emptyList()) + } + } catch (_: Exception) { } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + // BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts + private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A" + } +} 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 42c31dc..166105a 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 @@ -57,6 +57,10 @@ class ContactsFragment : Fragment() { override fun onTabReselected(tab: TabLayout.Tab) {} }) + binding.fabAddContact.setOnClickListener { + AddContactSheetFragment().show(childFragmentManager, "add_contact") + } + viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> categories = cats rebuildTabs(cats) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 86f273f..52b2dda 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -272,7 +272,8 @@ class HomeActivity : AppCompatActivity() { if (seenCategories.add(cat.id)) categories.add(cat) } for (contact in contactsClient.fetchContacts(session)) { - if (seenContacts.add(contact.benefNo)) contacts.add(contact) + if (seenContacts.add(contact.benefNo)) + contacts.add(contact.copy(profileId = profile.profileId)) } } catch (_: Exception) { /* profile has no contacts access */ } } 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 0cc37c6..6a22916 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 @@ -25,6 +25,7 @@ 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.MibAccount import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibIpsAccountInfo @@ -44,6 +45,7 @@ class TransferFragment : Fragment() { private var selectedAccount: MibAccount? = null private val session get() = (requireActivity().application as BasedBankApp).mibSession + private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult @@ -154,25 +156,55 @@ class TransferFragment : Fragment() { return } - val sess = session - if (sess == null) { + val mibSess = session + val bmlSess = bmlSession + if (mibSess == null && bmlSess == null) { Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() return } + val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true + 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 + if (isBmlSource && bmlSess != null) { + // BML source: prefer BML validate, fall back to MIB IPS + val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null } + if (bmlResult != null) { + val bankId = when (bmlResult.trnType) { + "IAT" -> "MALBMVMV" + else -> bmlResult.agnt ?: bmlResult.account + } + MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId) + } else if (mibSess != null) { + try { MibTransferClient().lookup(mibSess, accountNumber) } + catch (e: MibLookupException) { errorMsg = e.message; null } + catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null } + } else { + errorMsg = getString(R.string.transfer_account_not_found); null + } + } else { + // MIB source (or no preference): prefer MIB, fall back to BML validate + if (mibSess != null) { + try { MibTransferClient().lookup(mibSess, accountNumber) } + catch (e: MibLookupException) { errorMsg = e.message; null } + catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null } + } else { + // MIB not available, try BML + val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null } + if (bmlResult != null) { + val bankId = when (bmlResult.trnType) { + "IAT" -> "MALBMVMV" + else -> bmlResult.agnt ?: bmlResult.account + } + MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId) + } else { + errorMsg = getString(R.string.transfer_account_not_found); null + } + } } } binding.tilTo.isEnabled = true diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt index 7dee68a..7fda1f6 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -27,6 +27,7 @@ object AccountCache { put("mvrBalance", acc.mvrBalance) put("statusDesc", acc.statusDesc) put("loginTag", acc.loginTag) + put("profileId", acc.profileId) if (acc.profileImageHash != null) put("profileImageHash", acc.profileImageHash) }) } @@ -102,7 +103,8 @@ object AccountCache { mvrBalance = o.optString("mvrBalance"), statusDesc = o.optString("statusDesc"), profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() }, - loginTag = o.optString("loginTag") + loginTag = o.optString("loginTag"), + profileId = o.optString("profileId", "") ) } } catch (e: Exception) { diff --git a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt index efdc3d0..a1531ce 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt @@ -34,6 +34,7 @@ object ContactsCache { put("transferCyDesc", c.transferCyDesc) put("customerImgHash", c.customerImgHash ?: "") put("benefCategoryId", c.benefCategoryId) + put("profileId", c.profileId) }) } prefs.putString(KEY_CONTACTS, contactsArr.toString()) @@ -69,7 +70,8 @@ object ContactsCache { benefStatus = o.optString("benefStatus"), transferCyDesc = o.optString("transferCyDesc", "MVR"), customerImgHash = o.optString("customerImgHash").takeIf { it.isNotBlank() }, - benefCategoryId = o.optString("benefCategoryId", "0") + benefCategoryId = o.optString("benefCategoryId", "0"), + profileId = o.optString("profileId", "") ) } } catch (e: Exception) { diff --git a/app/src/main/res/layout/fragment_contacts.xml b/app/src/main/res/layout/fragment_contacts.xml index 05a543f..b200de3 100644 --- a/app/src/main/res/layout/fragment_contacts.xml +++ b/app/src/main/res/layout/fragment_contacts.xml @@ -1,71 +1,87 @@ - + android:layout_height="match_parent"> - + android:layout_height="match_parent" + android:orientation="vertical"> - + android:layout_marginHorizontal="12dp" + android:layout_marginTop="8dp" + 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"> - + - + - - - - - + app:tabMode="scrollable" + app:tabGravity="start" /> - + - + - + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_add_contact.xml b/app/src/main/res/layout/sheet_add_contact.xml new file mode 100644 index 0000000..47364ce --- /dev/null +++ b/app/src/main/res/layout/sheet_add_contact.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0e3021..09765ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,6 +123,23 @@ Remove from recents All + + Add Contact + Save to account + Alias / Nickname + Currency + Group + No group + Save Contact + Upload Image + Contact saved + Failed to save contact + No bank session available + Could not find account + Select a destination account first + Contact already exists: %s + Cannot save your own account as a contact + No financing deals found Total