Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb7c2ce72 |
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 29
|
versionCode = 28
|
||||||
versionName = "1.0.28"
|
versionName = "1.0.27"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
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.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentChatBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
import sh.sar.basedbank.util.ChatAccount
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One person's transfers as a chat. A person can have several accounts (e.g. MVR and USD); the
|
||||||
|
* "To" bar picks which one to send to. Sending opens [TransferFragment] in a sheet, prefilled with
|
||||||
|
* the recipient, source account and amount, so the usual confirm/OTP flow still applies.
|
||||||
|
*/
|
||||||
|
class ChatFragment : Fragment(), ContactSheetHost {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private var peerKey = ""
|
||||||
|
private var header: ViewChatHeaderBinding? = null
|
||||||
|
private val adapter = ChatMessagesAdapter { openReceipt(it.receiptKey) }
|
||||||
|
private var thread: ChatThread? = null
|
||||||
|
private var fromAccounts: List<BankAccount> = emptyList()
|
||||||
|
private var selectedFrom: BankAccount? = null
|
||||||
|
private var selectedTo: ChatAccount? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
|
||||||
|
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
|
||||||
|
binding.rvMessages.adapter = adapter
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
adapter.setHideAmounts(it)
|
||||||
|
selectedFrom?.let { acc -> bindAccountRow(binding.fromAccountRow, acc) }
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
|
||||||
|
bindFromAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.cardFromAccount.setOnClickListener { showAccountPicker() }
|
||||||
|
binding.btnSaveContact.setOnClickListener { openContact() }
|
||||||
|
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
|
||||||
|
binding.btnSend.setOnClickListener { send() }
|
||||||
|
binding.etAmount.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfers started from this chat come straight back here instead of opening the receipt.
|
||||||
|
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||||
|
onTransferDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putString(ARG_PEER_KEY, peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThread() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
|
||||||
|
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
|
||||||
|
val t = threads.firstOrNull { it.peerKey == peerKey }
|
||||||
|
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
thread = t
|
||||||
|
if (t == null) return@launch
|
||||||
|
peerKey = t.peerKey
|
||||||
|
adapter.showDestination = t.accounts.size > 1
|
||||||
|
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
|
||||||
|
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
|
||||||
|
updateToolbar()
|
||||||
|
|
||||||
|
bindFromAccounts()
|
||||||
|
val previous = selectedTo
|
||||||
|
val to = t.accounts.firstOrNull { it.account == previous?.account }
|
||||||
|
?: t.accounts.firstOrNull { it.account == t.peerAccount }
|
||||||
|
selectTo(to, matchFromCurrency = previous == null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
|
||||||
|
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val t = thread ?: return
|
||||||
|
selectedTo = to
|
||||||
|
|
||||||
|
b.toAccountBar.visibility = if (to == null) View.GONE else View.VISIBLE
|
||||||
|
if (to != null) {
|
||||||
|
b.tvToAccount.text = to.account
|
||||||
|
b.tvToCurrency.text = to.currency
|
||||||
|
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
|
||||||
|
b.tvToNickname.text = nickname
|
||||||
|
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
|
||||||
|
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offer to save the selected account (or the person, when no account is known yet).
|
||||||
|
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
val canSend = to != null
|
||||||
|
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
|
||||||
|
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
// Sending to a USD account: default to a USD source account, and likewise for MVR.
|
||||||
|
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
|
||||||
|
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
|
||||||
|
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecipientPicker() {
|
||||||
|
val t = thread ?: return
|
||||||
|
if (t.accounts.size < 2) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in t.accounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
|
||||||
|
?: getString(R.string.chat_not_in_contacts)
|
||||||
|
row.tvDropdownAccountNumber.text = account.account
|
||||||
|
row.tvDropdownBalance.text = account.currency
|
||||||
|
row.tvDropdownAccountType.visibility = View.GONE
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
selectTo(account, matchFromCurrency = true)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
|
||||||
|
val pad = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(pad, pad / 2, pad, pad / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFromAccounts() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
// Default to the account last used with this person.
|
||||||
|
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
|
||||||
|
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
|
||||||
|
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
|
||||||
|
|
||||||
|
selectFrom(selectedFrom!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTransferDone() {
|
||||||
|
binding.etAmount.text = null
|
||||||
|
// The receipt shows the new bubble right away; history catches up a little later and
|
||||||
|
// replaces it with the bank's own entry.
|
||||||
|
sync()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(HISTORY_CATCH_UP_MS)
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
Snackbar.make(binding.root, R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
|
||||||
|
.setAnchorView(binding.composer)
|
||||||
|
.setAction(R.string.chat_view_receipt) {
|
||||||
|
val latest = ReceiptStore.loadAll(requireContext()).firstOrNull() ?: return@setAction
|
||||||
|
openReceipt(latest.savedAt.toString())
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReceipt(receiptKey: String) {
|
||||||
|
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
|
||||||
|
if (entry == null) {
|
||||||
|
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
if (_binding != null) loadThread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectFrom(account: BankAccount) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
selectedFrom = account
|
||||||
|
bindAccountRow(b.fromAccountRow, account)
|
||||||
|
b.tilAmount.prefixText = account.currencyName
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
|
||||||
|
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
|
||||||
|
val display = AccountListParser.from(account)
|
||||||
|
row.tvDropdownAccountName.text = account.accountBriefName
|
||||||
|
row.tvDropdownAccountNumber.text = account.accountNumber
|
||||||
|
val balance = display?.balance.orEmpty()
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
|
||||||
|
val type = display?.typeLabel.orEmpty()
|
||||||
|
row.tvDropdownAccountType.text = type
|
||||||
|
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAccountPicker() {
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in fromAccounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
bindAccountRow(row, account)
|
||||||
|
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
selectFrom(account)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val from = selectedFrom ?: return
|
||||||
|
val to = selectedTo ?: return
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0.0) {
|
||||||
|
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val transfer = TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = to.account,
|
||||||
|
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
|
||||||
|
amount = "%.2f".format(amount),
|
||||||
|
remarks = null,
|
||||||
|
fromAccountNumber = from.accountNumber,
|
||||||
|
returnOnSuccess = true
|
||||||
|
)
|
||||||
|
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||||
|
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
|
||||||
|
private fun updateToolbar() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
|
||||||
|
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
|
||||||
|
header = h
|
||||||
|
h.root.setOnClickListener { openContact() }
|
||||||
|
}
|
||||||
|
h.tvHeaderName.text = t.peerName
|
||||||
|
// Show the real account-holder name when the chat is titled with a nickname.
|
||||||
|
val subtitle = t.realName.takeIf { !it.equals(t.peerName, ignoreCase = true) }.orEmpty()
|
||||||
|
h.tvHeaderAccount.text = subtitle
|
||||||
|
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val sizePx = (40 * resources.displayMetrics.density).toInt()
|
||||||
|
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||||
|
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
|
||||||
|
|
||||||
|
requireActivity().title = t.peerName
|
||||||
|
bar.setDisplayShowTitleEnabled(false)
|
||||||
|
bar.setDisplayShowCustomEnabled(true)
|
||||||
|
bar.customView = h.root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openContact() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
|
||||||
|
if (display != null) {
|
||||||
|
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||||
|
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
|
||||||
|
} else {
|
||||||
|
// Not a saved contact yet: offer to save them, prefilled with what we know.
|
||||||
|
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
|
||||||
|
AddContactSheetFragment.newInstance(
|
||||||
|
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = selectedTo?.account,
|
||||||
|
recipientName = t.realName.ifBlank { t.peerName },
|
||||||
|
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
|
||||||
|
).show(childFragmentManager, "add_contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openTransfer(contact: ContactDisplay) {
|
||||||
|
val fragment = TransferFragment.newInstance(
|
||||||
|
accountNumber = contact.accountNumber,
|
||||||
|
displayName = contact.name,
|
||||||
|
subtitle = contact.transferSubtitle,
|
||||||
|
colorHex = contact.bankColor,
|
||||||
|
imageHash = contact.imageHash
|
||||||
|
)
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
|
||||||
|
override fun confirmDelete(contact: ContactDisplay) {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
(activity as? AppCompatActivity)?.supportActionBar?.apply {
|
||||||
|
setDisplayShowCustomEnabled(false)
|
||||||
|
setDisplayShowTitleEnabled(true)
|
||||||
|
customView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
header = null
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_PEER_KEY = "peer_key"
|
||||||
|
private const val HISTORY_CATCH_UP_MS = 10_000L
|
||||||
|
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||||
|
|
||||||
|
fun newInstance(peerKey: String) = ChatFragment().apply {
|
||||||
|
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatDateBinding
|
||||||
|
import sh.sar.basedbank.util.ChatMessage
|
||||||
|
|
||||||
|
/** Transfers with one person as chat bubbles: sent on the right, received on the left. */
|
||||||
|
class ChatMessagesAdapter(
|
||||||
|
private val onReceiptClick: (ChatMessage) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class DateChip(val label: String) : Item()
|
||||||
|
data class Bubble(val message: ChatMessage) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val items = mutableListOf<Item>()
|
||||||
|
private var hideAmounts = false
|
||||||
|
|
||||||
|
/** When the person has several accounts, sent bubbles say which one the money went to. */
|
||||||
|
var showDestination = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [messages] must be oldest first. */
|
||||||
|
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
|
||||||
|
items.clear()
|
||||||
|
var lastDay = ""
|
||||||
|
for (m in messages) {
|
||||||
|
val day = m.date.take(10)
|
||||||
|
if (day != lastDay) {
|
||||||
|
val label = when {
|
||||||
|
DateUtils.isToday(m.timeMillis) -> todayLabel
|
||||||
|
DateUtils.isToday(m.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
|
||||||
|
else -> AccountHistoryAdapter.formatDateHeader(m.date)
|
||||||
|
}
|
||||||
|
items.add(Item.DateChip(label))
|
||||||
|
lastDay = day
|
||||||
|
}
|
||||||
|
items.add(Item.Bubble(m))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
|
is Item.DateChip -> TYPE_DATE
|
||||||
|
is Item.Bubble -> TYPE_BUBBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
|
||||||
|
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (val item = items[position]) {
|
||||||
|
is Item.DateChip -> (holder as DateVH).b.tvDate.text = item.label
|
||||||
|
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateVH(val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root)
|
||||||
|
|
||||||
|
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(m: ChatMessage) {
|
||||||
|
val ctx = b.root.context
|
||||||
|
val sent = m.isSent
|
||||||
|
|
||||||
|
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
(b.tvMeta.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
|
||||||
|
// Sent: tinted like the mockup. Received: neutral grey with an outline, so the two
|
||||||
|
// read apart even on a red-tinted theme.
|
||||||
|
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
|
||||||
|
else Color.parseColor(if (night) "#2A2C2E" else "#F1F2F4")
|
||||||
|
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
|
||||||
|
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
|
||||||
|
b.cardBubble.setCardBackgroundColor(bg)
|
||||||
|
b.cardBubble.strokeWidth = if (sent) 0 else (1 * ctx.resources.displayMetrics.density).toInt()
|
||||||
|
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
|
||||||
|
b.tvLabel.setTextColor(fg)
|
||||||
|
b.tvAmount.setTextColor(fg)
|
||||||
|
b.tvAccount.setTextColor(fg)
|
||||||
|
b.tvNote.setTextColor(fg)
|
||||||
|
b.btnReceipt.setColorFilter(fg)
|
||||||
|
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||||
|
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
|
||||||
|
|
||||||
|
b.tvLabel.text = ctx.getString(if (sent) R.string.chat_sent_label else R.string.chat_received_label)
|
||||||
|
// Own account on its own line so long names aren't cut off by the receipt icon and tag.
|
||||||
|
b.tvAccount.text = when {
|
||||||
|
!sent -> ctx.getString(R.string.chat_into_label, m.accountDisplayName)
|
||||||
|
showDestination && m.peerAccount.isNotBlank() ->
|
||||||
|
ctx.getString(R.string.chat_from_to_label, m.accountDisplayName, m.peerAccount)
|
||||||
|
else -> ctx.getString(R.string.chat_from_label, m.accountDisplayName)
|
||||||
|
}
|
||||||
|
b.tvAccount.visibility = if (m.accountDisplayName.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
b.tvBadge.text = ctx.getString(if (sent) R.string.chat_debit else R.string.chat_credit)
|
||||||
|
// Debit uses the bubble's own text colour; Credit a green that stays readable in both themes.
|
||||||
|
val badgeFg = if (sent) fg else Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||||
|
b.tvBadge.setTextColor(badgeFg)
|
||||||
|
b.tvBadge.background = GradientDrawable().apply {
|
||||||
|
cornerRadius = 10 * ctx.resources.displayMetrics.density
|
||||||
|
setColor(androidx.core.graphics.ColorUtils.setAlphaComponent(badgeFg, 0x2E))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.tvAmount.text = if (hideAmounts) "${m.currency} ••••••"
|
||||||
|
else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
|
||||||
|
b.tvNote.text = m.note
|
||||||
|
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
val time = AccountHistoryAdapter.formatTime(m.date)
|
||||||
|
b.tvMeta.text = if (sent) ctx.getString(R.string.chat_sent_meta, time) else time
|
||||||
|
|
||||||
|
b.cardBubble.setOnClickListener { showDetail(m) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDetail(m: ChatMessage) {
|
||||||
|
val ctx = b.root.context
|
||||||
|
val details = buildString {
|
||||||
|
val sign = if (m.isSent) "-" else "+"
|
||||||
|
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
|
||||||
|
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
|
||||||
|
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
|
||||||
|
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
|
||||||
|
append("Account\n${m.accountDisplayName}")
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(m.peerName)
|
||||||
|
.setMessage(details)
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE = 0
|
||||||
|
private const val TYPE_BUBBLE = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBinding
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
|
||||||
|
/** Telegram-style chat list: one row per person, latest transfer as the preview. */
|
||||||
|
class ChatsAdapter(
|
||||||
|
private val onChatClick: (ChatThread) -> Unit
|
||||||
|
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var allThreads: List<ChatThread> = emptyList()
|
||||||
|
private var displayed: List<ChatThread> = emptyList()
|
||||||
|
private var searchQuery = ""
|
||||||
|
private var hideAmounts = false
|
||||||
|
|
||||||
|
fun updateThreads(threads: List<ChatThread>) {
|
||||||
|
allThreads = threads
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearch(query: String) {
|
||||||
|
searchQuery = query
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isEmpty get() = displayed.isEmpty()
|
||||||
|
|
||||||
|
private fun applyFilter() {
|
||||||
|
displayed = if (searchQuery.isBlank()) allThreads else allThreads.filter {
|
||||||
|
it.peerName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.realName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.peerAccount.contains(searchQuery) ||
|
||||||
|
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
holder.binding.root.setOnClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
|
||||||
|
}
|
||||||
|
return holder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
|
||||||
|
|
||||||
|
override fun getItemCount() = displayed.size
|
||||||
|
|
||||||
|
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(thread: ChatThread) {
|
||||||
|
val ctx = binding.root.context
|
||||||
|
val last = thread.last
|
||||||
|
binding.tvName.text = thread.peerName
|
||||||
|
|
||||||
|
val amount = if (hideAmounts) "${last.currency} ••••••"
|
||||||
|
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
|
||||||
|
binding.tvPreview.text = if (last.isSent) {
|
||||||
|
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
|
||||||
|
ctx.getString(R.string.chat_preview_sent, amount) + note
|
||||||
|
} else {
|
||||||
|
ctx.getString(R.string.chat_preview_received, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
|
||||||
|
else AccountHistoryAdapter.formatDateOnly(last.date)
|
||||||
|
|
||||||
|
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
|
||||||
|
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
|
||||||
|
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
|
||||||
|
|
||||||
|
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
|
||||||
|
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
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.databinding.FragmentChatsBinding
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/** Transfers grouped by person, shown as a chat list. Contacts is reachable from the toolbar. */
|
||||||
|
class ChatsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private lateinit var adapter: ChatsAdapter
|
||||||
|
private var syncedThisView = false
|
||||||
|
private var syncedWithContacts = false
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ChatsAdapter { thread ->
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
|
||||||
|
}
|
||||||
|
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.rvChats.adapter = adapter
|
||||||
|
|
||||||
|
binding.etSearch.addTextChangedListener { text ->
|
||||||
|
adapter.setSearch(text?.toString() ?: "")
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener { sync() }
|
||||||
|
|
||||||
|
requireActivity().addMenuProvider(object : MenuProvider {
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
|
||||||
|
.setIcon(R.drawable.ic_contacts)
|
||||||
|
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
if (menuItem.itemId != R.id.action_open_contacts) return false
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
// Contacts give chats a saved name and an account number to send to.
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
loadThreads()
|
||||||
|
// Contacts usually load after the first sync; sync again so their real names get looked up.
|
||||||
|
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts arrive after login; sync once they are available.
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
syncedThisView = true
|
||||||
|
binding.swipeRefresh.isRefreshing = true
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
if (contacts.isNotEmpty()) syncedWithContacts = true
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
_binding?.swipeRefresh?.isRefreshing = false
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThreads() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
adapter.updateThreads(threads)
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateEmptyView() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
|
||||||
|
if (!adapter.isEmpty) return
|
||||||
|
val since = ChatStore.sinceMillis(requireContext())
|
||||||
|
b.emptyView.text = when {
|
||||||
|
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
|
||||||
|
since > 0L -> getString(R.string.chats_empty_since,
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
|
||||||
|
else -> getString(R.string.chats_empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,14 +24,14 @@ import sh.sar.basedbank.util.ContactListParser
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.TransferNetwork
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
|
/** Contact details drawer shown when a contact is tapped in [ContactsFragment] or a chat header. */
|
||||||
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
private var _binding: SheetContactDetailsBinding? = null
|
private var _binding: SheetContactDetailsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private val contactsFragment get() = parentFragment as? ContactsFragment
|
private val contactsFragment get() = parentFragment as? ContactSheetHost
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
|
/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */
|
||||||
|
interface ContactSheetHost {
|
||||||
|
fun openTransfer(contact: ContactDisplay)
|
||||||
|
fun confirmDelete(contact: ContactDisplay)
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager
|
|||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
import sh.sar.basedbank.util.TransferNetwork
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
class ContactsFragment : Fragment() {
|
class ContactsFragment : Fragment(), ContactSheetHost {
|
||||||
|
|
||||||
private var _binding: FragmentContactsBinding? = null
|
private var _binding: FragmentContactsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
@@ -182,7 +182,7 @@ class ContactsFragment : Fragment() {
|
|||||||
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun openTransfer(contact: ContactDisplay) {
|
override fun openTransfer(contact: ContactDisplay) {
|
||||||
val fragment = TransferFragment.newInstance(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.accountNumber,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.name,
|
displayName = contact.name,
|
||||||
@@ -193,7 +193,7 @@ class ContactsFragment : Fragment() {
|
|||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun confirmDelete(contact: ContactDisplay) {
|
override fun confirmDelete(contact: ContactDisplay) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
||||||
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val frag = when (item.itemId) {
|
val frag = when (item.itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
|
R.id.nav_chats -> ChatsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
@@ -473,6 +474,7 @@ fun applyNavLabelVisibility() {
|
|||||||
val dest = fragment ?: when (itemId) {
|
val dest = fragment ?: when (itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
|
R.id.nav_chats -> ChatsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ object NavCustomization {
|
|||||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
val ALL_SWAPPABLE = listOf(
|
val ALL_SWAPPABLE = listOf(
|
||||||
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||||
|
NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats),
|
||||||
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||||
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||||
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||||
@@ -51,14 +52,14 @@ object NavCustomization {
|
|||||||
|
|
||||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
||||||
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
|
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats),
|
||||||
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
||||||
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
|
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats")
|
||||||
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ object NavCustomization {
|
|||||||
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
|
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
|
||||||
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
|
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
|
||||||
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
|
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_chats),
|
||||||
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
|
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ object NavCustomization {
|
|||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
|
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
|
||||||
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
|
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
|
||||||
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
|
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_chats")
|
||||||
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
|
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.text.TextPaint
|
|
||||||
import android.text.TextUtils
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
@@ -68,18 +65,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
|
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
|
||||||
|
|
||||||
private fun currentTarget(): QrTarget? = contactTarget
|
private fun currentTarget(): QrTarget? = contactTarget
|
||||||
?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) }
|
?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) }
|
||||||
|
|
||||||
/** Name printed on the card and put in the payload (tag 59). */
|
|
||||||
private fun qrHolderName(account: BankAccount): String = when {
|
|
||||||
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
|
|
||||||
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
|
|
||||||
?: CredentialStore(requireContext())
|
|
||||||
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
|
|
||||||
?.fullName?.takeIf { it.isNotBlank() }
|
|
||||||
?: account.accountBriefName
|
|
||||||
else -> account.accountBriefName
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
contactTarget = arguments?.let { args ->
|
contactTarget = arguments?.let { args ->
|
||||||
@@ -160,13 +146,11 @@ class PayMvQrFragment : Fragment() {
|
|||||||
"FAHIPAY" -> "FAHIMVMV"
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
else -> "MADVMVMV"
|
else -> "MADVMVMV"
|
||||||
}
|
}
|
||||||
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
|
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||||
val amountFormatted = amountRaw
|
?.replace(",", "")
|
||||||
?.toDoubleOrNull()
|
?.toDoubleOrNull()
|
||||||
?.takeIf { it > 0 }
|
?.takeIf { it > 0 }
|
||||||
?.let { "%.2f".format(it) }
|
?.let { "%.2f".format(it) }
|
||||||
// BML shows the amount on the card as typed (no forced decimals)
|
|
||||||
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
|
|
||||||
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val account = selectedAccount
|
val account = selectedAccount
|
||||||
@@ -181,28 +165,17 @@ class PayMvQrFragment : Fragment() {
|
|||||||
when {
|
when {
|
||||||
m.startsWith("+") -> m
|
m.startsWith("+") -> m
|
||||||
m.length == 7 -> "+960$m"
|
m.length == 7 -> "+960$m"
|
||||||
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
|
|
||||||
else -> m
|
else -> m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val purpose = binding.etReference.text?.toString()?.trim()
|
val purpose = binding.etReference.text?.toString()?.trim()
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
||||||
|
|
||||||
// The reference (62/05) is also printed vertically beside the QR, as each bank does
|
|
||||||
val reference = when (target.bank) {
|
|
||||||
// BML: base-32 account number followed by the amount as typed
|
|
||||||
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
|
|
||||||
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
|
|
||||||
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
|
|
||||||
else -> generateReference(9)
|
|
||||||
}
|
|
||||||
|
|
||||||
val bmp = withContext(Dispatchers.Default) {
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank)
|
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
|
||||||
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
|
renderQrCard(ctx, target, payload, amountFormatted)
|
||||||
else renderQrCard(ctx, target, payload, reference)
|
|
||||||
}
|
}
|
||||||
if (_binding == null) return
|
if (_binding == null) return
|
||||||
generatedBitmap = bmp
|
generatedBitmap = bmp
|
||||||
@@ -221,19 +194,14 @@ class PayMvQrFragment : Fragment() {
|
|||||||
acquirer: String,
|
acquirer: String,
|
||||||
amountStr: String?,
|
amountStr: String?,
|
||||||
mobile: String?,
|
mobile: String?,
|
||||||
purpose: String?,
|
purpose: String
|
||||||
ref: String,
|
|
||||||
bank: String
|
|
||||||
): String {
|
): String {
|
||||||
fun tlv(tag: String, value: String): String {
|
fun tlv(tag: String, value: String): String {
|
||||||
val len = value.length
|
val len = value.length
|
||||||
return tag + (if (len < 10) "0$len" else "$len") + value
|
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||||
}
|
}
|
||||||
val format = tlv("00", "01")
|
val format = tlv("00", "01")
|
||||||
// Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount
|
val poi = tlv("01", "11")
|
||||||
// as "***" when they don't; its scanner may reject QRs that differ
|
|
||||||
val fahipay = bank == "FAHIPAY"
|
|
||||||
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
|
|
||||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
val sub01 = tlv("01", acquirer)
|
val sub01 = tlv("01", acquirer)
|
||||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||||
@@ -243,28 +211,21 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||||
val mcc = tlv("52", "0000")
|
val mcc = tlv("52", "0000")
|
||||||
val currency = tlv("53", "462")
|
val currency = tlv("53", "462")
|
||||||
val amountTLV = when {
|
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||||
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
|
|
||||||
fahipay -> tlv("54", "***")
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
val country = tlv("58", "MV")
|
val country = tlv("58", "MV")
|
||||||
val name = tlv("59", accountName.uppercase().take(25))
|
val name = tlv("59", accountName.take(25))
|
||||||
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
|
val ref = generateReference()
|
||||||
val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else ""
|
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
||||||
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
|
|
||||||
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
|
|
||||||
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
|
|
||||||
val timestamp = java.time.LocalDateTime.now()
|
val timestamp = java.time.LocalDateTime.now()
|
||||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
||||||
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
||||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304"
|
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
||||||
return prefix + crc16(prefix)
|
return prefix + crc16(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateReference(length: Int): String {
|
private fun generateReference(): String {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
return (1..length).map { chars.random() }.joinToString("")
|
return (1..9).map { chars.random() }.joinToString("")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun crc16(data: String): String {
|
private fun crc16(data: String): String {
|
||||||
@@ -281,252 +242,88 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
// ── QR card rendering ────────────────────────────────────────────────────
|
// ── QR card rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
|
|
||||||
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
|
|
||||||
* [PX_PER_DP] pixels per dp.
|
|
||||||
*/
|
|
||||||
private fun renderQrCard(
|
private fun renderQrCard(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
target: QrTarget,
|
target: QrTarget,
|
||||||
qrPayload: String,
|
qrPayload: String,
|
||||||
qrId: String
|
amountStr: String?
|
||||||
): Bitmap {
|
): Bitmap {
|
||||||
val sw = SCREEN_WIDTH_DP
|
val W = 900
|
||||||
fun px(dp: Float) = dp * PX_PER_DP
|
val H = 1080
|
||||||
val mmaBlue = Color.parseColor("#0E5CA4")
|
val outerCorner = 48f
|
||||||
|
val boxBlue = Color.parseColor("#2272B7")
|
||||||
|
val footerBlue = Color.parseColor("#1A5799")
|
||||||
|
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
||||||
|
|
||||||
val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side
|
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||||
val qrSize = sw * 0.5f
|
|
||||||
val railW = sw / 1.85f
|
|
||||||
|
|
||||||
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = Color.WHITE
|
|
||||||
textSize = px(14f)
|
|
||||||
typeface = Typeface.DEFAULT
|
|
||||||
textAlign = Paint.Align.CENTER
|
|
||||||
}
|
|
||||||
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = Color.WHITE
|
|
||||||
textSize = px(sw * 0.046f)
|
|
||||||
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
|
||||||
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
|
|
||||||
textAlign = Paint.Align.CENTER
|
|
||||||
}
|
|
||||||
// Android RN Text lines include font padding: line height = bottom - top
|
|
||||||
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
|
|
||||||
|
|
||||||
// --- Vertical layout (dp, card-local) ---
|
|
||||||
val topCardTop = 2f
|
|
||||||
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
|
|
||||||
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
|
|
||||||
val logoBoxH = logoBoxW * 0.1116751269035533f
|
|
||||||
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
|
|
||||||
val payMvBoxH = payMvBoxW * 0.17333333333333334f
|
|
||||||
val brandH = maxOf(logoBoxH, payMvBoxH)
|
|
||||||
val nameText = target.name.uppercase()
|
|
||||||
val hasName = nameText.isNotBlank()
|
|
||||||
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
|
|
||||||
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
|
|
||||||
val nameH = if (hasName) lineHeight(namePaint) else 0f
|
|
||||||
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
|
|
||||||
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
|
|
||||||
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
|
|
||||||
val footerLineH = lineHeight(footerPaint)
|
|
||||||
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
|
|
||||||
|
|
||||||
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
|
// Clip to outer rounded card shape
|
||||||
val outerPath = Path().apply {
|
val outerPath = Path()
|
||||||
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW)
|
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
||||||
}
|
|
||||||
canvas.clipPath(outerPath)
|
canvas.clipPath(outerPath)
|
||||||
canvas.drawColor(mmaBlue)
|
canvas.drawColor(Color.WHITE)
|
||||||
|
|
||||||
// topCard: white, 2dp inset, top corners 18
|
// --- Bank logo top-left ---
|
||||||
paint.color = Color.WHITE
|
val logoRes = when (target.bank) {
|
||||||
val r = px(18f)
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
canvas.drawPath(Path().apply {
|
"MIB" -> R.drawable.mib_faisanet_logo
|
||||||
addRoundRect(
|
else -> R.drawable.fahipay_logo_long
|
||||||
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)),
|
}
|
||||||
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
|
|
||||||
)
|
|
||||||
}, paint)
|
|
||||||
|
|
||||||
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
|
|
||||||
val rowL = 2f + 40f
|
|
||||||
val rowR = cardW - 2f - 40f
|
|
||||||
val rowCenterY = brandTop + brandH / 2
|
|
||||||
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
|
|
||||||
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||||
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||||
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||||
// resizeMode "contain" inside the logo box, left-aligned in the row
|
val maxW = 180f; val maxH = 76f
|
||||||
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH)
|
val scale = minOf(maxW / nW, maxH / nH)
|
||||||
val lW = (nW * scale).toInt()
|
val lW = (nW * scale).toInt()
|
||||||
val lH = (nH * scale).toInt()
|
val lH = (nH * scale).toInt()
|
||||||
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
|
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
||||||
val lTop = (px(rowCenterY) - lH / 2f).toInt()
|
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
||||||
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
|
|
||||||
d.draw(canvas)
|
d.draw(canvas)
|
||||||
}
|
}
|
||||||
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
|
|
||||||
paint.color = mmaBlue
|
// --- "PayMV QR" top-right ---
|
||||||
paint.typeface = footerPaint.typeface
|
paint.color = Color.parseColor("#1A1A2E")
|
||||||
|
paint.textSize = 36f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
paint.textAlign = Paint.Align.RIGHT
|
paint.textAlign = Paint.Align.RIGHT
|
||||||
paint.textSize = px(payMvBoxH)
|
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||||
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
|
|
||||||
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
|
|
||||||
canvas.drawText(
|
|
||||||
"PayMV QR", px(rowR),
|
|
||||||
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- qrCard: mmaBlue, radius 16, 40dp side margins ---
|
// --- Blue rounded box ---
|
||||||
val qrCardL = 2f + 40f
|
paint.color = boxBlue
|
||||||
val qrCardR = cardW - 2f - 40f
|
paint.textAlign = Paint.Align.LEFT
|
||||||
paint.color = mmaBlue
|
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||||
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint)
|
|
||||||
|
|
||||||
if (hasName) {
|
// Account name (white, bold, uppercase, auto-scaled to fit)
|
||||||
val maxNameW = px(qrCardR - qrCardL)
|
|
||||||
if (namePaint.measureText(nameText) > maxNameW) {
|
|
||||||
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
|
||||||
}
|
|
||||||
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
|
|
||||||
val qrPx = px(qrSize).toInt()
|
|
||||||
val qrLeft = px(cardW / 2) - qrPx / 2f
|
|
||||||
try {
|
|
||||||
val hints = mapOf(
|
|
||||||
EncodeHintType.MARGIN to 0,
|
|
||||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
|
||||||
)
|
|
||||||
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
|
||||||
val pixels = IntArray(qrPx * qrPx)
|
|
||||||
for (y in 0 until qrPx) {
|
|
||||||
for (x in 0 until qrPx) {
|
|
||||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
|
||||||
canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null)
|
|
||||||
qrBm.recycle()
|
|
||||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
|
||||||
|
|
||||||
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
|
|
||||||
if (qrId.isNotEmpty()) {
|
|
||||||
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = Color.BLACK
|
|
||||||
alpha = (255 * 0.8f).toInt()
|
|
||||||
textSize = px(10f)
|
|
||||||
typeface = Typeface.DEFAULT
|
|
||||||
textAlign = Paint.Align.CENTER
|
|
||||||
}
|
|
||||||
val qrRight = cardW / 2 + qrSize / 2
|
|
||||||
val cx = px(qrRight + railW / 1.37f - railW / 2)
|
|
||||||
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
|
|
||||||
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
|
|
||||||
canvas.save()
|
|
||||||
canvas.rotate(-90f, cx, cy)
|
|
||||||
val fm = idPaint.fontMetrics
|
|
||||||
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
|
|
||||||
canvas.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
|
|
||||||
canvas.drawText(
|
|
||||||
"MALDIVES NATIONAL QR", px(cardW / 2),
|
|
||||||
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
|
|
||||||
)
|
|
||||||
|
|
||||||
return bm
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
|
|
||||||
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
|
|
||||||
* except the vertical reference, which is Montserrat as on Fahipay's.
|
|
||||||
*/
|
|
||||||
private fun renderFahipayQrCard(
|
|
||||||
ctx: Context,
|
|
||||||
target: QrTarget,
|
|
||||||
qrPayload: String,
|
|
||||||
reference: String
|
|
||||||
): Bitmap {
|
|
||||||
val w = 1240f
|
|
||||||
val h = 1322f
|
|
||||||
val blue = Color.parseColor("#005DA3")
|
|
||||||
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
|
||||||
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
|
|
||||||
|
|
||||||
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(bm)
|
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
|
|
||||||
// Blue card with a 6px border around the white area; footer is the blue below it
|
|
||||||
canvas.clipPath(Path().apply {
|
|
||||||
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
|
|
||||||
})
|
|
||||||
canvas.drawColor(blue)
|
|
||||||
paint.color = Color.WHITE
|
paint.color = Color.WHITE
|
||||||
canvas.drawPath(Path().apply {
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
addRoundRect(
|
paint.textAlign = Paint.Align.CENTER
|
||||||
RectF(6f, 6f, w - 6f, 1174f),
|
|
||||||
floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
|
|
||||||
)
|
|
||||||
}, paint)
|
|
||||||
|
|
||||||
// Square app icon, then the "FahiPay" wordmark, in one row
|
|
||||||
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
|
|
||||||
d.setBounds(94, 94, 163, 163)
|
|
||||||
d.draw(canvas)
|
|
||||||
}
|
|
||||||
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
|
|
||||||
val lH = 48
|
|
||||||
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
|
|
||||||
d.setBounds(178, 104, 178 + lW, 104 + lH)
|
|
||||||
d.draw(canvas)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sized so capitals match the reference's cap heights; positions below are cap tops
|
|
||||||
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
|
|
||||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
typeface = tf
|
|
||||||
letterSpacing = spacingEm
|
|
||||||
textAlign = align
|
|
||||||
textSize = 100f
|
|
||||||
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
|
|
||||||
textSize = 100f * capH / h
|
|
||||||
}
|
|
||||||
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
|
|
||||||
|
|
||||||
// "PayMV QR" top-right
|
|
||||||
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
|
|
||||||
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
|
|
||||||
|
|
||||||
// Blue QR panel
|
|
||||||
paint.color = blue
|
|
||||||
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
|
|
||||||
|
|
||||||
// Account name
|
|
||||||
val nameText = target.name.uppercase()
|
val nameText = target.name.uppercase()
|
||||||
if (nameText.isNotBlank()) {
|
paint.textSize = 36f
|
||||||
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
val maxNameW = boxR - boxL - 48f
|
||||||
val maxNameW = 1074f - 166f - 80f
|
if (paint.measureText(nameText) > maxNameW) {
|
||||||
if (namePaint.measureText(nameText) > maxNameW) {
|
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
||||||
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
}
|
||||||
}
|
val nameBaseline = boxT + 68f
|
||||||
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint)
|
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
||||||
|
|
||||||
|
// Optional amount below name
|
||||||
|
val qrTopY: Float
|
||||||
|
if (!amountStr.isNullOrBlank()) {
|
||||||
|
paint.textSize = 28f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||||
|
val amtBaseline = nameBaseline + 42f
|
||||||
|
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
||||||
|
qrTopY = amtBaseline + 20f
|
||||||
|
} else {
|
||||||
|
qrTopY = nameBaseline + 26f
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR — white modules on blue, no quiet zone
|
// QR code — white modules on the same blue as the box background
|
||||||
val qrPx = 562
|
val availH = boxB - qrTopY - 24f
|
||||||
|
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
||||||
|
val qrLeft = ((W - qrPx) / 2).toFloat()
|
||||||
try {
|
try {
|
||||||
val hints = mapOf(
|
val hints = mapOf(
|
||||||
EncodeHintType.MARGIN to 0,
|
EncodeHintType.MARGIN to 0,
|
||||||
@@ -536,25 +333,23 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val pixels = IntArray(qrPx * qrPx)
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
for (y in 0 until qrPx) {
|
for (y in 0 until qrPx) {
|
||||||
for (x in 0 until qrPx) {
|
for (x in 0 until qrPx) {
|
||||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else blue
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
canvas.drawBitmap(qrBm, 338f, 417f, null)
|
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||||
qrBm.recycle()
|
qrBm.recycle()
|
||||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
// Reference, blue, reading bottom-to-top in the white margin right of the panel,
|
// --- Dark blue footer ---
|
||||||
// starting level with y=1087 and with its baseline at x=1171
|
paint.color = footerBlue
|
||||||
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue }
|
paint.textAlign = Paint.Align.LEFT
|
||||||
canvas.save()
|
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
||||||
canvas.rotate(-90f, 1171f, 1087f)
|
paint.color = Color.WHITE
|
||||||
canvas.drawText(reference, 1171f, 1087f, refPaint)
|
paint.textSize = 32f
|
||||||
canvas.restore()
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
// Footer
|
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
||||||
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
|
||||||
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
|
|
||||||
|
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
@@ -642,10 +437,6 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private const val ARG_ACCOUNT_NAME = "account_name"
|
private const val ARG_ACCOUNT_NAME = "account_name"
|
||||||
private const val ARG_BANK = "bank"
|
private const val ARG_BANK = "bank"
|
||||||
|
|
||||||
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
|
|
||||||
private const val SCREEN_WIDTH_DP = 560f
|
|
||||||
private const val PX_PER_DP = 2f
|
|
||||||
|
|
||||||
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
|
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
|
||||||
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
|
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ class TransferFragment : Fragment() {
|
|||||||
private var resolvedAccountNumber = ""
|
private var resolvedAccountNumber = ""
|
||||||
private var resolvedRecipientName = ""
|
private var resolvedRecipientName = ""
|
||||||
private var resolvedBankName = ""
|
private var resolvedBankName = ""
|
||||||
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
|
||||||
private var loadedToPhoto: Bitmap? = null
|
|
||||||
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||||
private var resolvedToOwnAccount: BankAccount? = null
|
private var resolvedToOwnAccount: BankAccount? = null
|
||||||
|
|
||||||
@@ -117,7 +115,7 @@ class TransferFragment : Fragment() {
|
|||||||
clearForm()
|
clearForm()
|
||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
showReceipt(receipt, avatar)
|
||||||
}
|
}
|
||||||
).also { bmlHandler = it }
|
).also { bmlHandler = it }
|
||||||
|
|
||||||
@@ -157,7 +155,7 @@ class TransferFragment : Fragment() {
|
|||||||
clearForm()
|
clearForm()
|
||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
showReceipt(receipt, avatar)
|
||||||
}
|
}
|
||||||
).also { mfaisaHandler = it }
|
).also { mfaisaHandler = it }
|
||||||
|
|
||||||
@@ -247,6 +245,10 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
private const val ARG_BML_QR_URL = "bml_qr_url"
|
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||||
private const val ARG_AUTO_SCAN = "auto_scan"
|
private const val ARG_AUTO_SCAN = "auto_scan"
|
||||||
|
private const val ARG_RETURN_ON_SUCCESS = "return_on_success"
|
||||||
|
|
||||||
|
/** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */
|
||||||
|
const val RESULT_TRANSFER_DONE = "transfer_done"
|
||||||
|
|
||||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||||
@@ -284,7 +286,8 @@ class TransferFragment : Fragment() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
amount: String?,
|
amount: String?,
|
||||||
remarks: String?,
|
remarks: String?,
|
||||||
fromAccountNumber: String? = null
|
fromAccountNumber: String? = null,
|
||||||
|
returnOnSuccess: Boolean = false
|
||||||
) = TransferFragment().apply {
|
) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT, accountNumber)
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
@@ -294,10 +297,28 @@ class TransferFragment : Fragment() {
|
|||||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||||
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||||
|
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the receipt, or — when opened with returnOnSuccess (e.g. from a chat) — goes back to
|
||||||
|
* the caller and tells it via [RESULT_TRANSFER_DONE] on the activity's fragment manager;
|
||||||
|
* the receipt is already in ReceiptStore.
|
||||||
|
*/
|
||||||
|
private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) {
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) == true) {
|
||||||
|
activity.supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
|
||||||
|
// Hosted in a sheet (e.g. over a chat): close the sheet; otherwise leave this page.
|
||||||
|
val sheet = parentFragment as? androidx.fragment.app.DialogFragment
|
||||||
|
if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack()
|
||||||
|
} else {
|
||||||
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentTransferBinding.inflate(inflater, container, false)
|
_binding = FragmentTransferBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -1170,8 +1191,6 @@ class TransferFragment : Fragment() {
|
|||||||
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
|
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
|
||||||
val bankNameCapture = resolvedBankName
|
val bankNameCapture = resolvedBankName
|
||||||
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
||||||
// The MIB receipt only takes a real photo; it draws its own initials placeholder otherwise
|
|
||||||
val capturedToPhoto = capturedToAvatar?.takeIf { it === loadedToPhoto }
|
|
||||||
|
|
||||||
val destCurrency = resolvedDestCurrency.ifBlank {
|
val destCurrency = resolvedDestCurrency.ifBlank {
|
||||||
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||||
@@ -1209,7 +1228,7 @@ class TransferFragment : Fragment() {
|
|||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToPhoto))
|
showReceipt(receipt, capturedToAvatar)
|
||||||
} else if (!ok) {
|
} else if (!ok) {
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
if (msg == "CONNECTIVITY") {
|
if (msg == "CONNECTIVITY") {
|
||||||
@@ -1585,7 +1604,6 @@ class TransferFragment : Fragment() {
|
|||||||
if (_binding != null) {
|
if (_binding != null) {
|
||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
binding.ivToPhoto.setImageBitmap(bitmap)
|
binding.ivToPhoto.setImageBitmap(bitmap)
|
||||||
loadedToPhoto = bitmap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ data class TransferReceiptData(
|
|||||||
// MIB receipt fields
|
// MIB receipt fields
|
||||||
val mibReferenceNo: String = "",
|
val mibReferenceNo: String = "",
|
||||||
val mibTransactionDate: String = "",
|
val mibTransactionDate: String = "",
|
||||||
val mibFromProfileName: String = "",
|
|
||||||
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
|
|
||||||
// BML receipt fields
|
// BML receipt fields
|
||||||
val bmlFromName: String = "",
|
val bmlFromName: String = "",
|
||||||
val bmlReference: String = "",
|
val bmlReference: String = "",
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import android.widget.Toast
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
@@ -34,8 +33,6 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.databinding.DialogReceiptFullscreenBmlBinding
|
|
||||||
import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding
|
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||||
@@ -64,8 +61,6 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS = "remarks"
|
private const val ARG_REMARKS = "remarks"
|
||||||
private const val ARG_MIB_REF = "mib_ref"
|
private const val ARG_MIB_REF = "mib_ref"
|
||||||
private const val ARG_MIB_DATE = "mib_date"
|
private const val ARG_MIB_DATE = "mib_date"
|
||||||
private const val ARG_MIB_FROM_PROFILE = "mib_from_profile"
|
|
||||||
private const val ARG_MIB_TXN_TYPE = "mib_txn_type"
|
|
||||||
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
||||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||||
@@ -94,8 +89,6 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
putString(ARG_REMARKS, data.remarks)
|
putString(ARG_REMARKS, data.remarks)
|
||||||
putString(ARG_MIB_REF, data.mibReferenceNo)
|
putString(ARG_MIB_REF, data.mibReferenceNo)
|
||||||
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
||||||
putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName)
|
|
||||||
putString(ARG_MIB_TXN_TYPE, data.mibTransactionType)
|
|
||||||
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
||||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||||
@@ -175,64 +168,43 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
||||||
val args = requireArguments()
|
val args = requireArguments()
|
||||||
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
||||||
|
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
|
||||||
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
||||||
val toLabel = args.getString(ARG_TO_LABEL, "")
|
val toLabel = args.getString(ARG_TO_LABEL, "")
|
||||||
val currency = args.getString(ARG_CURRENCY, "MVR")
|
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||||
val amount = args.getString(ARG_AMOUNT, "")
|
val amount = args.getString(ARG_AMOUNT, "")
|
||||||
|
|
||||||
// From avatar: initials first, then load profile image if hash available
|
// From avatar: initials first, then load profile image if hash available
|
||||||
binding.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel))
|
binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor))
|
||||||
binding.tvFromLabel.text = fromLabel
|
binding.tvFromLabel.text = fromLabel
|
||||||
if (fromProfileHash != null) {
|
if (fromProfileHash != null) {
|
||||||
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) }
|
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// To avatar: use already-rendered bitmap from TransferFragment if available
|
// To avatar: use already-rendered bitmap from TransferFragment if available
|
||||||
val toAvatar = pendingToAvatarBitmap
|
val toAvatar = pendingToAvatarBitmap
|
||||||
if (toAvatar != null) {
|
if (toAvatar != null) {
|
||||||
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar))
|
binding.ivToAvatar.setImageBitmap(toAvatar)
|
||||||
} else {
|
} else {
|
||||||
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel))
|
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
|
||||||
}
|
}
|
||||||
binding.tvToLabel.text = toLabel
|
binding.tvToLabel.text = toLabel
|
||||||
|
|
||||||
binding.tvAmount.text = "$currency $amount"
|
binding.tvAmount.text = "$currency $amount"
|
||||||
|
|
||||||
val toBank = args.getString(ARG_TO_BANK, "")
|
|
||||||
val rawDate = args.getString(ARG_MIB_DATE, "")
|
|
||||||
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
||||||
binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel }
|
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
|
||||||
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, ""))
|
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
|
||||||
.filter { it.isNotBlank() }.joinToString("\n")
|
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvToBank.text = toBank
|
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
// Receipts saved before the type was recorded fall back to a guess from the bank
|
|
||||||
binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank {
|
|
||||||
if (toBank == "MIB") "MIB Transfer" else "Quick Transfer"
|
|
||||||
}
|
|
||||||
binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm")
|
|
||||||
binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy")
|
|
||||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||||
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
|
|
||||||
|
|
||||||
copyOnLongClick(
|
copyOnLongClick(
|
||||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus,
|
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
||||||
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank,
|
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
||||||
binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */
|
|
||||||
private fun formatMibDate(raw: String, pattern: String): String {
|
|
||||||
if (raw.isBlank()) return ""
|
|
||||||
val out = DateTimeFormatter.ofPattern(pattern, Locale.US)
|
|
||||||
for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) {
|
|
||||||
try {
|
|
||||||
return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out)
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.anyMibSession() ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
@@ -403,13 +375,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||||
*/
|
*/
|
||||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||||
val shown = _receiptCard ?: run { callback(null); return }
|
val view = _receiptCard ?: run { callback(null); return }
|
||||||
if (shown.width == 0 || shown.height == 0) { callback(null); return }
|
if (view.width == 0 || view.height == 0) { callback(null); return }
|
||||||
|
|
||||||
// BML: the preview follows the app theme, but shared/saved images are always light
|
|
||||||
val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") {
|
|
||||||
inflateLightBmlCard(shown.width)
|
|
||||||
} else shown
|
|
||||||
|
|
||||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
@@ -418,27 +385,6 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
callback(bitmap)
|
callback(bitmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
|
|
||||||
private fun inflateLightBmlCard(widthPx: Int): View {
|
|
||||||
val config = android.content.res.Configuration(resources.configuration).apply {
|
|
||||||
uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
|
||||||
}
|
|
||||||
val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply {
|
|
||||||
applyOverrideConfiguration(config)
|
|
||||||
}
|
|
||||||
val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx))
|
|
||||||
bindBml(binding)
|
|
||||||
val card = binding.receiptCard
|
|
||||||
(card.parent as? ViewGroup)?.removeView(card)
|
|
||||||
card.measure(
|
|
||||||
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
|
|
||||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
|
||||||
)
|
|
||||||
card.layout(0, 0, card.measuredWidth, card.measuredHeight)
|
|
||||||
return card
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatBmlTimestamp(raw: String): String {
|
private fun formatBmlTimestamp(raw: String): String {
|
||||||
if (raw.isBlank()) return ""
|
if (raw.isBlank()) return ""
|
||||||
return try {
|
return try {
|
||||||
@@ -448,46 +394,26 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Center-crops [src] to a square and masks it to a circle. */
|
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||||
private fun circleCrop(src: Bitmap): Bitmap {
|
|
||||||
val size = minOf(src.width, src.height)
|
|
||||||
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
|
|
||||||
shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply {
|
|
||||||
setLocalMatrix(android.graphics.Matrix().apply {
|
|
||||||
setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */
|
|
||||||
private fun makeMibInitialsBitmap(name: String): Bitmap {
|
|
||||||
val sizePx = (resources.displayMetrics.density * 52).toInt()
|
val sizePx = (resources.displayMetrics.density * 52).toInt()
|
||||||
|
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
paint.color = Color.parseColor("#C6E1FD")
|
paint.color = bgColor
|
||||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
paint.color = Color.parseColor("#1168F3")
|
paint.color = Color.WHITE
|
||||||
paint.textSize = sizePx * 0.36f
|
paint.textSize = sizePx * 0.42f
|
||||||
paint.textAlign = Paint.Align.CENTER
|
paint.textAlign = Paint.Align.CENTER
|
||||||
paint.typeface = android.graphics.Typeface.DEFAULT_BOLD
|
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
val initials = name.split(Regex("\\s+"))
|
|
||||||
.mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() }
|
|
||||||
.take(2).joinToString("").ifEmpty { "?" }
|
|
||||||
val metrics = paint.fontMetrics
|
val metrics = paint.fontMetrics
|
||||||
canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFullScreenReceipt() {
|
private fun showFullScreenReceipt() {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
if (bank == "BML") { showBmlFullScreenReceipt(); return }
|
|
||||||
if (bank == "MIB") { showMibFullScreenReceipt(); return }
|
|
||||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
|
||||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||||
@@ -540,113 +466,6 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* BML full-screen receipt: status bar stays visible, top bar with back button,
|
|
||||||
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
|
||||||
* Follows the app theme (light/dark).
|
|
||||||
*/
|
|
||||||
private fun showBmlFullScreenReceipt() {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
|
||||||
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
|
||||||
|
|
||||||
val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard
|
|
||||||
(card.parent as? ViewGroup)?.removeView(card)
|
|
||||||
page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
|
||||||
))
|
|
||||||
|
|
||||||
page.btnBack.setOnClickListener { dialog.dismiss() }
|
|
||||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
|
||||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
|
||||||
|
|
||||||
val topBasePadding = page.topBar.paddingTop
|
|
||||||
val bottomBasePadding = page.bottomBar.paddingBottom
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
|
||||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
page.topBar.updatePadding(top = topBasePadding + bars.top)
|
|
||||||
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
|
||||||
page.root.updatePadding(left = bars.left, right = bars.right)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.setContentView(page.root)
|
|
||||||
dialog.window?.let { win ->
|
|
||||||
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
|
||||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
win.statusBarColor = Color.TRANSPARENT
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
win.navigationBarColor = Color.TRANSPARENT
|
|
||||||
val isLight = (resources.configuration.uiMode and
|
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
|
||||||
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
|
||||||
isAppearanceLightStatusBars = isLight
|
|
||||||
isAppearanceLightNavigationBars = isLight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dialog.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MIB full-screen receipt: edge-to-edge card whose green header runs under the
|
|
||||||
* (visible) status bar, a floating close button top-right just below the status bar,
|
|
||||||
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
|
|
||||||
*/
|
|
||||||
private fun showMibFullScreenReceipt() {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
|
||||||
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
|
||||||
|
|
||||||
val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) }
|
|
||||||
val card = cardBinding.receiptCard
|
|
||||||
(card.parent as? ViewGroup)?.removeView(card)
|
|
||||||
page.cardHolder.addView(card, ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
|
||||||
))
|
|
||||||
|
|
||||||
page.btnClose.setOnClickListener { dialog.dismiss() }
|
|
||||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
|
||||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
|
||||||
|
|
||||||
val header = cardBinding.receiptHeader
|
|
||||||
val headerBaseHeight = header.layoutParams.height
|
|
||||||
val headerBasePadding = header.paddingTop
|
|
||||||
val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin
|
|
||||||
val bottomBasePadding = page.bottomBar.paddingBottom
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
|
||||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
// Grow the green header under the status bar, keeping its content below it
|
|
||||||
header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top }
|
|
||||||
header.updatePadding(top = headerBasePadding + bars.top)
|
|
||||||
page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams)
|
|
||||||
.apply { topMargin = closeBaseMargin + bars.top }
|
|
||||||
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
|
||||||
page.root.updatePadding(left = bars.left, right = bars.right)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.setContentView(page.root)
|
|
||||||
dialog.window?.let { win ->
|
|
||||||
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
|
||||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
win.statusBarColor = Color.TRANSPARENT
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
win.navigationBarColor = Color.TRANSPARENT
|
|
||||||
val isLight = (resources.configuration.uiMode and
|
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
|
||||||
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
|
||||||
// Status bar sits over the green header, so always use light icons
|
|
||||||
isAppearanceLightStatusBars = false
|
|
||||||
isAppearanceLightNavigationBars = isLight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dialog.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
for (tv in views) {
|
for (tv in views) {
|
||||||
tv.setOnLongClickListener {
|
tv.setOnLongClickListener {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import sh.sar.basedbank.databinding.SheetTransferBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible.
|
||||||
|
* The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet.
|
||||||
|
*/
|
||||||
|
class TransferSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: SheetTransferBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = SheetTransferBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
// Leave the top of the chat visible above the sheet.
|
||||||
|
binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt()
|
||||||
|
if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) {
|
||||||
|
val transferArgs = requireArguments()
|
||||||
|
childFragmentManager.beginTransaction()
|
||||||
|
.replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs })
|
||||||
|
.commitNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
(dialog as? BottomSheetDialog)?.let { d ->
|
||||||
|
// Keep the amount/remarks fields above the keyboard.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
d.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||||
|
d.behavior.skipCollapsed = true
|
||||||
|
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SHEET_HEIGHT_FRACTION = 0.8f
|
||||||
|
|
||||||
|
/** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */
|
||||||
|
fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,15 +129,6 @@ class MibTransferHandler(
|
|||||||
else -> bankName.ifBlank { "LOCAL" }
|
else -> bankName.ifBlank { "LOCAL" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isOwnAccount = isDestMib && app.mibAccounts.any {
|
|
||||||
it.accountNumber == destAccount && it.loginTag == src.loginTag &&
|
|
||||||
(src.profileId.isBlank() || it.profileId == src.profileId)
|
|
||||||
}
|
|
||||||
val transactionType = when {
|
|
||||||
isOwnAccount -> "Own Transfer"
|
|
||||||
isDestMib -> "MIB Transfer"
|
|
||||||
else -> "Quick Transfer"
|
|
||||||
}
|
|
||||||
return try {
|
return try {
|
||||||
// Switch to the profile that owns the source account
|
// Switch to the profile that owns the source account
|
||||||
if (src.profileId.isNotBlank()) {
|
if (src.profileId.isNotBlank()) {
|
||||||
@@ -169,9 +160,7 @@ class MibTransferHandler(
|
|||||||
toBank = toBank,
|
toBank = toBank,
|
||||||
remarks = remarks,
|
remarks = remarks,
|
||||||
mibReferenceNo = result.trxId,
|
mibReferenceNo = result.trxId,
|
||||||
mibTransactionDate = result.date,
|
mibTransactionDate = result.date
|
||||||
mibFromProfileName = src.profileName,
|
|
||||||
mibTransactionType = transactionType
|
|
||||||
)
|
)
|
||||||
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted directory of account number → real account-holder name and currency, looked up through BML's
|
||||||
|
* account validation. Saved contacts often carry a nickname, while bank history only shows the
|
||||||
|
* real name; this lets a name-only transfer be matched to the right account.
|
||||||
|
*/
|
||||||
|
object AccountNameStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "account_names.json"
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
/** Names are re-checked after this long, in case an account changes hands. */
|
||||||
|
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
|
||||||
|
/** Failed lookups (closed or foreign accounts) are retried after this long. */
|
||||||
|
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
|
||||||
|
/** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */
|
||||||
|
private const val MAX_LOOKUPS_PER_RUN = 20
|
||||||
|
|
||||||
|
data class Info(val name: String, val currency: String)
|
||||||
|
|
||||||
|
private data class Entry(val name: String, val currency: String, val checkedAt: Long)
|
||||||
|
|
||||||
|
/** Account number → real name and currency, for every account resolved so far. */
|
||||||
|
fun all(context: Context): Map<String, Info> = synchronized(lock) {
|
||||||
|
load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */
|
||||||
|
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val known = synchronized(lock) { load(context) }
|
||||||
|
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
|
||||||
|
val e = known[account] ?: return@filter true
|
||||||
|
val age = now - e.checkedAt
|
||||||
|
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
|
||||||
|
}.take(MAX_LOOKUPS_PER_RUN)
|
||||||
|
if (due.isEmpty()) return
|
||||||
|
|
||||||
|
val client = BmlValidateClient()
|
||||||
|
// null = network error: leave the account due for next time. A failed lookup is stored blank.
|
||||||
|
val found = due.associateWith { account ->
|
||||||
|
try {
|
||||||
|
val v = client.validateAccount(session, account)
|
||||||
|
Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = load(context).toMutableMap()
|
||||||
|
found.forEach { (account, entry) -> if (entry != null) current[account] = entry }
|
||||||
|
save(context, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
|
||||||
|
|
||||||
|
private fun load(context: Context): Map<String, Entry> {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return emptyMap()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||||
|
root.keys().asSequence().associateWith { key ->
|
||||||
|
val o = root.getJSONObject(key)
|
||||||
|
Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyMap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(context: Context, entries: Map<String, Entry>) {
|
||||||
|
try {
|
||||||
|
val root = JSONObject()
|
||||||
|
entries.forEach { (account, e) ->
|
||||||
|
root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt))
|
||||||
|
}
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
import sh.sar.basedbank.ui.home.AccountHistoryAdapter
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One transfer shown as a chat bubble. BML history only carries the counterparty's name, so
|
||||||
|
* [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name.
|
||||||
|
*/
|
||||||
|
data class ChatMessage(
|
||||||
|
val id: String,
|
||||||
|
val peerKey: String,
|
||||||
|
val peerName: String,
|
||||||
|
val amount: Double, // negative = sent, positive = received
|
||||||
|
val currency: String,
|
||||||
|
val timeMillis: Long,
|
||||||
|
val accountNumber: String, // own account the money moved in/out of
|
||||||
|
val accountDisplayName: String,
|
||||||
|
val reference: String = "",
|
||||||
|
val note: String = "", // remarks — only known for transfers sent from this app
|
||||||
|
val peerAccount: String = "", // only known for transfers sent from this app
|
||||||
|
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
|
||||||
|
val dateOnly: Boolean = false // bank gave only a date (Favara); time of day unknown
|
||||||
|
) {
|
||||||
|
val isSent get() = amount < 0
|
||||||
|
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
|
||||||
|
val date: String get() = DATE_FMT.format(Date(timeMillis))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of a person's accounts that can be sent to from their chat. */
|
||||||
|
data class ChatAccount(
|
||||||
|
val account: String,
|
||||||
|
val currency: String, // "MVR", "USD"; blank if not known yet
|
||||||
|
val contact: BankContact? // saved contact for this account, if any
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatThread(
|
||||||
|
val peerKey: String, // "name:<normalised real name>"
|
||||||
|
val peerName: String, // contact nickname, else the real name
|
||||||
|
val realName: String, // account holder's name as the bank knows it; may be blank
|
||||||
|
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
|
||||||
|
val messages: List<ChatMessage> // oldest first
|
||||||
|
) {
|
||||||
|
val last get() = messages.last()
|
||||||
|
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
|
||||||
|
/** The account last sent to, else the first known one; blank when none is known. */
|
||||||
|
val peerAccount: String get() =
|
||||||
|
messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount
|
||||||
|
?.takeIf { a -> accounts.any { it.account == a } }
|
||||||
|
?: accounts.firstOrNull()?.account.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted local record of BML transfers, shown as chats.
|
||||||
|
*
|
||||||
|
* Old history is never loaded: the first call to [sync] only stamps the start time, and every
|
||||||
|
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
|
||||||
|
* pages back through the latest history of every BML current/savings account (booked later), and
|
||||||
|
* merges in receipts of transfers sent from this app, keeping one message per transfer.
|
||||||
|
*/
|
||||||
|
object ChatStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "chats.json"
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||||
|
private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
|
||||||
|
|
||||||
|
/** "You have received MVR 5.00 from NAME to 7730*****1234" */
|
||||||
|
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
|
||||||
|
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
|
||||||
|
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
|
||||||
|
/** Masked own account in notifications, e.g. "7730*****1234". */
|
||||||
|
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
|
||||||
|
|
||||||
|
/** How far apart two records of the same transfer can be timestamped. */
|
||||||
|
private const val MATCH_WINDOW_MS = 15 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Upper bound on history pages fetched per account in one sync. */
|
||||||
|
private const val MAX_PAGES_PER_SYNC = 5
|
||||||
|
|
||||||
|
private const val NAME_KEY_PREFIX = "name:"
|
||||||
|
|
||||||
|
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
|
||||||
|
|
||||||
|
fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US)
|
||||||
|
|
||||||
|
fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis }
|
||||||
|
|
||||||
|
fun isStarted(context: Context) = sinceMillis(context) > 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups stored messages into chats, most recent chat first. Call off the main thread.
|
||||||
|
*
|
||||||
|
* Chats are per person, keyed by the account holder's real name: bank history only names the
|
||||||
|
* sender, never their account, so money can't be told apart per account anyway. Every known
|
||||||
|
* account with that real name (saved contacts, accounts sent to) belongs to the chat, and the
|
||||||
|
* user picks which one to send to — typically their MVR or USD account.
|
||||||
|
*
|
||||||
|
* Real names come from [AccountNameStore], so a contact saved as "Ali" joins the
|
||||||
|
* "Mohamed Ali" chat.
|
||||||
|
*/
|
||||||
|
fun threads(context: Context, contacts: List<BankContact>): List<ChatThread> {
|
||||||
|
val messages = synchronized(lock) { load(context).messages }
|
||||||
|
val lookedUp = AccountNameStore.all(context)
|
||||||
|
val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount }
|
||||||
|
|
||||||
|
fun realNameOf(account: String): String? =
|
||||||
|
lookedUp[account]?.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
fun keyOf(m: ChatMessage): String {
|
||||||
|
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
|
||||||
|
return NAME_KEY_PREFIX + normalise(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every known account, grouped under its holder's real name.
|
||||||
|
val accountsByKey = HashMap<String, LinkedHashMap<String, ChatAccount>>()
|
||||||
|
fun addAccount(account: String, fallbackCurrency: String) {
|
||||||
|
val name = realNameOf(account) ?: return
|
||||||
|
val contact = contactsByAccount[account]
|
||||||
|
val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() }
|
||||||
|
?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency
|
||||||
|
accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() }
|
||||||
|
.getOrPut(account) { ChatAccount(account, currency, contact) }
|
||||||
|
}
|
||||||
|
contactsByAccount.keys.forEach { addAccount(it, "") }
|
||||||
|
messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) }
|
||||||
|
|
||||||
|
return messages.groupBy(::keyOf).map { (key, msgs) ->
|
||||||
|
val sorted = msgs.sortedBy { it.timeMillis }
|
||||||
|
val accounts = accountsByKey[key]?.values?.toList().orEmpty()
|
||||||
|
// Bank history and the account lookup carry the real name; a receipt only has our label.
|
||||||
|
val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName
|
||||||
|
?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
?: ""
|
||||||
|
val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
ChatThread(
|
||||||
|
peerKey = key,
|
||||||
|
peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName,
|
||||||
|
realName = realName,
|
||||||
|
accounts = accounts,
|
||||||
|
messages = sorted
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.last.timeMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */
|
||||||
|
suspend fun sync(context: Context, app: BasedBankApp, accounts: List<BankAccount>, contacts: List<BankContact>) {
|
||||||
|
val state = synchronized(lock) { load(context) }
|
||||||
|
if (state.sinceMillis == 0L) {
|
||||||
|
synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val since = state.sinceMillis
|
||||||
|
|
||||||
|
val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id })
|
||||||
|
val notifications = fetchNotifications(context, app, accounts, since)
|
||||||
|
val receipts = receiptMessages(context, accounts, since)
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = load(context)
|
||||||
|
save(context, current.copy(messages = merge(current.messages, history + notifications + receipts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
|
||||||
|
// SWIFT (foreign) beneficiaries can't be looked up through BML.
|
||||||
|
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
|
||||||
|
AccountNameStore.resolve(context, session, toResolve)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers from account history. BML only adds a transaction here once it is booked (often
|
||||||
|
* the next day), so recent ones come from [fetchNotifications] first.
|
||||||
|
*/
|
||||||
|
private suspend fun fetchHistory(
|
||||||
|
app: BasedBankApp,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
since: Long,
|
||||||
|
knownIds: Set<String>
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val fetched = mutableListOf<ChatMessage>()
|
||||||
|
for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) {
|
||||||
|
val fetcher = HistoryFetcher(account)
|
||||||
|
var pages = 0
|
||||||
|
// Page back until we reach transfers we already have or ones older than the start time.
|
||||||
|
while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) {
|
||||||
|
val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break }
|
||||||
|
pages++
|
||||||
|
if (page.isEmpty()) break
|
||||||
|
var reachedKnown = false
|
||||||
|
for (trx in page) {
|
||||||
|
val time = AccountHistoryAdapter.parseDateMillis(trx.date)
|
||||||
|
if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true
|
||||||
|
trx.toChatMessage(time, since)?.let { fetched += it }
|
||||||
|
}
|
||||||
|
if (reachedKnown) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
|
||||||
|
val name = counterpartyName?.trim().orEmpty()
|
||||||
|
if (description !in TRANSFER_TYPES || name.isBlank()) return null
|
||||||
|
// Favara (other-bank) entries carry only a date; keep those from the start day onwards.
|
||||||
|
val dateOnly = date.contains("T00:00:00")
|
||||||
|
if (if (dateOnly) time < startOfDay(since) else time < since) return null
|
||||||
|
return ChatMessage(
|
||||||
|
id = "bml_$id",
|
||||||
|
peerKey = normalise(name),
|
||||||
|
peerName = name,
|
||||||
|
amount = amount,
|
||||||
|
currency = currency,
|
||||||
|
timeMillis = time,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
reference = reference.orEmpty(),
|
||||||
|
dateOnly = dateOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive
|
||||||
|
* instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page
|
||||||
|
* is not written back to the cache, which the notification poller uses to spot new alerts.
|
||||||
|
*/
|
||||||
|
private fun fetchNotifications(
|
||||||
|
context: Context,
|
||||||
|
app: BasedBankApp,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
since: Long
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val client = BmlNotificationsClient()
|
||||||
|
val notifications = LinkedHashMap<String, AppNotification>()
|
||||||
|
for ((loginId, session) in app.bmlSessions.toMap()) {
|
||||||
|
NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it }
|
||||||
|
try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } }
|
||||||
|
catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
return notifications.values
|
||||||
|
.filter { it.timestampMs >= since }
|
||||||
|
.mapNotNull { notificationToMessage(it, accounts) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notificationToMessage(n: AppNotification, accounts: List<BankAccount>): ChatMessage? {
|
||||||
|
val text = n.message.trim()
|
||||||
|
val received = NOTIF_RECEIVED.matchEntire(text)
|
||||||
|
val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null
|
||||||
|
val (currency, amountText, name, ownMasked) = when {
|
||||||
|
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) }
|
||||||
|
sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
|
||||||
|
val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null
|
||||||
|
val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m ->
|
||||||
|
val (head, tail) = m.destructured
|
||||||
|
accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) }
|
||||||
|
}
|
||||||
|
return ChatMessage(
|
||||||
|
id = "ntf_${n.id}",
|
||||||
|
peerKey = normalise(name),
|
||||||
|
peerName = name.trim(),
|
||||||
|
amount = if (received != null) amount else -amount,
|
||||||
|
currency = currency,
|
||||||
|
timeMillis = n.timestampMs,
|
||||||
|
accountNumber = own?.accountNumber.orEmpty(),
|
||||||
|
accountDisplayName = own?.accountBriefName ?: ownMasked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
|
||||||
|
private fun receiptMessages(context: Context, accounts: List<BankAccount>, since: Long): List<ChatMessage> =
|
||||||
|
ReceiptStore.loadAll(context).mapNotNull { entry ->
|
||||||
|
val r = entry.data
|
||||||
|
if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null
|
||||||
|
val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null
|
||||||
|
val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt
|
||||||
|
val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel }
|
||||||
|
ChatMessage(
|
||||||
|
id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}",
|
||||||
|
peerKey = normalise(r.toLabel),
|
||||||
|
peerName = r.toLabel,
|
||||||
|
amount = -amount,
|
||||||
|
currency = r.currency,
|
||||||
|
timeMillis = time,
|
||||||
|
accountNumber = from?.accountNumber.orEmpty(),
|
||||||
|
accountDisplayName = r.fromLabel,
|
||||||
|
reference = r.bmlReference,
|
||||||
|
note = r.remarks,
|
||||||
|
peerAccount = r.toAccount,
|
||||||
|
receiptKey = entry.savedAt.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges stored and newly fetched messages so each transfer appears once. The same transfer can
|
||||||
|
* come from history, a notification and a receipt; the highest-ranked source is kept (history,
|
||||||
|
* then notification, then receipt) and picks up what the others add: the exact time, the note,
|
||||||
|
* the recipient account and the receipt.
|
||||||
|
*/
|
||||||
|
private fun merge(existing: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||||
|
val byId = LinkedHashMap<String, ChatMessage>()
|
||||||
|
incoming.forEach { byId[it.id] = it }
|
||||||
|
existing.forEach { byId[it.id] = it } // stored copies already carry merged details
|
||||||
|
|
||||||
|
val kept = mutableListOf<ChatMessage>()
|
||||||
|
val absorbed = HashMap<String, MutableSet<Int>>() // kept id -> source ranks merged into it
|
||||||
|
for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) {
|
||||||
|
val r = rank(candidate)
|
||||||
|
val index = kept.indexOfFirst { k ->
|
||||||
|
rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate)
|
||||||
|
}
|
||||||
|
if (index >= 0) {
|
||||||
|
val k = kept[index]
|
||||||
|
kept[index] = k.copy(
|
||||||
|
timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis,
|
||||||
|
dateOnly = k.dateOnly && candidate.dateOnly,
|
||||||
|
accountNumber = k.accountNumber.ifBlank { candidate.accountNumber },
|
||||||
|
reference = k.reference.ifBlank { candidate.reference },
|
||||||
|
note = k.note.ifBlank { candidate.note },
|
||||||
|
peerAccount = k.peerAccount.ifBlank { candidate.peerAccount },
|
||||||
|
receiptKey = k.receiptKey.ifBlank { candidate.receiptKey }
|
||||||
|
)
|
||||||
|
absorbed.getOrPut(k.id) { mutableSetOf() } += r
|
||||||
|
} else {
|
||||||
|
kept += candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept.sortedBy { it.timeMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rank(m: ChatMessage) = when {
|
||||||
|
m.id.startsWith("bml_") -> 0
|
||||||
|
m.id.startsWith("ntf_") -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
|
||||||
|
if (a.reference.isNotBlank() && a.reference == b.reference) return true
|
||||||
|
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
|
||||||
|
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false
|
||||||
|
if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
|
||||||
|
if (a.dateOnly || b.dateOnly) {
|
||||||
|
// A date-only entry could be any transfer that day, so the names must agree too.
|
||||||
|
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
|
||||||
|
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
|
||||||
|
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
|
||||||
|
}
|
||||||
|
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply {
|
||||||
|
timeInMillis = millis
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
|
||||||
|
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
|
||||||
|
|
||||||
|
private fun load(context: Context): State {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return State(0L, emptyList())
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||||
|
val arr = root.optJSONArray("messages") ?: JSONArray()
|
||||||
|
State(
|
||||||
|
sinceMillis = root.optLong("since", 0L),
|
||||||
|
messages = (0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
ChatMessage(
|
||||||
|
id = o.optString("id"),
|
||||||
|
peerKey = o.optString("peerKey"),
|
||||||
|
peerName = o.optString("peerName"),
|
||||||
|
amount = o.optDouble("amount", 0.0),
|
||||||
|
currency = o.optString("currency"),
|
||||||
|
timeMillis = o.optLong("time", 0L),
|
||||||
|
accountNumber = o.optString("account"),
|
||||||
|
accountDisplayName = o.optString("accountName"),
|
||||||
|
reference = o.optString("reference"),
|
||||||
|
note = o.optString("note"),
|
||||||
|
peerAccount = o.optString("peerAccount"),
|
||||||
|
receiptKey = o.optString("receiptKey"),
|
||||||
|
dateOnly = o.optBoolean("dateOnly", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { State(0L, emptyList()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(context: Context, state: State) {
|
||||||
|
try {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (m in state.messages) arr.put(JSONObject().apply {
|
||||||
|
put("id", m.id)
|
||||||
|
put("peerKey", m.peerKey)
|
||||||
|
put("peerName", m.peerName)
|
||||||
|
put("amount", m.amount)
|
||||||
|
put("currency", m.currency)
|
||||||
|
put("time", m.timeMillis)
|
||||||
|
put("account", m.accountNumber)
|
||||||
|
put("accountName", m.accountDisplayName)
|
||||||
|
put("reference", m.reference)
|
||||||
|
put("note", m.note)
|
||||||
|
put("peerAccount", m.peerAccount)
|
||||||
|
put("receiptKey", m.receiptKey)
|
||||||
|
put("dateOnly", m.dateOnly)
|
||||||
|
})
|
||||||
|
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,8 +40,6 @@ object ReceiptStore {
|
|||||||
remarks = o.optString("remarks"),
|
remarks = o.optString("remarks"),
|
||||||
mibReferenceNo = o.optString("mibReferenceNo"),
|
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||||
mibTransactionDate = o.optString("mibTransactionDate"),
|
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||||
mibFromProfileName = o.optString("mibFromProfileName"),
|
|
||||||
mibTransactionType = o.optString("mibTransactionType"),
|
|
||||||
bmlFromName = o.optString("bmlFromName"),
|
bmlFromName = o.optString("bmlFromName"),
|
||||||
bmlReference = o.optString("bmlReference"),
|
bmlReference = o.optString("bmlReference"),
|
||||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||||
@@ -78,8 +76,6 @@ object ReceiptStore {
|
|||||||
put("remarks", d.remarks)
|
put("remarks", d.remarks)
|
||||||
put("mibReferenceNo", d.mibReferenceNo)
|
put("mibReferenceNo", d.mibReferenceNo)
|
||||||
put("mibTransactionDate", d.mibTransactionDate)
|
put("mibTransactionDate", d.mibTransactionDate)
|
||||||
put("mibFromProfileName", d.mibFromProfileName)
|
|
||||||
put("mibTransactionType", d.mibTransactionType)
|
|
||||||
put("bmlFromName", d.bmlFromName)
|
put("bmlFromName", d.bmlFromName)
|
||||||
put("bmlReference", d.bmlReference)
|
put("bmlReference", d.bmlReference)
|
||||||
put("bmlTimestamp", d.bmlTimestamp)
|
put("bmlTimestamp", d.bmlTimestamp)
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Dark-mode replacement for drawable/bottom_receipt_wave.jpg (988x48):
|
|
||||||
receipt background above the teeth, footer colour below. -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="329dp"
|
|
||||||
android:height="16dp"
|
|
||||||
android:viewportWidth="988"
|
|
||||||
android:viewportHeight="48">
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/bml_receipt_bg"
|
|
||||||
android:pathData="M0,0 L988,0 L988,48 L0,48 Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/bml_receipt_footer"
|
|
||||||
android:pathData="M0,48 L0,27 L24.08,8.28 Q30.88,3 37.67,8.28 L54.96,21.72 Q61.75,27 68.54,21.72 L85.83,8.28 Q92.63,3 99.42,8.28 L116.71,21.72 Q123.5,27 130.29,21.72 L147.58,8.28 Q154.38,3 161.17,8.28 L178.46,21.72 Q185.25,27 192.04,21.72 L209.33,8.28 Q216.13,3 222.92,8.28 L240.21,21.72 Q247,27 253.79,21.72 L271.08,8.28 Q277.88,3 284.67,8.28 L301.96,21.72 Q308.75,27 315.54,21.72 L332.83,8.28 Q339.63,3 346.42,8.28 L363.71,21.72 Q370.5,27 377.29,21.72 L394.58,8.28 Q401.38,3 408.17,8.28 L425.46,21.72 Q432.25,27 439.04,21.72 L456.33,8.28 Q463.13,3 469.92,8.28 L487.21,21.72 Q494,27 500.79,21.72 L518.08,8.28 Q524.88,3 531.67,8.28 L548.96,21.72 Q555.75,27 562.54,21.72 L579.83,8.28 Q586.63,3 593.42,8.28 L610.71,21.72 Q617.5,27 624.29,21.72 L641.58,8.28 Q648.38,3 655.17,8.28 L672.46,21.72 Q679.25,27 686.04,21.72 L703.33,8.28 Q710.13,3 716.92,8.28 L734.21,21.72 Q741,27 747.79,21.72 L765.08,8.28 Q771.88,3 778.67,8.28 L795.96,21.72 Q802.75,27 809.54,21.72 L826.83,8.28 Q833.63,3 840.42,8.28 L857.71,21.72 Q864.5,27 871.29,21.72 L888.58,8.28 Q895.38,3 902.17,8.28 L919.46,21.72 Q926.25,27 933.04,21.72 L950.33,8.28 Q957.13,3 963.92,8.28 L988,27 L988,48 Z" />
|
|
||||||
</vector>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
<solid android:color="?attr/colorSurfaceContainerHigh" />
|
||||||
|
<stroke android:width="1dp" android:color="?attr/colorOutlineVariant" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:startColor="#2E7D32"
|
||||||
|
android:centerColor="#43A047"
|
||||||
|
android:endColor="#66BB6A"
|
||||||
|
android:angle="315"
|
||||||
|
android:type="linear" />
|
||||||
|
</shape>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -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="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2H6V9zM14,14H6v-2h8v2zM18,8H6V6h12v2z"/>
|
||||||
|
</vector>
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:strokeColor="#FFFFFFFF"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:pathData="M15,4 L7,12 L15,20" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- MIB full-screen receipt close button: tinted-black disc, themed ring, white X -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="32dp"
|
|
||||||
android:height="32dp"
|
|
||||||
android:viewportWidth="32"
|
|
||||||
android:viewportHeight="32">
|
|
||||||
<path
|
|
||||||
android:fillColor="#4D000000"
|
|
||||||
android:strokeColor="@color/mib_receipt_close_ring"
|
|
||||||
android:strokeWidth="1.5"
|
|
||||||
android:pathData="M16,1.25 A14.75,14.75 0 1,1 16,30.75 A14.75,14.75 0 1,1 16,1.25 Z" />
|
|
||||||
<path
|
|
||||||
android:strokeColor="#FFFFFFFF"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:pathData="M11,11 L21,21 M21,11 L11,21" />
|
|
||||||
</vector>
|
|
||||||
@@ -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="M19.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2 7.5,3.5 6,2 4.5,3.5 3,2v20l1.5,-1.5L6,22l1.5,-1.5L9,22l1.5,-1.5L12,22l1.5,-1.5L15,22l1.5,-1.5L18,22l1.5,-1.5L21,22V2l-1.5,1.5zM19,19.09H5V4.91h14v14.18zM6,15h12v2H6zM6,11h12v2H6zM6,7h12v2H6z"/>
|
||||||
|
</vector>
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Full MIB logo (mark + "MALDIVES ISLAMIC BANK" wordmark); wordmark follows the receipt footer text color -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="187.6dp"
|
|
||||||
android:height="22dp"
|
|
||||||
android:viewportWidth="779.5"
|
|
||||||
android:viewportHeight="91.4">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:strokeColor="#A8AAAC"
|
|
||||||
android:strokeWidth="3.8833"
|
|
||||||
android:strokeMiterLimit="10"
|
|
||||||
android:pathData="M64.6,89.2H26.7c-13.5,0-24.5-11-24.5-24.5v-38c0-13.5,11-24.5,24.5-24.5h37.9c13.5,0,24.5,11,24.5,24.5v37.9 C89.1,78.2,78.2,89.2,64.6,89.2z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#1E2859"
|
|
||||||
android:pathData="M49.4,28.7c1.7-1.7,3.5-3.3,5.4-4.8c1.9,1.5,3.7,3.1,5.4,4.8c9,9.2,14.4,21.7,14.4,35.3c0,0.7,0,1.5-0.1,2.2 H57.6l10.9-6.8c-0.5-6-2.4-11.8-5.6-17.2c-2.2-3.7-5-7-8.2-9.7c-3.2,2.7-5.9,6-8.2,9.7c-3.2,5.3-5,11.2-5.6,17.2l4.6,6.8H35 c0-0.7-0.1-1.5-0.1-2.2C34.9,50.3,40.4,37.9,49.4,28.7z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#006A4D"
|
|
||||||
android:pathData="M41.9,28.7c-1.7-1.7-3.5-3.3-5.4-4.8c-1.9,1.5-3.7,3.1-5.4,4.8c-9,9.2-14.4,21.7-14.4,35.3 c0,0.7,0,1.5,0.1,2.2h16.9l-10.9-6.8c0.5-6,2.4-11.8,5.6-17.2c2.2-3.7,5-7,8.2-9.7c3.2,2.7,5.9,6,8.2,9.7c3.2,5.3,5,11.2,5.6,17.2 l-4.6,6.8h10.6c0-0.7,0.1-1.5,0.1-2.2C56.3,50.3,50.8,37.9,41.9,28.7z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M146.4,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L146.4,49.5z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M170,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8L183,54.6H170z M181.7,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L171.2,49H181.7z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M200.5,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M230.5,25.8c3.3-0.5,7.5-0.9,11.9-0.9c7.7,0,13,1.6,16.7,4.7c4,3.2,6.4,8.1,6.4,15.1c0,7.3-2.5,12.8-6.4,16.3 c-4.1,3.7-10.6,5.6-18.6,5.6c-4.4,0-7.7-0.2-10.1-0.5V25.8z M237.9,60.5c1,0.2,2.6,0.2,4.1,0.2c9.7,0.1,15.5-5.3,15.5-15.7 c0.1-9.1-5.2-14.2-14.5-14.2c-2.4,0-4.1,0.2-5.1,0.4V60.5z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M279.5,25.2v41H272v-41H279.5z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M297.4,66.2l-13.3-41h8.2l5.6,18.6c1.6,5.2,2.9,10,4,15h0.1c1.1-4.9,2.6-9.9,4.2-14.8l6-18.7h8l-14.2,41 H297.4z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M347.6,48.1h-15.5v12h17.3v6.1h-24.8v-41h23.8v6.1h-16.4V42h15.5V48.1z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M355.8,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L355.8,58.1z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M407.6,25.2v41h-7.5v-41H407.6z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M416,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L416,58.1z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M447.8,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M486.2,54.6l-3.5,11.6H475l13.1-41h9.6l13.3,41h-8l-3.7-11.6H486.2z M498,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L487.4,49H498z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M551.1,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L551.1,49.5z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M574.3,25.2v41h-7.5v-41H574.3z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M612.3,65c-1.8,0.9-5.7,1.8-10.6,1.8c-13,0-20.9-8.2-20.9-20.6c0-13.5,9.4-21.7,21.9-21.7c4.9,0,8.5,1,10,1.8 l-1.6,6c-1.9-0.9-4.6-1.6-8-1.6c-8.3,0-14.4,5.2-14.4,15.1c0,9,5.3,14.8,14.3,14.8c3,0,6.2-0.6,8.2-1.5L612.3,65z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M631.3,25.8c2.4-0.5,6.7-0.9,10.9-0.9c5.5,0,8.9,0.7,11.7,2.6c2.6,1.5,4.3,4.2,4.3,7.7c0,3.8-2.4,7.2-6.8,8.9 v0.1c4.3,1.1,8.3,4.5,8.3,10.2c0,3.7-1.6,6.5-4,8.5c-2.9,2.6-7.7,3.8-15.2,3.8c-4.1,0-7.3-0.3-9.2-0.5V25.8z M638.7,42h3.8 c5.2,0,8.1-2.4,8.1-5.9c0-3.8-2.9-5.6-7.7-5.6c-2.2,0-3.5,0.1-4.3,0.3V42z M638.7,60.8c1,0.1,2.3,0.2,4,0.2c4.8,0,9.1-1.8,9.1-6.9 c0-4.7-4.1-6.7-9.3-6.7h-3.7V60.8z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M674.3,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8l-3.7-11.6H674.3z M686.1,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L675.5,49H686.1z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M704.8,66.2v-41h8.5l10.6,17.6c2.7,4.6,5.1,9.3,7,13.7h0.1c-0.5-5.5-0.7-10.8-0.7-17V25.2h6.9v41h-7.7 l-10.7-18c-2.6-4.5-5.4-9.6-7.4-14.2l-0.2,0.1c0.3,5.3,0.4,10.7,0.4,17.5v14.7H704.8z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@color/mib_receipt_footer_text"
|
|
||||||
android:pathData="M745.9,25.2h7.4v18.9h0.2c1-1.6,2-3,3-4.4l10.7-14.4h9.2l-14.1,17.5l15,23.5h-8.8L757,47.5l-3.7,4.4v14.4 h-7.4V25.2z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- MIB receipt header: solid green under the official app's translucent palm texture -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item android:drawable="@color/mib_receipt_amount" />
|
|
||||||
<item>
|
|
||||||
<bitmap
|
|
||||||
android:src="@drawable/mib_receipt_texture"
|
|
||||||
android:gravity="fill" />
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Binary file not shown.
Binary file not shown.
@@ -48,7 +48,6 @@
|
|||||||
android:id="@+id/languageToggle"
|
android:id="@+id/languageToggle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:baselineAligned="false"
|
|
||||||
app:singleSelection="true"
|
app:singleSelection="true"
|
||||||
app:selectionRequired="true">
|
app:selectionRequired="true">
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Full-screen BML receipt, modelled on docs/bmlapi/tmp/recipt_full_*.jpg.
|
|
||||||
The receipt card is inserted at the top of cardHolder, directly followed by
|
|
||||||
the Save/Share buttons; the rest of the screen shows the footer colour. -->
|
|
||||||
<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="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@color/bml_receipt_footer">
|
|
||||||
|
|
||||||
<!-- Top bar — status bar inset is added as top padding in code -->
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/topBar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="@color/bml_receipt_bg">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="48dp">
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnBack"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_gravity="start|center_vertical"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_chevron_back"
|
|
||||||
app:tint="@color/bml_receipt_amount"
|
|
||||||
android:contentDescription="Back" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:text="Transfer successful"
|
|
||||||
android:textSize="17sp"
|
|
||||||
android:textColor="@color/bml_receipt_amount"
|
|
||||||
android:fontFamily="@font/sofia_pro" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="1dp"
|
|
||||||
android:background="@color/bml_receipt_divider" />
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:id="@+id/scroll"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:overScrollMode="never"
|
|
||||||
android:scrollbars="none">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/cardHolder"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<!-- receipt card goes here (index 0) -->
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="1dp"
|
|
||||||
android:background="@color/bml_receipt_bottom_divider" />
|
|
||||||
|
|
||||||
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/bottomBar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@color/bml_receipt_footer"
|
|
||||||
android:paddingHorizontal="20dp"
|
|
||||||
android:paddingTop="20dp"
|
|
||||||
android:paddingBottom="16dp">
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnSaveFull"
|
|
||||||
style="@style/Widget.Material3.Button"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="44dp"
|
|
||||||
android:insetTop="0dp"
|
|
||||||
android:insetBottom="0dp"
|
|
||||||
android:text="Save receipt"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:letterSpacing="0"
|
|
||||||
android:textSize="17sp"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:fontFamily="@font/sofia_pro"
|
|
||||||
app:backgroundTint="@color/bml_red"
|
|
||||||
app:cornerRadius="10dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnShareFull"
|
|
||||||
style="@style/Widget.Material3.Button.TextButton"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="44dp"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:insetTop="0dp"
|
|
||||||
android:insetBottom="0dp"
|
|
||||||
android:text="Share receipt"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:letterSpacing="0"
|
|
||||||
android:textSize="17sp"
|
|
||||||
android:textColor="@color/bml_receipt_message"
|
|
||||||
android:fontFamily="@font/sofia_pro"
|
|
||||||
app:rippleColor="@color/bml_receipt_divider" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Full-screen MIB receipt. The receipt card is inserted into cardHolder and its green
|
|
||||||
header is extended under the status bar in code; the close button floats over the
|
|
||||||
header just below the status bar. Share/Save sit pinned at the bottom of the screen. -->
|
|
||||||
<FrameLayout
|
|
||||||
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="match_parent"
|
|
||||||
android:background="@color/mib_receipt_bg">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:id="@+id/scroll"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:overScrollMode="never"
|
|
||||||
android:scrollbars="none">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/cardHolder"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="1dp"
|
|
||||||
android:background="@color/mib_receipt_divider" />
|
|
||||||
|
|
||||||
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/bottomBar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:paddingHorizontal="20dp"
|
|
||||||
android:paddingTop="20dp"
|
|
||||||
android:paddingBottom="16dp">
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnShareFull"
|
|
||||||
style="@style/Widget.Material3.Button"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="44dp"
|
|
||||||
android:insetTop="0dp"
|
|
||||||
android:insetBottom="0dp"
|
|
||||||
android:text="Share Receipt"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:letterSpacing="0"
|
|
||||||
android:textSize="17sp"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
app:backgroundTint="@color/mib_blue"
|
|
||||||
app:cornerRadius="10dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnSaveFull"
|
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="44dp"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:insetTop="0dp"
|
|
||||||
android:insetBottom="0dp"
|
|
||||||
android:text="Save Receipt"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:letterSpacing="0"
|
|
||||||
android:textSize="17sp"
|
|
||||||
android:textColor="@color/mib_blue"
|
|
||||||
app:backgroundTint="@color/mib_receipt_bg"
|
|
||||||
app:strokeColor="@color/mib_blue"
|
|
||||||
app:strokeWidth="1dp"
|
|
||||||
app:cornerRadius="10dp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Close (back) button — status bar inset is added to top margin in code -->
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnClose"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_gravity="top|end"
|
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:layout_marginEnd="8dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_mib_receipt_close"
|
|
||||||
android:contentDescription="Close" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?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="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/toAccountBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="10dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="@string/chat_to"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToAccount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToNickname"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToCurrency"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_chat_date"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivToSwitch"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:rotation="90"
|
||||||
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/saveContactBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/colorSurfaceContainerHigh"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/chat_not_in_contacts"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSaveContact"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/chat_save_contact" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvMessages"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCannotSend"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/chat_cannot_send"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/composer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:text="@string/chat_from_account"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardFromAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:strokeColor="?attr/colorOutline">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingEnd="12dp">
|
||||||
|
|
||||||
|
<include
|
||||||
|
android:id="@+id/fromAccountRow"
|
||||||
|
layout="@layout/item_account_dropdown"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:rotation="90"
|
||||||
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilAmount"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/chat_amount"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
app:boxCornerRadiusTopStart="28dp"
|
||||||
|
app:boxCornerRadiusTopEnd="28dp"
|
||||||
|
app:boxCornerRadiusBottomStart="28dp"
|
||||||
|
app:boxCornerRadiusBottomEnd="28dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etAmount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:imeOptions="actionSend" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSend"
|
||||||
|
style="@style/Widget.Material3.Button.Icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/chat_send"
|
||||||
|
app:cornerRadius="28dp"
|
||||||
|
app:icon="@drawable/ic_send"
|
||||||
|
app:iconGravity="textEnd" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?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="match_parent"
|
||||||
|
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="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">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etSearch"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/chats_search_hint"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:imeOptions="actionSearch" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvChats"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="65dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/emptyView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginHorizontal="32dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="@color/bml_receipt_bg">
|
android:background="#FFFFFF">
|
||||||
|
|
||||||
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
|
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
android:layout_gravity="center_horizontal"
|
android:layout_gravity="center_horizontal"
|
||||||
android:layout_marginTop="20dp"
|
android:layout_marginTop="20dp"
|
||||||
android:src="@drawable/bml_logo_vector"
|
android:src="@drawable/bml_icon"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:adjustViewBounds="true"
|
android:adjustViewBounds="true"
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="@null" />
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
android:layout_marginBottom="20dp"
|
android:layout_marginBottom="20dp"
|
||||||
android:id="@+id/tvMessage"
|
android:id="@+id/tvMessage"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@color/bml_receipt_message"
|
android:textColor="#2D2D2D"
|
||||||
android:fontFamily="@font/nunito_sans"
|
android:fontFamily="@font/nunito_sans"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_marginBottom="13dp"
|
android:layout_marginBottom="13dp"
|
||||||
android:textSize="42sp"
|
android:textSize="42sp"
|
||||||
android:textColor="@color/bml_receipt_amount"
|
android:textColor="#242424"
|
||||||
android:fontFamily="@font/sofia_pro"
|
android:fontFamily="@font/sofia_pro"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@@ -109,66 +109,66 @@
|
|||||||
|
|
||||||
<!-- Status (value = green #8BC155) -->
|
<!-- Status (value = green #8BC155) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||||
|
|
||||||
<!-- Message (value wraps if long) -->
|
<!-- Message (value wraps if long) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||||
|
|
||||||
<!-- Reference -->
|
<!-- Reference -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||||
|
|
||||||
<!-- Transaction date -->
|
<!-- Transaction date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||||
|
|
||||||
<!-- From (value uppercase) -->
|
<!-- From (value uppercase) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
|
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||||
|
|
||||||
<!-- To (stacked name + account) -->
|
<!-- To (stacked name + account) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
|
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
|
||||||
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
||||||
|
|
||||||
<!-- Amount (value = green #8BC155) -->
|
<!-- Amount (value = green #8BC155) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Remarks (hidden when empty) -->
|
<!-- Remarks (hidden when empty) -->
|
||||||
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" android:visibility="gone" />
|
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" android:visibility="gone" />
|
||||||
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
|
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:background="@color/bml_receipt_footer"
|
android:background="#F5F5F5"
|
||||||
android:paddingVertical="22dp">
|
android:paddingVertical="22dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
android:overScrollMode="never"
|
android:overScrollMode="never"
|
||||||
android:scrollbars="none">
|
android:scrollbars="none">
|
||||||
|
|
||||||
<!-- Renderable receipt card -->
|
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/receiptCard"
|
android:id="@+id/receiptCard"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -23,35 +23,76 @@
|
|||||||
android:layout_marginHorizontal="40dp"
|
android:layout_marginHorizontal="40dp"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<!-- Green header — from/to avatars and names -->
|
<!-- Green header — fills all space not taken by body -->
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/receiptHeader"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="160dp"
|
android:layout_height="200dp"
|
||||||
android:background="@drawable/mib_receipt_header_bg">
|
android:background="@drawable/trx_success_bg">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="TRANSACTION RECEIPT"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:letterSpacing="0.08"
|
||||||
|
android:layout_marginBottom="22dp" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="64dp"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:src="@drawable/ic_receipt_check"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="SUCCESSFUL"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:letterSpacing="0.05"
|
||||||
|
android:layout_marginTop="18dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- White body — fixed size content -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="#FFFFFF"
|
||||||
|
android:paddingTop="16dp">
|
||||||
|
|
||||||
|
<!-- Avatars row -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingHorizontal="24dp">
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingBottom="14dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/ivFromAvatar"
|
android:id="@+id/ivFromAvatar"
|
||||||
android:layout_width="52dp"
|
android:layout_width="52dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
android:scaleType="fitCenter"
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
android:contentDescription="@null" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvFromLabel"
|
android:id="@+id/tvFromLabel"
|
||||||
@@ -59,9 +100,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:maxWidth="110dp"
|
android:textColor="#565656"
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:maxLines="2" />
|
android:maxLines="2" />
|
||||||
|
|
||||||
@@ -70,25 +109,22 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:layout_marginHorizontal="10dp"
|
android:layout_marginHorizontal="12dp"
|
||||||
android:src="@drawable/ic_arrow_right"
|
android:src="@drawable/ic_arrow_right"
|
||||||
android:tint="#FFFFFF"
|
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/ivToAvatar"
|
android:id="@+id/ivToAvatar"
|
||||||
android:layout_width="52dp"
|
android:layout_width="52dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
android:scaleType="fitCenter"
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
android:contentDescription="@null" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvToLabel"
|
android:id="@+id/tvToLabel"
|
||||||
@@ -96,9 +132,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:maxWidth="110dp"
|
android:textColor="#565656"
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:maxLines="2" />
|
android:maxLines="2" />
|
||||||
|
|
||||||
@@ -106,92 +140,81 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</FrameLayout>
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Total Amount -->
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:text="TOTAL AMOUNT"
|
||||||
android:background="@color/mib_receipt_bg">
|
android:textSize="13sp"
|
||||||
|
android:textColor="#a0a2a1"
|
||||||
|
android:letterSpacing="0.08"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvAmount"
|
android:id="@+id/tvAmount"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="24sp"
|
android:textSize="34sp"
|
||||||
android:textStyle="bold"
|
android:textColor="#f14f0f"
|
||||||
android:textColor="@color/mib_receipt_amount"
|
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingTop="14dp" />
|
android:paddingBottom="10dp" />
|
||||||
|
|
||||||
<TextView
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
android:id="@+id/tvStatus"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Success"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/mib_receipt_amount"
|
|
||||||
android:gravity="center"
|
|
||||||
android:paddingBottom="6dp" />
|
|
||||||
|
|
||||||
<!-- Transaction # -->
|
<!-- Reference # -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction#" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||||
<TextView android:id="@+id/tvReferenceNo" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
|
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- From (sender profile name) -->
|
<!-- To Account -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="From" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||||
<TextView android:id="@+id/tvFromName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
|
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- To Account (recipient name + account number) -->
|
<!-- To Bank -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="To Account" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||||
<TextView android:id="@+id/tvToAccount" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
|
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Bank -->
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Bank" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
|
||||||
<TextView android:id="@+id/tvToBank" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
|
||||||
</LinearLayout>
|
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
|
||||||
|
|
||||||
<!-- Transaction Type -->
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Type" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
|
||||||
<TextView android:id="@+id/tvTransactionType" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
|
||||||
</LinearLayout>
|
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
|
||||||
|
|
||||||
<!-- Transaction Date -->
|
<!-- Transaction Date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
|
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Processed Date -->
|
<!-- Value Date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Processed Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||||
<TextView android:id="@+id/tvValueDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
|
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Remarks -->
|
<!-- Purpose -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Remarks" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="15sp" android:textColor="#a0a2a1" />
|
||||||
<TextView android:id="@+id/tvPurpose" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
|
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- MIB footer -->
|
<!-- MIB footer -->
|
||||||
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -201,10 +224,19 @@
|
|||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="22dp"
|
android:layout_height="28dp"
|
||||||
android:src="@drawable/mib_logo_full"
|
android:src="@drawable/mib_logo"
|
||||||
android:adjustViewBounds="true"
|
android:adjustViewBounds="true"
|
||||||
android:contentDescription="Maldives Islamic Bank" />
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="MALDIVES ISLAMIC BANK"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textColor="#000000"
|
||||||
|
android:letterSpacing="0.05" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
android:layout_width="32dp"
|
android:layout_width="32dp"
|
||||||
android:layout_height="32dp"
|
android:layout_height="32dp"
|
||||||
android:layout_marginEnd="16dp"
|
android:layout_marginEnd="16dp"
|
||||||
android:src="@drawable/bml_logo_vector"
|
android:src="@drawable/bml_icon"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:contentDescription="BML" />
|
android:contentDescription="BML" />
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,6 @@
|
|||||||
android:id="@+id/languageToggle"
|
android:id="@+id/languageToggle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:baselineAligned="false"
|
|
||||||
app:singleSelection="true"
|
app:singleSelection="true"
|
||||||
app:selectionRequired="true">
|
app:selectionRequired="true">
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
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:background="?attr/selectableItemBackground"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivAvatar"
|
||||||
|
android:layout_width="52dp"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/tvTime"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/tvPreview"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintBaseline_toBaselineOf="@id/tvName" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPreview"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvName"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?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:id="@+id/bubbleRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="4dp">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardBubble"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="200dp"
|
||||||
|
app:cardCornerRadius="18dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="0dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="200dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLabel"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnReceipt"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:padding="6dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/chat_view_receipt"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@drawable/ic_receipt"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBadge"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:paddingVertical="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAmount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAccount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:alpha="0.8"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNote"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvMeta"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_marginHorizontal="6dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDate"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:background="@drawable/bg_chat_date"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/transferContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?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="wrap_content"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingEnd="12dp">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivHeaderAvatar"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvHeaderName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvHeaderAccount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
<item android:id="@+id/nav_accounts"
|
<item android:id="@+id/nav_accounts"
|
||||||
android:icon="@drawable/ic_nav_accounts"
|
android:icon="@drawable/ic_nav_accounts"
|
||||||
android:title="@string/nav_accounts" />
|
android:title="@string/nav_accounts" />
|
||||||
<item android:id="@+id/nav_contacts"
|
<item android:id="@+id/nav_chats"
|
||||||
android:icon="@drawable/ic_contacts"
|
android:icon="@drawable/ic_chat"
|
||||||
android:title="@string/nav_contacts" />
|
android:title="@string/nav_chats" />
|
||||||
<item android:id="@+id/nav_transfer"
|
<item android:id="@+id/nav_transfer"
|
||||||
android:icon="@drawable/ic_send"
|
android:icon="@drawable/ic_send"
|
||||||
android:title="@string/transfer" />
|
android:title="@string/transfer" />
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
<item android:id="@+id/nav_pay_mv_qr"
|
<item android:id="@+id/nav_pay_mv_qr"
|
||||||
android:icon="@drawable/ic_qr_scan"
|
android:icon="@drawable/ic_qr_scan"
|
||||||
android:title="@string/pay_mv_qr" />
|
android:title="@string/pay_mv_qr" />
|
||||||
|
<item android:id="@+id/nav_chats"
|
||||||
|
android:icon="@drawable/ic_chat"
|
||||||
|
android:title="@string/nav_chats" />
|
||||||
<item android:id="@+id/nav_contacts"
|
<item android:id="@+id/nav_contacts"
|
||||||
android:icon="@drawable/ic_contacts"
|
android:icon="@drawable/ic_contacts"
|
||||||
android:title="@string/nav_contacts" />
|
android:title="@string/nav_contacts" />
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- BML receipt — dark mode -->
|
|
||||||
<color name="bml_receipt_bg">#191A1C</color>
|
|
||||||
<color name="bml_receipt_message">#DEDEE0</color>
|
|
||||||
<color name="bml_receipt_amount">#DEDEE0</color>
|
|
||||||
<color name="bml_receipt_label">#DEDEE0</color>
|
|
||||||
<color name="bml_receipt_footer">#000000</color>
|
|
||||||
<color name="bml_receipt_divider">#3D3E40</color>
|
|
||||||
<color name="bml_receipt_bottom_divider">#1E1F21</color>
|
|
||||||
|
|
||||||
<!-- MIB receipt — dark mode -->
|
|
||||||
<color name="mib_receipt_bg">#1A1A3C</color>
|
|
||||||
<color name="mib_receipt_label">#FFFFFF</color>
|
|
||||||
<color name="mib_receipt_value">#FFFFFF</color>
|
|
||||||
<color name="mib_receipt_divider">#33335C</color>
|
|
||||||
<color name="mib_receipt_footer_text">#FFFFFF</color>
|
|
||||||
<color name="mib_receipt_close_ring">#000000</color>
|
|
||||||
</resources>
|
|
||||||
@@ -5,24 +5,4 @@
|
|||||||
<color name="seed_secondary">#9AD141</color>
|
<color name="seed_secondary">#9AD141</color>
|
||||||
<color name="color_unpaid">#E85D04</color>
|
<color name="color_unpaid">#E85D04</color>
|
||||||
<color name="ic_logo_background">#E8B547</color>
|
<color name="ic_logo_background">#E8B547</color>
|
||||||
|
|
||||||
<!-- BML receipt (dark variants in values-night/colors.xml) -->
|
|
||||||
<color name="bml_receipt_bg">#FFFFFF</color>
|
|
||||||
<color name="bml_receipt_message">#2D2D2D</color>
|
|
||||||
<color name="bml_receipt_amount">#242424</color>
|
|
||||||
<color name="bml_receipt_label">#000000</color>
|
|
||||||
<color name="bml_receipt_footer">#F5F5F5</color>
|
|
||||||
<color name="bml_receipt_divider">#E9E9E9</color>
|
|
||||||
<color name="bml_receipt_bottom_divider">#EBEBEB</color>
|
|
||||||
<color name="bml_red">#E21B23</color>
|
|
||||||
|
|
||||||
<!-- MIB receipt (dark variants in values-night/colors.xml) -->
|
|
||||||
<color name="mib_receipt_bg">#FFFFFF</color>
|
|
||||||
<color name="mib_receipt_label">#000000</color>
|
|
||||||
<color name="mib_receipt_value">#000000</color>
|
|
||||||
<color name="mib_receipt_divider">#CAC4D0</color>
|
|
||||||
<color name="mib_receipt_footer_text">#000000</color>
|
|
||||||
<color name="mib_receipt_amount">#1EA833</color>
|
|
||||||
<color name="mib_receipt_close_ring">#FFFFFF</color>
|
|
||||||
<color name="mib_blue">#006FFC</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<item name="action_open_contacts" type="id" />
|
||||||
|
</resources>
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
<string name="nav_add_account">Add Login</string>
|
<string name="nav_add_account">Add Login</string>
|
||||||
<string name="nav_accounts">Accounts</string>
|
<string name="nav_accounts">Accounts</string>
|
||||||
<string name="nav_contacts">Contacts</string>
|
<string name="nav_contacts">Contacts</string>
|
||||||
|
<string name="nav_chats">Chats</string>
|
||||||
<string name="nav_activities">Recent Transfers</string>
|
<string name="nav_activities">Recent Transfers</string>
|
||||||
<string name="nav_transfer_history">Transaction History</string>
|
<string name="nav_transfer_history">Transaction History</string>
|
||||||
<string name="nav_finances">Finances</string>
|
<string name="nav_finances">Finances</string>
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
<string name="nav_more">More</string>
|
<string name="nav_more">More</string>
|
||||||
<string name="nav_desc_accounts">View all your bank accounts</string>
|
<string name="nav_desc_accounts">View all your bank accounts</string>
|
||||||
<string name="nav_desc_contacts">Manage your transfer contacts</string>
|
<string name="nav_desc_contacts">Manage your transfer contacts</string>
|
||||||
|
<string name="nav_desc_chats">Your transfers with each person, as chats</string>
|
||||||
<string name="nav_desc_transfer">Send money to a contact</string>
|
<string name="nav_desc_transfer">Send money to a contact</string>
|
||||||
<string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string>
|
<string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string>
|
||||||
<string name="nav_desc_activities">View your recent transfers</string>
|
<string name="nav_desc_activities">View your recent transfers</string>
|
||||||
@@ -127,6 +129,7 @@
|
|||||||
<string name="paymvqr_save_failed">Failed to save image</string>
|
<string name="paymvqr_save_failed">Failed to save image</string>
|
||||||
<string name="paymvqr_include_phone">Include phone number</string>
|
<string name="paymvqr_include_phone">Include phone number</string>
|
||||||
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
||||||
|
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<string name="action_lock">Lock app</string>
|
<string name="action_lock">Lock app</string>
|
||||||
@@ -399,4 +402,36 @@
|
|||||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
||||||
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
||||||
<string name="drag_to_reorder">Drag to reorder</string>
|
<string name="drag_to_reorder">Drag to reorder</string>
|
||||||
|
|
||||||
|
<!-- Chats -->
|
||||||
|
<string name="chats_search_hint">Search chats</string>
|
||||||
|
<string name="chats_empty">Chats start from now. New BML transfers will show up here.</string>
|
||||||
|
<string name="chats_empty_since">No transfers since %s. New BML transfers will show up here.</string>
|
||||||
|
<string name="chats_no_results">No chats found</string>
|
||||||
|
<string name="chat_preview_sent">You sent %s</string>
|
||||||
|
<string name="chat_preview_received">Received %s</string>
|
||||||
|
<string name="chat_from_label">From %s</string>
|
||||||
|
<string name="chat_received_label">Received</string>
|
||||||
|
<string name="chat_sent_label">Sent</string>
|
||||||
|
<string name="chat_into_label">To %s</string>
|
||||||
|
<string name="chat_debit">Debit</string>
|
||||||
|
<string name="chat_credit">Credit</string>
|
||||||
|
<string name="chat_sent_meta">✓ Sent · %s</string>
|
||||||
|
<string name="chat_today">Today</string>
|
||||||
|
<string name="chat_yesterday">Yesterday</string>
|
||||||
|
<string name="chat_from_account">From account</string>
|
||||||
|
<string name="chat_pick_account">Send from</string>
|
||||||
|
<string name="chat_transfer_sent">Transfer sent</string>
|
||||||
|
<string name="chat_view_receipt">Receipt</string>
|
||||||
|
<string name="chat_not_in_contacts">Not in your contacts</string>
|
||||||
|
<string name="chat_save_contact">Save contact</string>
|
||||||
|
<string name="chat_send">Send</string>
|
||||||
|
<string name="chat_to">To</string>
|
||||||
|
<string name="chat_pick_recipient_account">Send to</string>
|
||||||
|
<string name="chat_from_to_label">From %1$s → %2$s</string>
|
||||||
|
<string name="chat_receipt_missing">Receipt no longer available</string>
|
||||||
|
<string name="chat_amount">Amount</string>
|
||||||
|
<string name="chat_amount_invalid">Enter an amount</string>
|
||||||
|
<string name="chat_account_subtitle">Account %s</string>
|
||||||
|
<string name="chat_cannot_send">Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[← Profile Picture](06-profile-picture.md) **Next →** [PayMV QR](08-paymv-qr.md)
|
[← Profile Picture](06-profile-picture.md)
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# PayMV QR (Receive)
|
|
||||||
|
|
||||||
> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress).
|
|
||||||
|
|
||||||
Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
GET api/app/qr/?lang=<lang>&type=p2p&amount=<amount>
|
|
||||||
```
|
|
||||||
|
|
||||||
The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields.
|
|
||||||
|
|
||||||
## Response
|
|
||||||
|
|
||||||
The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`.
|
|
||||||
|
|
||||||
## The card image
|
|
||||||
|
|
||||||
A 1240 × 1322 PNG:
|
|
||||||
- Blue `#005DA3` card with a 6 px border.
|
|
||||||
- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right.
|
|
||||||
- A blue panel holding the holder's name and the QR.
|
|
||||||
- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it.
|
|
||||||
- A "MALDIVES NATIONAL QR" footer.
|
|
||||||
|
|
||||||
Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard).
|
|
||||||
|
|
||||||
Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[← Saved Favourites](07-contacts.md)
|
|
||||||
@@ -127,7 +127,6 @@ Client Server
|
|||||||
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
||||||
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
||||||
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
||||||
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,102 +2,37 @@
|
|||||||
|
|
||||||
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
|
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
|
||||||
|
|
||||||
> **Fahipay QRs are a work in progress.** Thijooree's Fahipay card matches Fahipay's design, but the Fahipay app currently rejects the QRs it generates as **"Invalid QR"**. BML QRs scan fine. See [PayMV QR Format → Fahipay](18-paymv-qr-format.md#fahipay-work-in-progress).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fragment — `PayMvQrFragment`
|
## Fragment — `PayMvQrFragment`
|
||||||
|
|
||||||
A single screen (no tabs). Re-renders the QR live (300 ms debounce) as the user edits the form. It can also be opened for a saved contact with `PayMvQrFragment.forContact(accountNumber, name, bank)`. The QR then pays into the contact's account, and the account picker and phone toggle are hidden.
|
A single screen (no tabs). Re-renders the QR live as the user edits the form.
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
| Field | Source / behaviour |
|
| Field | Source / behaviour |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB, M-Faisa and BML USD currently excluded — flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
|
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB and BML USD currently excluded — both flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
|
||||||
| Amount (`etAmount`) | Optional. Blank / zero / unparseable → open-amount QR. Commas are stripped |
|
| Amount (`etAmount`) | Optional. Blank → open-amount QR |
|
||||||
| Reference (`etReference`) | Free-text purpose, written to tag 62→08. Blank → tag omitted (BML), or `PAYMENT` (Fahipay) |
|
| Reference (`etReference`) | Free-text purpose; defaults to `paymvqr_reference_default` if blank — written to tag 62→08 |
|
||||||
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05, normalised to `+960XXXXXXX` |
|
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05 (auto-prefixed `+960` if 7-digit local) |
|
||||||
|
|
||||||
### What goes on the card
|
### Generation
|
||||||
|
|
||||||
| Item | Value |
|
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md):
|
||||||
|---|---|
|
|
||||||
| Name | BML / MIB: `accountBriefName`. Fahipay: the holder's full name (`profileName`, falling back to the saved Fahipay profile's `fullName`) — **not** the generic "Fahipay Wallet" brief name. Contacts: the contact's name. Always uppercased |
|
|
||||||
| QR | The payload below, white modules on the card blue, error correction M, no quiet zone |
|
|
||||||
| Vertical text | The QR's reference (tag 62→05) — see below |
|
|
||||||
| Amount | **Not printed** on the card (neither bank does); it only appears inside the QR, and in BML's vertical text |
|
|
||||||
|
|
||||||
### Vertical text (reference)
|
1. Tag 26 container: GUI (`mv.favara.mpqr`), acquirer BIC, account number, optional mobile, `IPAY`
|
||||||
|
2. Acquirer BIC is derived from the source account's bank: `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay)
|
||||||
|
3. Tag 62 container: random 9-char reference + the purpose text
|
||||||
|
4. Tag 80 container: GUI + ISO timestamp
|
||||||
|
5. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
|
||||||
|
|
||||||
Both banks print a short code vertically beside the QR, reading bottom-to-top. It is the same string as the payload's reference, **tag 62→05**, so Thijooree calculates the reference first and uses it for both.
|
The rendered card image (bank-styled background plus QR) is shown in-place.
|
||||||
|
|
||||||
| Bank | Reference / vertical text | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| BML | Account number converted to **base-32** (digits `0-9A-V`, uppercase), followed by the **amount exactly as typed** (commas removed, no forced decimals), capped at 25 chars | `7730000188362` → `70V3UKKUA`; with amount `100` → `70V3UKKUA100` |
|
|
||||||
| Fahipay | `P` + 9 random uppercase alphanumeric chars. **No amount** | `P2KVTPYL4E` |
|
|
||||||
| Other (MIB contacts) | 9 random uppercase alphanumeric chars | `WHQS0SX5O` |
|
|
||||||
|
|
||||||
BML's base-32 is `AccountNumbertoBase32` from the BML app: `BigInt(account)`, repeatedly `% 32` into the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`. If the account number isn't numeric, Thijooree falls back to a random 9-char reference.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Card Rendering
|
|
||||||
|
|
||||||
Two renderers, chosen by the target's bank. Both return a `Bitmap` shown in `ivQrCard` (`fitCenter`) and used for Share / Save.
|
|
||||||
|
|
||||||
### BML (and MIB) — `renderQrCard()`
|
|
||||||
|
|
||||||
A 1:1 copy of BML app v2.1.47's `ReceiveCard` React Native component (decompiled from the Hermes bundle). All values are BML's StyleSheet values in dp, laid out for a 560 dp reference screen width (`SCREEN_WIDTH_DP`) and drawn at 2 px/dp (`PX_PER_DP`), so the card comes out about 1024 px wide.
|
|
||||||
|
|
||||||
| Element | Spec |
|
|
||||||
|---|---|
|
|
||||||
| Colour | `mmaBlue` `#0E5CA4` everywhere |
|
|
||||||
| Card | Width `sw − 48`. Blue background, radius 20. The white top section is inset 2 dp (top corners 18), which shows as a thin blue border |
|
|
||||||
| Header row | 32 dp from top, 40 dp side margins. Left: `bml_logo_paymv` ("BANK OF MALDIVES" wordmark, from BML's assets) contained in `0.38·sw × 0.38·sw·0.1117`. Right: "PayMV QR", Sofia Pro Bold, `#0E5CA4`, sized to BML's `0.2·sw × 0.2·sw·0.1733` image box, shifted down 1 dp. MIB uses `mib_faisanet_logo` in the same box |
|
|
||||||
| QR panel | 24 dp below the header, 40 dp side margins, radius 16, 24 dp bottom margin |
|
|
||||||
| Name | Roboto (system default) regular, 14 sp, white; 8 + 12 dp above, 16 dp below |
|
|
||||||
| QR | `0.5·sw` square; 37 dp padding below |
|
|
||||||
| Vertical text | Roboto 10 sp, **black at 80 % opacity**, rotated −90°. Centred `railW/1.37 − railW/2` right of the QR's right edge and `(qr + railW/1.5)/2` down from the QR top, where `railW = sw/1.85`. Ellipsized to `railW` |
|
|
||||||
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold (`res/font/sofia_pro_bold.ttf`), `0.046·sw`, letter spacing 1.2 dp, 12 dp vertical padding |
|
|
||||||
|
|
||||||
### Fahipay — `renderFahipayQrCard()`
|
|
||||||
|
|
||||||
Fahipay's app doesn't draw its card; it shows an image generated by Fahipay's server (`api/app/qr/`). Thijooree copies that image's layout, measured in pixels on its **1240 × 1322** canvas. Text is sized so capital letters match the measured cap heights.
|
|
||||||
|
|
||||||
| Element | Spec |
|
|
||||||
|---|---|
|
|
||||||
| Colour | `#005DA3` |
|
|
||||||
| Card | Blue, radius 50. White area inset 6 px (top corners 44) down to y 1174. The blue below it is the footer |
|
|
||||||
| Header row | Square app icon `fahipay_logo` at (94, 94)–(163, 163), then the "FahiPay" wordmark `fahipay_logo_long` at x 178, y 104, 48 px tall — **both, side by side** |
|
|
||||||
| "PayMV QR" | Sofia Pro Bold, blue, right-aligned at x 1147, cap height 30 (cap top y 101) |
|
|
||||||
| QR panel | (166, 218)–(1074, 1126), radius 55 |
|
|
||||||
| Name | Roboto regular, white, centred at x 619, cap height 30 (cap top y 314). Shrinks to fit the panel minus 80 px |
|
|
||||||
| QR | 562 px at (338, 417) |
|
|
||||||
| Vertical text | Montserrat Regular (`res/font/montserrat_regular.ttf`), **blue**, cap height 27, rotated −90°. It sits in the **white margin right of the panel**: text starts at y 1087, baseline at x 1171 |
|
|
||||||
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold, white, cap height 55.5 (cap top y 1220), BML's letter spacing (1.2/25.76 em) |
|
|
||||||
|
|
||||||
Fonts follow the BML card (Sofia Pro Bold, Roboto), except the vertical text, which keeps Fahipay's Montserrat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Generation
|
|
||||||
|
|
||||||
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md#generating-a-receive-payment-qr):
|
|
||||||
|
|
||||||
1. Tag 01: `11` (static). Fahipay QRs with an amount use `12` (dynamic)
|
|
||||||
2. Tag 26: GUI (`mv.favara.mpqr`), acquirer BIC ×2 — `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay), account number, optional mobile, `IPAY`
|
|
||||||
3. Tag 54: amount as `%.2f`. If there's no amount: omitted (BML), `***` (Fahipay)
|
|
||||||
4. Tag 59: name, uppercased, max 25 chars
|
|
||||||
5. Tag 60: Fahipay only — `LD` + 4 random digits
|
|
||||||
6. Tag 62: the reference (above) + purpose
|
|
||||||
7. Tag 80: GUI + timestamp `yyyy-MM-dd'T'HH:mm:ss.00000`
|
|
||||||
8. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
|
|
||||||
|
|
||||||
### Actions
|
### Actions
|
||||||
|
|
||||||
- **Share** (`btnShare`) — writes `<name>_paymv_qr.png` to the cache and shares it via `FileProvider` + `ACTION_SEND`
|
- **Share** (`btnShare`) — exports the rendered card via `FileProvider` + `ACTION_SEND`
|
||||||
- **Save** (`btnSave`) — writes `<name>_PayMV_QR.png` to `MediaStore.Images` / `Pictures/`
|
- **Save** (`btnSave`, `PayMvQrFragment.kt:78`) — writes the PNG to `MediaStore.Images` / `Pictures/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
|||||||
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
|
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
|
||||||
| `52` | Merchant category code | `"0000"` (generic) |
|
| `52` | Merchant category code | `"0000"` (generic) |
|
||||||
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
||||||
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`). Open-amount QRs: absent (Thijooree BML) or `"***"` (BML's and Fahipay's own QRs) |
|
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
|
||||||
| `58` | Country code | `"MV"` |
|
| `58` | Country code | `"MV"` |
|
||||||
| `59` | Merchant / recipient name | Max 25 characters, uppercase in every real QR seen |
|
| `59` | Merchant / recipient name | Max 25 characters |
|
||||||
| `60` | Merchant city / store code | `LD` + 4 digits (e.g. `LD0442`, `LD0745`). Seen in BML POS QRs and in BML's and Fahipay's own receive QRs; meaning of the digits unknown |
|
| `60` | Merchant city / store code | BML POS QRs only |
|
||||||
| `62` | Additional data field | Container — see sub-tags below |
|
| `62` | Additional data field | Container — see sub-tags below |
|
||||||
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
||||||
| `80` | Supplementary data | Container — timestamp and domain |
|
| `80` | Supplementary data | Container — timestamp and domain |
|
||||||
@@ -66,8 +66,8 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
|||||||
|
|
||||||
| Sub-Tag | Field | Notes |
|
| Sub-Tag | Field | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `05` | Reference / bill number | Bank-specific — see [Reference (tag 62→05)](#reference-tag-6205). Also printed vertically on the QR card |
|
| `05` | Reference / bill number | 9 random uppercase alphanumeric characters |
|
||||||
| `08` | Payment purpose | Free-form text entered by the payee. BML's app defaults it to `Quickpay Transfer`, Fahipay to `PAYMENT` |
|
| `08` | Payment purpose | Free-form text entered by the payee |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,7 +77,6 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `00` | Domain | `"mv.favara.mpqr"` |
|
| `00` | Domain | `"mv.favara.mpqr"` |
|
||||||
| `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` |
|
| `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` |
|
||||||
| `02` | Unknown | `"0005"` — only seen in Fahipay's own QR **with an amount**; not generated by Thijooree |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,98 +115,18 @@ To create a QR that others can scan to pay you:
|
|||||||
10 04 IPAY
|
10 04 IPAY
|
||||||
52 04 0000 ← MCC
|
52 04 0000 ← MCC
|
||||||
53 03 462 ← MVR
|
53 03 462 ← MVR
|
||||||
54 <len> <amount> ← "%.2f". Open amount: omit (BML) / "***" (Fahipay)
|
54 <len> <amount> ← Omit tag entirely if open-amount
|
||||||
58 02 MV
|
58 02 MV
|
||||||
59 <len> <NAME UP TO 25 CHARS> ← Uppercased
|
59 <len> <name up to 25 chars>
|
||||||
60 06 LD<4 random digits> ← Fahipay only
|
|
||||||
62 <len>
|
62 <len>
|
||||||
05 <len> <reference> ← Bank-specific, see below
|
05 09 <9 random alphanum chars> ← Reference
|
||||||
08 <len> <purpose text> ← Omit if blank (BML) / "PAYMENT" (Fahipay)
|
08 <len> <purpose text>
|
||||||
80 <len>
|
80 <len>
|
||||||
00 15 mv.favara.mpqr
|
00 15 mv.favara.mpqr
|
||||||
01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp
|
01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp
|
||||||
6304<CRC>
|
6304<CRC>
|
||||||
```
|
```
|
||||||
|
|
||||||
For Fahipay, tag `01` is `12` (dynamic) when an amount is set.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reference (tag 62→05)
|
|
||||||
|
|
||||||
The reference is also the **vertical text** printed beside the QR on both banks' cards, so it is calculated once and used for both (`PayMvQrFragment.generateQr()`).
|
|
||||||
|
|
||||||
### BML — base-32 account number + amount
|
|
||||||
|
|
||||||
```
|
|
||||||
reference = base32(accountNumber) + amountAsTyped
|
|
||||||
```
|
|
||||||
|
|
||||||
- `base32` is BML's `AccountNumbertoBase32`: treat the account number as an integer and convert it to base 32 with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`, most significant digit first
|
|
||||||
- `amountAsTyped` is the amount field with commas removed and **no forced decimals** (`100` stays `100`, `100.5` stays `100.5`). Empty for open-amount QRs
|
|
||||||
- Capped at 25 characters. A non-numeric account number falls back to 9 random characters
|
|
||||||
|
|
||||||
| Account | Amount | Reference / vertical text |
|
|
||||||
|---|---|---|
|
|
||||||
| `7730000188362` | — | `70V3UKKUA` |
|
|
||||||
| `7730000188362` | `100` | `70V3UKKUA100` |
|
|
||||||
|
|
||||||
Confirmed by decoding a QR from BML's app: `62→05` = `70V3UKKUA`, the same as the vertical text on its card.
|
|
||||||
|
|
||||||
### Fahipay — `P` + 9 random characters
|
|
||||||
|
|
||||||
Fahipay's server issues references like `P135KOKXJY` and `P2KVTPYL4E`: `P` followed by 9 uppercase alphanumerics. The vertical text shows the reference only — **the amount is not appended** (confirmed on a QR carrying amount `55`). Thijooree generates `"P" + 9 random chars`.
|
|
||||||
|
|
||||||
### Others
|
|
||||||
|
|
||||||
9 random uppercase alphanumeric characters.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Real Receive QRs (Reference Samples)
|
|
||||||
|
|
||||||
Decoded from QR images generated by the official apps (CRC verified).
|
|
||||||
|
|
||||||
**BML app** (open amount):
|
|
||||||
|
|
||||||
```
|
|
||||||
00020101021126920014mv.favara.mpqr0108MALBMVMV0208MALBMVMV031377300001883620511+96091980261004IPAY6006LD04425204000053034625403***5802MV5915SHIHAM A.RAHMAN6234050970V3UKKUA0817Quickpay Transfer80470014mv.favara.mpqr01252026-09-26T02:29:53.000006304F8E6
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that BML's app places tag `60` *inside* tag `26` here (after `10 IPAY`, as `6006LD0442`).
|
|
||||||
|
|
||||||
**Fahipay app**, open amount:
|
|
||||||
|
|
||||||
```
|
|
||||||
00020101021126810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125008500611080511+96098074051004IPAY5204000053034625403***5802MV5912MOHAMED RAIF6006LD074562250510P135KOKXJY0807PAYMENT80470014mv.favara.mpqr01252026-09-26T03:44:36.0000063042707
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fahipay app**, amount `55`:
|
|
||||||
|
|
||||||
```
|
|
||||||
00020101021226810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125003600510030511+96091980261004IPAY5204000053034625402555802MV5919SHIHAM ABDUL RAHMAN6006LD097062250510P2KVTPYL4E0807PAYMENT80550014mv.favara.mpqr01252026-09-26T03:22:08.000000204000563045304
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fahipay (Work in Progress)
|
|
||||||
|
|
||||||
> ⚠️ **Fahipay QRs generated by Thijooree do not work yet.** The Fahipay app rejects them as **"Invalid QR"**. BML QRs from Thijooree scan fine in the BML app.
|
|
||||||
|
|
||||||
Fahipay's app doesn't build its QR locally: it fetches it from `GET api/app/qr/?lang=…&type=p2p&amount=…`, and the server returns the finished card image and payload. Things tried so far, with the Fahipay app still reporting invalid:
|
|
||||||
|
|
||||||
| Change | Status |
|
|
||||||
|---|---|
|
|
||||||
| Mobile `26→05` normalised from Fahipay's stored `960XXXXXXX` to `+960XXXXXXX` | Done (was a real bug) |
|
|
||||||
| Name `59` uppercased | Done |
|
|
||||||
| `54` = `***` for open amount, `01` = `12` with an amount | Done |
|
|
||||||
| `60` = `LD` + 4 random digits | Done |
|
|
||||||
| `62→08` defaults to `PAYMENT` | Done |
|
|
||||||
| `62→05` shaped `P` + 9 chars | Done |
|
|
||||||
| `80→02` = `0005` (amount QRs only) | Not done |
|
|
||||||
|
|
||||||
The CRC is correct (verified against all samples). With no amount, Thijooree's payload now has the same fields in the same order as Fahipay's own. The leading theory is that Fahipay's scanner looks up the `P…` reference on Fahipay's server, which issued it. If so, no locally generated QR can pass, and the fix would be to fetch the payload from `api/app/qr/` and render the card around it.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Parsing a PayMV QR (Incoming Scan)
|
## Parsing a PayMV QR (Incoming Scan)
|
||||||
@@ -300,11 +219,11 @@ exactly one request is made either way.
|
|||||||
|
|
||||||
## Example Payload
|
## Example Payload
|
||||||
|
|
||||||
Static BML QR for account `7730000188362`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
|
Static QR for account `7700000000123`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
|
||||||
|
|
||||||
```
|
```
|
||||||
000201010211268...520400005303462
|
000201010211268...520400005303462
|
||||||
5802MV5909AHMED ALI6221050970V3UKKUA0804Rent
|
5802MV5909AHMED ALI6225050912345ABCDEF0804Rent
|
||||||
80...63044A2B
|
80...63044A2B
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
|||||||
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
|
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
|
||||||
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
|
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
|
||||||
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
|
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
|
||||||
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR, BML/Fahipay card rendering, vertical reference text (Fahipay QRs WIP) |
|
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
|
||||||
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
|
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
|
||||||
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
|
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
|
||||||
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
|
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
|
||||||
@@ -39,7 +39,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
|||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference |
|
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, QR generation recipe, parsing reference |
|
||||||
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
|
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
|
||||||
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
|
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
|
||||||
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
|
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
- Fix language toggle alignment.
|
|
||||||
- Redesign Fahipay (still broken) and BML PayMV QR
|
|
||||||
- BML receipt preview dark theme support
|
|
||||||
- MIB receipt redesign with dark theme support
|
|
||||||
- New recipt full-screen mode for MIB and BML
|
|
||||||
Reference in New Issue
Block a user