chats: business payments, new bubbles, pinning, Scan to Pay

- Card / Scan to Pay payments show as business chats (from BML
  "Payment Completed" alerts and "Purchase" history), kept apart from
  people; static merchant QRs are kept so a business can be paid again
  from its chat, with a card picker
- Scan to Pay button on the chat list; returns and re-checks after paying
- Compact bubbles with ticks (alert seen / booked in history), receipt
  button, day totals, monthly summary, long-press actions
- Personal accounts confirm with just the dialog over the chat; business
  profiles keep the sheet for the OTP step
- From chip shows account number and balance; MVR -> USD account is
  blocked, USD -> MVR warns about conversion
- Long-press a chat to pin it to the top

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
This commit is contained in:
2026-09-26 04:10:06 +05:00
co-authored by Claude Opus 5.5
parent da5cca79c6
commit 83ca37eade
19 changed files with 1089 additions and 252 deletions
@@ -26,6 +26,7 @@ import sh.sar.basedbank.databinding.FragmentChatBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ViewChatHeaderBinding import sh.sar.basedbank.databinding.ViewChatHeaderBinding
import sh.sar.basedbank.util.ContactDisplay import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ReceiptStore import sh.sar.basedbank.util.ReceiptStore
@@ -35,9 +36,12 @@ import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread 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 * One person's transfers as a chat, or one business's card / Scan to Pay payments.
* "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. * 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,
* amount and note, so the usual confirm/OTP flow still applies. A monthly summary sits on top,
* and long-pressing a bubble offers Send again / receipt / copy.
*/ */
class ChatFragment : Fragment(), ContactSheetHost { class ChatFragment : Fragment(), ContactSheetHost {
@@ -48,11 +52,19 @@ class ChatFragment : Fragment(), ContactSheetHost {
private var peerKey = "" private var peerKey = ""
private var header: ViewChatHeaderBinding? = null private var header: ViewChatHeaderBinding? = null
private val adapter = ChatMessagesAdapter { openReceipt(it.receiptKey) } private val adapter = ChatMessagesAdapter(
onReceiptClick = { openReceipt(it.receiptKey) },
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
)
private var thread: ChatThread? = null private var thread: ChatThread? = null
private var fromAccounts: List<BankAccount> = emptyList() private var fromAccounts: List<BankAccount> = emptyList()
private var selectedFrom: BankAccount? = null private var selectedFrom: BankAccount? = null
private var selectedTo: ChatAccount? = null private var selectedTo: ChatAccount? = null
private var merchantQr: String? = null
private var cardAccounts: List<BankAccount> = emptyList()
private var selectedCard: BankAccount? = null
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
private var payStartedAt = 0L
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentChatBinding.inflate(inflater, container, false) _binding = FragmentChatBinding.inflate(inflater, container, false)
@@ -67,21 +79,26 @@ class ChatFragment : Fragment(), ContactSheetHost {
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false) adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { viewModel.hideAmounts.observe(viewLifecycleOwner) {
adapter.setHideAmounts(it) adapter.setHideAmounts(it)
selectedFrom?.let { acc -> bindAccountRow(binding.fromAccountRow, acc) } thread?.let { t -> bindSummary(t) }
bindFromChip()
} }
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN } fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
cardAccounts = accounts.filter {
it.bank == "BML" && it.profileType in CARD_TYPES && it.statusDesc.equals("Active", ignoreCase = true)
}
bindFromAccounts() bindFromAccounts()
} }
binding.cardFromAccount.setOnClickListener { showAccountPicker() } binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
binding.btnSaveContact.setOnClickListener { openContact() } binding.btnSaveContact.setOnClickListener { openContact() }
binding.toAccountBar.setOnClickListener { showRecipientPicker() } binding.toAccountBar.setOnClickListener { showRecipientPicker() }
binding.btnSend.setOnClickListener { send() } binding.btnSend.setOnClickListener { send() }
binding.etAmount.setOnEditorActionListener { _, actionId, _ -> binding.etNote.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
} }
binding.btnPayAgain.setOnClickListener { payBusiness() }
// Transfers started from this chat come straight back here instead of opening the receipt. // Transfers started from this chat come straight back here instead of opening the receipt.
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ -> parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
@@ -112,13 +129,14 @@ class ChatFragment : Fragment(), ContactSheetHost {
adapter.showDestination = t.accounts.size > 1 adapter.showDestination = t.accounts.size > 1
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday)) adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1) binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
updateToolbar() bindSummary(t)
bindFromAccounts() bindFromAccounts()
val previous = selectedTo val previous = selectedTo
val to = t.accounts.firstOrNull { it.account == previous?.account } val to = t.accounts.firstOrNull { it.account == previous?.account }
?: t.accounts.firstOrNull { it.account == t.peerAccount } ?: t.accounts.firstOrNull { it.account == t.peerAccount }
selectTo(to, matchFromCurrency = previous == null) selectTo(to, matchFromCurrency = previous == null)
updateToolbar()
} }
} }
@@ -128,7 +146,19 @@ class ChatFragment : Fragment(), ContactSheetHost {
val t = thread ?: return val t = thread ?: return
selectedTo = to selectedTo = to
b.toAccountBar.visibility = if (to == null) View.GONE else View.VISIBLE if (t.isMerchant) {
// Businesses paid by card / Scan to Pay: history only, nothing to send to or save.
b.toAccountBar.visibility = View.GONE
b.saveContactBar.visibility = View.GONE
b.composer.visibility = View.GONE
b.tvCannotSend.visibility = View.GONE
b.btnPayAgain.visibility = View.VISIBLE
bindMerchantQr(t)
return
}
b.btnPayAgain.visibility = View.GONE
// One account: it's shown in the header. Several: this bar picks between them.
b.toAccountBar.visibility = if (to == null || t.accounts.size < 2) View.GONE else View.VISIBLE
if (to != null) { if (to != null) {
b.tvToAccount.text = to.account b.tvToAccount.text = to.account
b.tvToCurrency.text = to.currency b.tvToCurrency.text = to.currency
@@ -151,6 +181,7 @@ class ChatFragment : Fragment(), ContactSheetHost {
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) { !selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom) fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
} }
updateCurrencyWarning()
} }
private fun showRecipientPicker() { private fun showRecipientPicker() {
@@ -171,12 +202,17 @@ class ChatFragment : Fragment(), ContactSheetHost {
?: getString(R.string.chat_not_in_contacts) ?: getString(R.string.chat_not_in_contacts)
row.tvDropdownAccountNumber.text = account.account row.tvDropdownAccountNumber.text = account.account
row.tvDropdownBalance.text = account.currency row.tvDropdownBalance.text = account.currency
row.tvDropdownAccountType.visibility = View.GONE
row.ivDropdownCardLogo.visibility = View.GONE row.ivDropdownCardLogo.visibility = View.GONE
// A USD account can only be paid from a USD account.
val unavailable = account.currency.equals("USD", ignoreCase = true) && !hasUsdSource()
row.tvDropdownAccountType.text = getString(R.string.chat_needs_usd_source)
row.tvDropdownAccountType.visibility = if (unavailable) View.VISIBLE else View.GONE
row.root.alpha = if (unavailable) 0.4f else 1f
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg) if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background) else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
row.root.setOnClickListener { if (!unavailable) row.root.setOnClickListener {
selectTo(account, matchFromCurrency = true) selectTo(account, matchFromCurrency = true)
updateToolbar()
dialog.dismiss() dialog.dismiss()
} }
list.addView(row.root) list.addView(row.root)
@@ -204,7 +240,11 @@ class ChatFragment : Fragment(), ContactSheetHost {
} }
private fun onTransferDone() { private fun onTransferDone() {
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
}
binding.etAmount.text = null binding.etAmount.text = null
binding.etNote.text = null
// The receipt shows the new bubble right away; history catches up a little later and // The receipt shows the new bubble right away; history catches up a little later and
// replaces it with the bank's own entry. // replaces it with the bank's own entry.
sync() sync()
@@ -212,12 +252,12 @@ class ChatFragment : Fragment(), ContactSheetHost {
delay(HISTORY_CATCH_UP_MS) delay(HISTORY_CATCH_UP_MS)
sync() sync()
} }
Snackbar.make(binding.root, R.string.chat_transfer_sent, Snackbar.LENGTH_LONG) // Only offer the receipt this payment made; QR payments don't save one.
.setAnchorView(binding.composer) val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
.setAction(R.string.chat_view_receipt) { val merchant = thread?.isMerchant == true
val latest = ReceiptStore.loadAll(requireContext()).firstOrNull() ?: return@setAction Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
openReceipt(latest.savedAt.toString()) .setAnchorView(if (binding.composer.visibility == View.VISIBLE) binding.composer else binding.btnPayAgain)
} .apply { if (receipt != null) setAction(R.string.chat_view_receipt) { openReceipt(receipt.savedAt.toString()) } }
.show() .show()
} }
@@ -243,8 +283,208 @@ class ChatFragment : Fragment(), ContactSheetHost {
private fun selectFrom(account: BankAccount) { private fun selectFrom(account: BankAccount) {
val b = _binding ?: return val b = _binding ?: return
selectedFrom = account selectedFrom = account
bindAccountRow(b.fromAccountRow, account) bindFromChip()
b.tilAmount.prefixText = account.currencyName b.tilAmount.prefixText = account.currencyName
updateCurrencyWarning()
}
/** The chip shows the account (or, in a business chat, the card) paid from, with its balance. */
private fun bindFromChip() {
val b = _binding ?: return
val account = (if (thread?.isMerchant == true) selectedCard else selectedFrom) ?: return
val balance = AccountListParser.from(account)?.balance.orEmpty()
val hide = viewModel.hideAmounts.value ?: false
val money = when {
balance.isBlank() -> account.currencyName
hide -> AccountHistoryAdapter.maskAmount(balance)
else -> balance
}
b.btnFrom.text = getString(R.string.chat_from_chip, account.accountBriefName, account.accountNumber, money)
b.btnFrom.contentDescription = getString(R.string.chat_from_account) + ": " + b.btnFrom.text
// A card shows its network logo up front, in full colour; an account keeps the dropdown arrow.
val logo = if (thread?.isMerchant == true) sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(account) else null
val density = resources.displayMetrics.density
if (logo != null) {
b.btnFrom.setIconResource(logo)
b.btnFrom.iconTint = null
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_START
b.btnFrom.iconSize = (28 * density).toInt()
} else {
b.btnFrom.setIconResource(R.drawable.ic_arrow_right)
b.btnFrom.iconTint = android.content.res.ColorStateList.valueOf(
MaterialColors.getColor(b.btnFrom, com.google.android.material.R.attr.colorOnSecondaryContainer))
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_END
b.btnFrom.iconSize = (16 * density).toInt()
}
}
private fun showCardPicker() {
if (cardAccounts.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_card)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (card in cardAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, card)
sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(card)?.let {
row.ivDropdownCardLogo.setImageResource(it)
row.ivDropdownCardLogo.visibility = View.VISIBLE
}
if (card.accountNumber == selectedCard?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
row.root.setOnClickListener {
selectedCard = card
bindFromChip()
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
/**
* MVR can't be sent into a USD account: say so and disable Send. USD into an MVR account is
* converted by BML at its rate: allowed, with a warning.
*/
private fun updateCurrencyWarning() {
val b = _binding ?: return
val from = selectedFrom?.currencyName.orEmpty()
val to = selectedTo?.currency.orEmpty()
val blocked = isBlockedPair(from, to)
val converts = from.equals("USD", ignoreCase = true) && to.equals("MVR", ignoreCase = true)
b.tvCurrencyWarning.text = when {
blocked -> getString(R.string.chat_currency_blocked)
converts -> getString(R.string.chat_currency_warning)
else -> null
}
b.tvCurrencyWarning.visibility = if (blocked || converts) View.VISIBLE else View.GONE
b.btnSend.isEnabled = !blocked
}
/** MVR → USD account: BML doesn't allow it. */
private fun isBlockedPair(fromCurrency: String, toCurrency: String) =
fromCurrency.equals("MVR", ignoreCase = true) && toCurrency.equals("USD", ignoreCase = true)
private fun hasUsdSource() = fromAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
/** Looks up a saved static QR for this business: with one, the button pays it directly. */
private fun bindMerchantQr(t: ChatThread) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val qr = withContext(Dispatchers.IO) { ChatStore.merchantQr(ctx, t.peerName) }
val b = _binding ?: return@launch
merchantQr = qr
b.btnPayAgain.text = if (qr != null) getString(R.string.chat_pay_business, t.peerName)
else getString(R.string.chat_pay_again)
// A saved QR is paid from a card picked here: show just the card chip above the button.
b.composer.visibility = if (qr != null && cardAccounts.isNotEmpty()) View.VISIBLE else View.GONE
b.amountRow.visibility = View.GONE
b.tvCurrencyWarning.visibility = View.GONE
if (qr != null && selectedCard == null) {
val lastCard = t.messages.lastOrNull { m -> cardAccounts.any { it.accountNumber == m.accountNumber } }?.accountNumber
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
selectedCard = cardAccounts.firstOrNull { it.accountNumber == lastCard }
?: cardAccounts.firstOrNull { it.accountNumber == defaultCard }
?: cardAccounts.firstOrNull()
}
bindFromChip()
}
}
/** Saved static QR: pay it in the sheet over the chat. Otherwise: open the scanner. */
private fun payBusiness() {
val qr = merchantQr
if (qr == null) {
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment.newInstanceWithAutoScan())
return
}
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
val transfer = TransferFragment.newInstanceFromBmlQr(qr, selectedCard?.accountNumber, returnOnSuccess = true)
payStartedAt = System.currentTimeMillis()
hideKeyboard()
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
}
/** This month with this person (sent / received / net) or at this business (spent / payments). */
private fun bindSummary(t: ChatThread) {
val b = _binding ?: return
val monthStart = java.util.Calendar.getInstance().apply {
set(java.util.Calendar.DAY_OF_MONTH, 1)
set(java.util.Calendar.HOUR_OF_DAY, 0); set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0); set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis
val thisMonth = t.messages.filter { it.timeMillis >= monthStart }
if (thisMonth.isEmpty()) { b.summaryCard.visibility = View.GONE; return }
// One currency per card: the selected account's, else the one used most this month.
val currency = selectedTo?.currency?.takeIf { c -> thisMonth.any { it.currency == c } }
?: thisMonth.groupingBy { it.currency }.eachCount().maxByOrNull { it.value }!!.key
val ms = thisMonth.filter { it.currency == currency }
val hide = viewModel.hideAmounts.value ?: false
fun money(v: Double) = if (hide) "$currency ••••" else "$currency ${"%,.2f".format(v)}"
val month = java.text.SimpleDateFormat("MMMM", java.util.Locale.getDefault()).format(java.util.Date())
b.summaryCard.visibility = View.VISIBLE
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
if (t.isMerchant) {
b.tvSumLabel1.text = getString(R.string.chat_sum_spent, month)
b.tvSumValue1.text = money(sent)
b.sumColumn2.visibility = View.GONE
b.tvSumLabel3.text = getString(R.string.chat_sum_payments)
b.tvSumValue3.text = ms.size.toString()
return
}
val received = ms.filter { !it.isSent }.sumOf { it.amount }
val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
b.sumColumn2.visibility = View.VISIBLE
b.tvSumLabel1.text = getString(R.string.chat_sum_sent, month)
b.tvSumValue1.text = money(sent)
b.tvSumValue1.setTextColor(MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorError))
b.tvSumLabel2.text = getString(R.string.chat_sum_received)
b.tvSumValue2.text = money(received)
b.tvSumValue2.setTextColor(android.graphics.Color.parseColor(if (night) "#81C784" else "#2E7D32"))
b.tvSumLabel3.text = getString(R.string.chat_sum_net)
val net = received - sent
b.tvSumValue3.text = if (hide) "••••" else (if (net < 0) "−" else "+") + "%,.2f".format(kotlin.math.abs(net))
}
/** Long-press on a bubble: send the same again, open its receipt, or copy its reference. */
private fun showMessageMenu(m: sh.sar.basedbank.util.ChatMessage, anchor: View) {
val t = thread ?: return
val menu = android.widget.PopupMenu(requireContext(), anchor, android.view.Gravity.END)
val amount = "%.2f".format(kotlin.math.abs(m.amount))
val canSendAgain = m.isSent && !t.isMerchant && selectedTo != null
if (canSendAgain) menu.menu.add(0, 1, 0, getString(R.string.chat_send_again, "${m.currency} $amount"))
if (m.receiptKey.isNotBlank()) menu.menu.add(0, 2, 1, R.string.chat_view_receipt_full)
if (m.reference.isNotBlank()) menu.menu.add(0, 3, 2, R.string.chat_copy_reference)
if (menu.menu.size() == 0) return
menu.setOnMenuItemClickListener { item ->
when (item.itemId) {
1 -> {
// Same account it went to, when that account is still one of theirs.
t.accounts.firstOrNull { it.account == m.peerAccount }?.let { selectTo(it, matchFromCurrency = true) }
binding.etAmount.setText(amount)
binding.etNote.setText(m.note)
send()
}
2 -> openReceipt(m.receiptKey)
3 -> {
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("reference", m.reference))
Snackbar.make(binding.root, R.string.chat_reference_copied, Snackbar.LENGTH_SHORT).show()
}
}
true
}
menu.show()
} }
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */ /** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
@@ -275,9 +515,16 @@ class ChatFragment : Fragment(), ContactSheetHost {
for (account in fromAccounts) { for (account in fromAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false) val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, account) bindAccountRow(row, account)
// MVR can't go into a USD account: show those accounts, but not selectable.
val blocked = isBlockedPair(account.currencyName, selectedTo?.currency.orEmpty())
if (blocked) {
row.tvDropdownAccountType.text = getString(R.string.chat_cant_send_mvr_to_usd)
row.tvDropdownAccountType.visibility = View.VISIBLE
row.root.alpha = 0.4f
}
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg) if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background) else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
row.root.setOnClickListener { if (!blocked) row.root.setOnClickListener {
selectFrom(account) selectFrom(account)
dialog.dismiss() dialog.dismiss()
} }
@@ -291,6 +538,7 @@ class ChatFragment : Fragment(), ContactSheetHost {
val t = thread ?: return val t = thread ?: return
val from = selectedFrom ?: return val from = selectedFrom ?: return
val to = selectedTo ?: return val to = selectedTo ?: return
if (isBlockedPair(from.currencyName, to.currency)) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
if (amount == null || amount <= 0.0) { if (amount == null || amount <= 0.0) {
binding.tilAmount.error = getString(R.string.chat_amount_invalid) binding.tilAmount.error = getString(R.string.chat_amount_invalid)
@@ -301,12 +549,39 @@ class ChatFragment : Fragment(), ContactSheetHost {
accountNumber = to.account, accountNumber = to.account,
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName, displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
amount = "%.2f".format(amount), amount = "%.2f".format(amount),
remarks = null, remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
fromAccountNumber = from.accountNumber, fromAccountNumber = from.accountNumber,
returnOnSuccess = true returnOnSuccess = true,
autoConfirm = true
) )
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet") payStartedAt = System.currentTimeMillis()
hideKeyboard()
if (isBusinessProfile(from)) {
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
} else {
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
// confirm dialog appears over the chat. A new send replaces any earlier one.
childFragmentManager.beginTransaction()
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
.commit()
}
}
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
private fun isBusinessProfile(account: BankAccount): Boolean {
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
}
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
private fun hideKeyboard() {
val b = _binding ?: return
val focused = b.root.findFocus() ?: return
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
?.hideSoftInputFromWindow(focused.windowToken, 0)
focused.clearFocus()
} }
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */ /** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
@@ -318,13 +593,23 @@ class ChatFragment : Fragment(), ContactSheetHost {
h.root.setOnClickListener { openContact() } h.root.setOnClickListener { openContact() }
} }
h.tvHeaderName.text = t.peerName h.tvHeaderName.text = t.peerName
// Show the real account-holder name when the chat is titled with a nickname. // Real name (when titled with a nickname), plus the account and currency when there's only one.
val subtitle = t.realName.takeIf { !it.equals(t.peerName, ignoreCase = true) }.orEmpty() val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
).joinToString(" · ")
h.tvHeaderAccount.text = subtitle h.tvHeaderAccount.text = subtitle
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
val sizePx = (40 * resources.displayMetrics.density).toInt() val sizePx = (40 * resources.displayMetrics.density).toInt()
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) } if (t.isMerchant) {
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx)) h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
} else {
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 requireActivity().title = t.peerName
bar.setDisplayShowTitleEnabled(false) bar.setDisplayShowTitleEnabled(false)
@@ -334,6 +619,7 @@ class ChatFragment : Fragment(), ContactSheetHost {
private fun openContact() { private fun openContact() {
val t = thread ?: return val t = thread ?: return
if (t.isMerchant) return
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) } val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
if (display != null) { if (display != null) {
if (childFragmentManager.findFragmentByTag("contact_details") != null) return if (childFragmentManager.findFragmentByTag("contact_details") != null) return
@@ -389,7 +675,9 @@ class ChatFragment : Fragment(), ContactSheetHost {
companion object { companion object {
private const val ARG_PEER_KEY = "peer_key" private const val ARG_PEER_KEY = "peer_key"
private const val HISTORY_CATCH_UP_MS = 10_000L private const val HISTORY_CATCH_UP_MS = 10_000L
private const val TAG_HIDDEN_TRANSFER = "transfer_hidden"
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN") private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
private val CARD_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
fun newInstance(peerKey: String) = ChatFragment().apply { fun newInstance(peerKey: String) = ChatFragment().apply {
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) } arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
@@ -2,33 +2,46 @@ package sh.sar.basedbank.ui.home
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.format.DateUtils import android.text.format.DateUtils
import android.text.style.StyleSpan
import android.view.Gravity import android.view.Gravity
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.LinearLayout import android.widget.LinearLayout
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBubbleBinding import sh.sar.basedbank.databinding.ItemChatBubbleBinding
import sh.sar.basedbank.databinding.ItemChatDateBinding import sh.sar.basedbank.databinding.ItemChatDateBinding
import sh.sar.basedbank.util.ChatMessage import sh.sar.basedbank.util.ChatMessage
/** Transfers with one person as chat bubbles: sent on the right, received on the left. */ /**
* One chat's transfers or payments as compact bubbles — sent / paid on the right, received on the
* left — with ✓ (seen in a BML alert) or ✓✓ (booked in history). Day separators carry the day's
* totals.
*/
class ChatMessagesAdapter( class ChatMessagesAdapter(
private val onReceiptClick: (ChatMessage) -> Unit private val onReceiptClick: (ChatMessage) -> Unit,
private val onLongPress: (ChatMessage, View) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { /** Rows of the list: a day separator or a bubble. */
data class DateChip(val label: String) : Item() sealed class Item {
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
data class Bubble(val message: ChatMessage) : Item() data class Bubble(val message: ChatMessage) : Item()
} }
private val items = mutableListOf<Item>() private val items = mutableListOf<Item>()
private var hideAmounts = false private var hideAmounts = false
private var todayLabel = ""
private var yesterdayLabel = ""
/** When the person has several accounts, sent bubbles say which one the money went to. */ /** When the person has several accounts, sent bubbles say which one the money went to. */
var showDestination = false var showDestination = false
@@ -41,20 +54,12 @@ class ChatMessagesAdapter(
/** [messages] must be oldest first. */ /** [messages] must be oldest first. */
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) { fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
this.todayLabel = todayLabel
this.yesterdayLabel = yesterdayLabel
items.clear() items.clear()
var lastDay = "" messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
for (m in messages) { items.add(Item.DateChip(day, dayMessages))
val day = m.date.take(10) dayMessages.forEach { items.add(Item.Bubble(it)) }
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() notifyDataSetChanged()
} }
@@ -74,87 +79,125 @@ class ChatMessagesAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) { when (val item = items[position]) {
is Item.DateChip -> (holder as DateVH).b.tvDate.text = item.label is Item.DateChip -> (holder as DateVH).bind(item)
is Item.Bubble -> (holder as BubbleVH).bind(item.message) is Item.Bubble -> (holder as BubbleVH).bind(item.message)
} }
} }
class DateVH(val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) private fun formatAmount(m: ChatMessage) =
if (hideAmounts) "${m.currency} ••••••" else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
private fun isNight(view: View) =
(view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
private fun ticksFor(m: ChatMessage) = if (m.isBooked) "✓✓" else "✓"
private fun tickColor(view: View, m: ChatMessage, fallback: Int) =
if (m.isBooked) Color.parseColor(if (isNight(view)) "#90CAF9" else "#1565C0") else fallback
inner class DateVH(private val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(item: Item.DateChip) {
val first = item.messages.first()
val label = when {
DateUtils.isToday(first.timeMillis) -> todayLabel
DateUtils.isToday(first.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
else -> AccountHistoryAdapter.formatDateHeader(first.date)
}
val text = SpannableStringBuilder(label)
text.setSpan(StyleSpan(Typeface.BOLD), 0, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (!hideAmounts) {
// Day totals per currency: ↑ sent, ↓ received.
val parts = item.messages.groupBy { it.currency }.map { (ccy, ms) ->
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
val received = ms.filter { !it.isSent }.sumOf { it.amount }
listOfNotNull(
"↑ %.2f".format(sent).takeIf { sent > 0 },
"↓ %.2f".format(received).takeIf { received > 0 }
).joinToString(" · ").let { if (item.messages.map { m -> m.currency }.toSet().size > 1) "$ccy $it" else it }
}.filter { it.isNotBlank() }
if (parts.isNotEmpty()) text.append(" ").append(parts.joinToString(" "))
}
b.tvDate.text = text
}
}
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) { inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(m: ChatMessage) { fun bind(m: ChatMessage) {
val ctx = b.root.context val ctx = b.root.context
val sent = m.isSent val sent = m.isSent
val night = isNight(b.root)
val density = ctx.resources.displayMetrics.density
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START 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.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 // Rounded, with a small "tail" corner on the sender's side at the bottom.
// read apart even on a red-tinted theme. val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val big = 18 * density
val tail = 4 * density
val tailOnRight = sent != rtl
b.cardBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCornerSize(big).setTopRightCornerSize(big)
.setBottomLeftCornerSize(if (tailOnRight) big else tail)
.setBottomRightCornerSize(if (tailOnRight) tail else big)
.build()
// Sent: tinted. Received: neutral with an outline, so the two read apart on any theme.
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer) val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
else Color.parseColor(if (night) "#2A2C2E" else "#F1F2F4") else Color.parseColor(if (night) "#2A2C2E" else "#FFFFFF")
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer) 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) else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
val green = Color.parseColor(if (night) "#81C784" else "#2E7D32")
b.cardBubble.setCardBackgroundColor(bg) b.cardBubble.setCardBackgroundColor(bg)
b.cardBubble.strokeWidth = if (sent) 0 else (1 * ctx.resources.displayMetrics.density).toInt() b.cardBubble.strokeWidth = if (sent) 0 else density.toInt()
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant) 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) b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
// Own account on its own line so long names aren't cut off by the receipt icon and tag. b.tvAmount.setTextColor(if (sent) fg else green)
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.text = m.note
b.tvNote.setTextColor(fg)
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
val time = AccountHistoryAdapter.formatTime(m.date) b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
b.tvMeta.text = if (sent) ctx.getString(R.string.chat_sent_meta, time) else time b.btnReceipt.setColorFilter(fg)
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
b.cardBubble.setOnClickListener { showDetail(m) } b.tvAccount.text = when {
} !sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
showDestination && m.peerAccount.isNotBlank() ->
private fun showDetail(m: ChatMessage) { ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
val ctx = b.root.context else -> m.accountDisplayName
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) b.tvAccount.setTextColor(fg)
.setTitle(m.peerName) b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
.setMessage(details) b.tvTime.setTextColor(fg)
.setPositiveButton("OK", null) b.tvTicks.text = ticksFor(m)
.show() b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
} }
} }
private fun showDetail(anchor: View, m: ChatMessage) {
val ctx = anchor.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}\n\n")
append("Status\n${ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)}")
}
MaterialAlertDialogBuilder(ctx)
.setTitle(m.peerName)
.setMessage(details)
.setPositiveButton("OK", null)
.show()
}
companion object { companion object {
private const val TYPE_DATE = 0 private const val TYPE_DATE = 0
private const val TYPE_BUBBLE = 1 private const val TYPE_BUBBLE = 1
@@ -1,26 +1,46 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBinding import sh.sar.basedbank.databinding.ItemChatBinding
import sh.sar.basedbank.util.ChatThread import sh.sar.basedbank.util.ChatThread
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
/** Telegram-style chat list: one row per person, latest transfer as the preview. */ /**
* Telegram-style chat list: one row per person or business, latest transfer as the preview.
* Pinned chats come first; long-press selects a chat (for pinning).
*/
class ChatsAdapter( class ChatsAdapter(
private val onChatClick: (ChatThread) -> Unit private val onChatClick: (ChatThread) -> Unit,
private val onChatLongPress: (ChatThread) -> Unit
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
private var allThreads: List<ChatThread> = emptyList() private var allThreads: List<ChatThread> = emptyList()
private var displayed: List<ChatThread> = emptyList() private var displayed: List<ChatThread> = emptyList()
private var pinned: List<String> = emptyList()
private var searchQuery = "" private var searchQuery = ""
private var hideAmounts = false private var hideAmounts = false
fun updateThreads(threads: List<ChatThread>) { /** Chat key shown as selected (long-pressed), or null. */
var selectedKey: String? = null
set(value) { field = value; notifyDataSetChanged() }
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
allThreads = threads allThreads = threads
pinned = pinnedKeys
applyFilter() applyFilter()
} }
@@ -35,15 +55,20 @@ class ChatsAdapter(
notifyDataSetChanged() notifyDataSetChanged()
} }
fun isPinned(key: String) = key in pinned
val isEmpty get() = displayed.isEmpty() val isEmpty get() = displayed.isEmpty()
private fun applyFilter() { private fun applyFilter() {
displayed = if (searchQuery.isBlank()) allThreads else allThreads.filter { val matching = if (searchQuery.isBlank()) allThreads else allThreads.filter {
it.peerName.contains(searchQuery, ignoreCase = true) || it.peerName.contains(searchQuery, ignoreCase = true) ||
it.realName.contains(searchQuery, ignoreCase = true) || it.realName.contains(searchQuery, ignoreCase = true) ||
it.peerAccount.contains(searchQuery) || it.peerAccount.contains(searchQuery) ||
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) } it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
} }
// Pinned first, most recently pinned on top; the rest stay newest first.
val (pinnedThreads, others) = matching.partition { it.peerKey in pinned }
displayed = pinnedThreads.sortedBy { pinned.indexOf(it.peerKey) } + others
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -53,6 +78,11 @@ class ChatsAdapter(
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos]) if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
} }
holder.binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
pos != RecyclerView.NO_POSITION
}
return holder return holder
} }
@@ -63,31 +93,80 @@ class ChatsAdapter(
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) { inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(thread: ChatThread) { fun bind(thread: ChatThread) {
val ctx = binding.root.context val ctx = binding.root.context
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val last = thread.last val last = thread.last
binding.tvName.text = thread.peerName binding.tvName.text = thread.peerName
val amount = if (hideAmounts) "${last.currency} ••••••" val amount = if (hideAmounts) "${last.currency} ••••••"
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}" else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
binding.tvPreview.text = if (last.isSent) { val ticks = if (last.isBooked) " ✓✓" else " ✓"
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty() binding.tvPreview.text = when {
ctx.getString(R.string.chat_preview_sent, amount) + note last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
} else { last.isSent -> {
ctx.getString(R.string.chat_preview_received, amount) val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
ctx.getString(R.string.chat_preview_sent, amount) + note + ticks
}
else -> ctx.getString(R.string.chat_preview_received, amount)
} }
binding.tvPreview.setTextColor(
if (!last.isSent) Color.parseColor(if (night) "#81C784" else "#2E7D32")
else MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
)
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date) binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
else AccountHistoryAdapter.formatDateOnly(last.date) else AccountHistoryAdapter.formatDateOnly(last.date)
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt() val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) } if (thread.isMerchant) {
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx)) // Businesses get a rounded-square shop icon; people keep round initials or a photo.
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(14 * ctx.resources.displayMetrics.density).build()
binding.ivAvatar.setImageBitmap(shopBitmap(binding.root, merchantColor(thread.peerKey), sizePx))
} else {
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(sizePx / 2f).build()
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
}
val isPinned = thread.peerKey in pinned
binding.ivPin.visibility = if (isPinned) View.VISIBLE else View.GONE
// Selected (long-pressed): tinted; pinned: a soft surface; otherwise the normal ripple.
when {
thread.peerKey == selectedKey -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorErrorContainer))
isPinned -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorSurfaceContainerHigh))
else -> binding.root.setBackgroundResource(selectableBackground(binding.root))
}
} }
} }
private fun selectableBackground(view: View): Int {
val out = android.util.TypedValue()
view.context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true)
return out.resourceId
}
companion object { companion object {
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65") private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
private val MERCHANT_COLORS = listOf("#6D4C41", "#546E7A", "#5D4037", "#455A64", "#8D6E63", "#37474F")
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */ /** 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] fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
fun merchantColor(peerKey: String) = MERCHANT_COLORS[(peerKey.hashCode() and 0x7fffffff) % MERCHANT_COLORS.size]
/** Shop icon on a coloured square; the image view's shape rounds the corners. */
fun shopBitmap(view: View, colorHex: String, sizePx: Int): Bitmap {
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
canvas.drawRect(RectF(0f, 0f, sizePx.toFloat(), sizePx.toFloat()),
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(colorHex) })
val icon = ContextCompat.getDrawable(view.context, R.drawable.ic_store)?.mutate() ?: return bm
val inset = sizePx / 4
icon.setBounds(inset, inset, sizePx - inset, sizePx - inset)
icon.draw(canvas)
return bm
}
} }
} }
@@ -1,30 +1,45 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.databinding.FragmentChatsBinding import sh.sar.basedbank.databinding.FragmentChatsBinding
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.ChatStore import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
/** Transfers grouped by person, shown as a chat list. Contacts is reachable from the toolbar. */ /**
* Transfers grouped by person or business, shown as a chat list. Contacts is reachable from the
* toolbar; long-press a chat to pin it to the top.
*/
class ChatsFragment : Fragment() { class ChatsFragment : Fragment() {
private var _binding: FragmentChatsBinding? = null private var _binding: FragmentChatsBinding? = null
@@ -33,6 +48,13 @@ class ChatsFragment : Fragment() {
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private lateinit var adapter: ChatsAdapter private lateinit var adapter: ChatsAdapter
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
onQrScanned(raw)
}
private var actionMode: ActionMode? = null
private var syncedThisView = false private var syncedThisView = false
private var syncedWithContacts = false private var syncedWithContacts = false
@@ -42,9 +64,13 @@ class ChatsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ChatsAdapter { thread -> adapter = ChatsAdapter(
(requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey)) onChatClick = { thread ->
} if (actionMode != null) actionMode?.finish()
else (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
},
onChatLongPress = { thread -> startSelection(thread) }
)
binding.rvChats.layoutManager = LinearLayoutManager(requireContext()) binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
binding.rvChats.adapter = adapter binding.rvChats.adapter = adapter
@@ -55,6 +81,31 @@ class ChatsFragment : Fragment() {
binding.swipeRefresh.setOnRefreshListener { sync() } binding.swipeRefresh.setOnRefreshListener { sync() }
// Scan to Pay: opens the QR scanner; after paying it comes back here and refreshes.
binding.fabScanPay.setOnClickListener {
scanLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
sync()
// BML's alert often lands a few seconds after the payment: check again shortly.
viewLifecycleOwner.lifecycleScope.launch {
delay(RECHECK_AFTER_PAYMENT_MS)
if (_binding != null) sync()
}
}
// Keep the button above the system navigation bar when there's no bottom bar.
val fabMargin = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabScanPay) { v, insets ->
val bottomNav = NavCustomization.getNavMode(
requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)
) == NavCustomization.NAV_MODE_BOTTOM
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
(v.layoutParams as android.widget.FrameLayout.LayoutParams).bottomMargin =
fabMargin + if (bottomNav) 0 else navBar
v.requestLayout()
insets
}
requireActivity().addMenuProvider(object : MenuProvider { requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts) menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
@@ -106,13 +157,80 @@ class ChatsFragment : Fragment() {
val ctx = requireContext().applicationContext val ctx = requireContext().applicationContext
val contacts = viewModel.contacts.value ?: emptyList() val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) } val (threads, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
if (_binding == null) return@launch if (_binding == null) return@launch
adapter.updateThreads(threads) adapter.updateThreads(threads, pins)
updateEmptyView() updateEmptyView()
} }
} }
/**
* A scanned QR: BML Scan to Pay (static or card-machine) opens the payment, a PayMV QR opens a
* transfer. Either way, success comes back to this list, which then refreshes.
*/
private fun onQrScanned(raw: String) {
val activity = requireActivity() as HomeActivity
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
activity.showWithBackStack(TransferFragment.newInstanceFromBmlQr(bmlTarget, null, returnOnSuccess = true))
return
}
val qr = PaymvQrParser.parse(raw)
val account = qr?.accountNumber
if (account == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return
}
activity.showWithBackStack(TransferFragment.newInstanceFromQr(
accountNumber = account,
displayName = qr.merchantName ?: account,
amount = qr.amount,
remarks = qr.purpose,
returnOnSuccess = true
))
}
/** Long-press: select the chat and offer Pin / Unpin in the top bar. */
private fun startSelection(thread: ChatThread) {
adapter.selectedKey = thread.peerKey
val pinned = adapter.isPinned(thread.peerKey)
val callback = object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = getString(R.string.chat_selected_one)
menu.add(Menu.NONE, R.id.action_pin_chat, 0, if (pinned) R.string.chat_unpin else R.string.chat_pin)
.setIcon(R.drawable.ic_pin)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (item.itemId != R.id.action_pin_chat) return false
setPinned(thread, !pinned, offerUndo = true)
mode.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
actionMode = null
adapter.selectedKey = null
}
}
actionMode?.finish()
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
}
private fun setPinned(thread: ChatThread, pin: Boolean, offerUndo: Boolean) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.setPinned(ctx, thread.peerKey, pin) }
loadThreads()
val b = _binding ?: return@launch
if (!offerUndo) return@launch
Snackbar.make(b.root, getString(if (pin) R.string.chat_pinned_msg else R.string.chat_unpinned_msg, thread.peerName), Snackbar.LENGTH_LONG)
.setAction(R.string.chat_undo) { setPinned(thread, !pin, offerUndo = false) }
.show()
}
}
private fun updateEmptyView() { private fun updateEmptyView() {
val b = _binding ?: return val b = _binding ?: return
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
@@ -132,7 +250,12 @@ class ChatsFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
actionMode?.finish()
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
companion object {
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
}
} }
@@ -246,6 +246,7 @@ class TransferFragment : Fragment() {
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" private const val ARG_RETURN_ON_SUCCESS = "return_on_success"
private const val ARG_AUTO_CONFIRM = "auto_confirm"
/** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */ /** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */
const val RESULT_TRANSFER_DONE = "transfer_done" const val RESULT_TRANSFER_DONE = "transfer_done"
@@ -254,10 +255,11 @@ class TransferFragment : Fragment() {
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) } arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
} }
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply { fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null, returnOnSuccess: Boolean = false) = TransferFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_BML_QR_URL, qrUrl) putString(ARG_BML_QR_URL, qrUrl)
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber) if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
} }
} }
@@ -287,7 +289,8 @@ class TransferFragment : Fragment() {
amount: String?, amount: String?,
remarks: String?, remarks: String?,
fromAccountNumber: String? = null, fromAccountNumber: String? = null,
returnOnSuccess: Boolean = false returnOnSuccess: Boolean = false,
autoConfirm: Boolean = false
) = TransferFragment().apply { ) = TransferFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber) putString(ARG_ACCOUNT, accountNumber)
@@ -298,6 +301,7 @@ class TransferFragment : Fragment() {
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) if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
if (autoConfirm) putBoolean(ARG_AUTO_CONFIRM, true)
} }
} }
} }
@@ -308,17 +312,23 @@ class TransferFragment : Fragment() {
* the receipt is already in ReceiptStore. * the receipt is already in ReceiptStore.
*/ */
private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) { private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) {
val activity = requireActivity() as HomeActivity if (!returnToCallerIfRequested()) {
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) == true) { (requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
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))
} }
} }
/**
* When opened with returnOnSuccess, tells the caller the payment went through and goes back to
* it — closing the sheet this page is hosted in, if any. Returns false when not requested.
*/
internal fun returnToCallerIfRequested(): Boolean {
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) != true) return false
requireActivity().supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
val sheet = parentFragment as? androidx.fragment.app.DialogFragment
if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack()
return true
}
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
@@ -401,6 +411,16 @@ class TransferFragment : Fragment() {
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) } arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
// Opened fully filled in (e.g. from a chat): go straight to the confirm dialog, once. Posted
// so the source account, picked when the accounts arrive, is selected first.
if (arguments?.getBoolean(ARG_AUTO_CONFIRM) == true) {
view.post {
if (_binding == null || arguments?.getBoolean(ARG_AUTO_CONFIRM) != true) return@post
arguments?.remove(ARG_AUTO_CONFIRM)
if (selectedAccount != null && resolvedAccountNumber.isNotBlank()) initiateTransfer()
}
}
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) } arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
@@ -38,9 +38,11 @@ class TransferSheetFragment : BottomSheetDialogFragment() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
(dialog as? BottomSheetDialog)?.let { d -> (dialog as? BottomSheetDialog)?.let { d ->
// Keep the amount/remarks fields above the keyboard. // Keep the amount/remarks fields above the keyboard, but don't open it by itself.
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
d.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) d.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
d.behavior.skipCollapsed = true d.behavior.skipCollapsed = true
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
} }
@@ -351,6 +351,7 @@ class BmlTransferHandler(
) { ) {
fragment.clearForm() fragment.clearForm()
host?.triggerRefresh() host?.triggerRefresh()
fragment.returnToCallerIfRequested()
} }
} else { } else {
dialog.dismiss() dialog.dismiss()
@@ -32,9 +32,12 @@ data class ChatMessage(
val note: String = "", // remarks — only known for transfers sent from this app val note: String = "", // remarks — only known for transfers sent from this app
val peerAccount: String = "", // 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 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 dateOnly: Boolean = false, // bank gave only a date (Favara); time of day unknown
val merchant: Boolean = false // card / Scan to Pay payment to a business, not a person
) { ) {
val isSent get() = amount < 0 val isSent get() = amount < 0
/** In BML's account history (✓✓); otherwise only seen in a notification or receipt so far (✓). */
val isBooked get() = id.startsWith("bml_")
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */ /** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
val date: String get() = DATE_FMT.format(Date(timeMillis)) val date: String get() = DATE_FMT.format(Date(timeMillis))
@@ -51,11 +54,12 @@ data class ChatAccount(
) )
data class ChatThread( data class ChatThread(
val peerKey: String, // "name:<normalised real name>" val peerKey: String, // "name:<normalised real name>" or "shop:<normalised business name>"
val peerName: String, // contact nickname, else the 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 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 accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
val messages: List<ChatMessage> // oldest first val messages: List<ChatMessage>, // oldest first
val isMerchant: Boolean = false // a business paid by card / Scan to Pay; nothing to send to
) { ) {
val last get() = messages.last() val last get() = messages.last()
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact } val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
@@ -67,7 +71,7 @@ data class ChatThread(
} }
/** /**
* Encrypted local record of BML transfers, shown as chats. * Encrypted local record of BML transfers and card / Scan to Pay payments, shown as chats.
* *
* Old history is never loaded: the first call to [sync] only stamps the start time, and every * 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), * later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
@@ -77,16 +81,24 @@ data class ChatThread(
object ChatStore { object ChatStore {
private const val FILE_NAME = "chats.json" private const val FILE_NAME = "chats.json"
private const val PINS_FILE_NAME = "chat_pins.json"
private const val QR_FILE_NAME = "chat_merchant_qr.json"
/** Prefix [RecentsCache] uses for static BML merchant QRs the user has paid. */
private const val RECENT_QR_PREFIX = "bmlqr:"
private val lock = Any() private val lock = Any()
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN") 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") private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
/** Card and Scan to Pay payments; narrative2 is the business name. */
private val MERCHANT_TYPES = setOf("Purchase")
/** "You have received MVR 5.00 from NAME to 7730*****1234" */ /** "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+)""") 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" */ /** "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 (.+)""") 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". */ /** "You have paid MVR 5.00 from 4xxx********xxxx to BUSINESS" (Scan to Pay / card); the name may be cut short. */
private val NOTIF_PAID = Regex("""You have paid ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
/** Masked own account or card in notifications, e.g. "7730*****1234". */
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""") private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
/** How far apart two records of the same transfer can be timestamped. */ /** How far apart two records of the same transfer can be timestamped. */
@@ -96,6 +108,7 @@ object ChatStore {
private const val MAX_PAGES_PER_SYNC = 5 private const val MAX_PAGES_PER_SYNC = 5
private const val NAME_KEY_PREFIX = "name:" private const val NAME_KEY_PREFIX = "name:"
private const val MERCHANT_KEY_PREFIX = "shop:"
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>) private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
@@ -125,7 +138,14 @@ object ChatStore {
lookedUp[account]?.name?.takeIf { it.isNotBlank() } lookedUp[account]?.name?.takeIf { it.isNotBlank() }
?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() } ?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() }
// Notifications cut business names short ("FAMILY ROOM" for "FAMILY ROOM COFFEE"); file a
// short name under the longest known name it starts.
val merchantNames = messages.filter { it.merchant }.map { it.peerKey }.toSet()
fun merchantKey(name: String) =
merchantNames.filter { it.startsWith(name) }.maxByOrNull { it.length } ?: name
fun keyOf(m: ChatMessage): String { fun keyOf(m: ChatMessage): String {
if (m.merchant) return MERCHANT_KEY_PREFIX + merchantKey(m.peerKey)
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
return NAME_KEY_PREFIX + normalise(name) return NAME_KEY_PREFIX + normalise(name)
} }
@@ -145,6 +165,12 @@ object ChatStore {
return messages.groupBy(::keyOf).map { (key, msgs) -> return messages.groupBy(::keyOf).map { (key, msgs) ->
val sorted = msgs.sortedBy { it.timeMillis } val sorted = msgs.sortedBy { it.timeMillis }
if (key.startsWith(MERCHANT_KEY_PREFIX)) {
// History carries the full business name; notifications may not.
val name = sorted.lastOrNull { it.id.startsWith("bml_") }?.peerName
?: sorted.maxByOrNull { it.peerName.length }!!.peerName
return@map ChatThread(key, name, "", emptyList(), sorted, isMerchant = true)
}
val accounts = accountsByKey[key]?.values?.toList().orEmpty() val accounts = accountsByKey[key]?.values?.toList().orEmpty()
// Bank history and the account lookup carry the real name; a receipt only has our label. // 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() } } val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
@@ -221,7 +247,8 @@ object ChatStore {
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? { private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
val name = counterpartyName?.trim().orEmpty() val name = counterpartyName?.trim().orEmpty()
if (description !in TRANSFER_TYPES || name.isBlank()) return null val merchant = description in MERCHANT_TYPES
if ((description !in TRANSFER_TYPES && !merchant) || name.isBlank()) return null
// Favara (other-bank) entries carry only a date; keep those from the start day onwards. // Favara (other-bank) entries carry only a date; keep those from the start day onwards.
val dateOnly = date.contains("T00:00:00") val dateOnly = date.contains("T00:00:00")
if (if (dateOnly) time < startOfDay(since) else time < since) return null if (if (dateOnly) time < startOfDay(since) else time < since) return null
@@ -235,7 +262,8 @@ object ChatStore {
accountNumber = accountNumber, accountNumber = accountNumber,
accountDisplayName = accountDisplayName, accountDisplayName = accountDisplayName,
reference = reference.orEmpty(), reference = reference.orEmpty(),
dateOnly = dateOnly dateOnly = dateOnly,
merchant = merchant
) )
} }
@@ -266,9 +294,11 @@ object ChatStore {
val text = n.message.trim() val text = n.message.trim()
val received = NOTIF_RECEIVED.matchEntire(text) val received = NOTIF_RECEIVED.matchEntire(text)
val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null
val paid = if (received == null && sent == null) NOTIF_PAID.matchEntire(text) else null
val (currency, amountText, name, ownMasked) = when { val (currency, amountText, name, ownMasked) = when {
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) } 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) } sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
paid != null -> paid.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
else -> return null else -> return null
} }
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
@@ -285,7 +315,8 @@ object ChatStore {
currency = currency, currency = currency,
timeMillis = n.timestampMs, timeMillis = n.timestampMs,
accountNumber = own?.accountNumber.orEmpty(), accountNumber = own?.accountNumber.orEmpty(),
accountDisplayName = own?.accountBriefName ?: ownMasked accountDisplayName = own?.accountBriefName ?: "•••• ${ownMasked.takeLast(4)}",
merchant = paid != null
) )
} }
@@ -340,7 +371,8 @@ object ChatStore {
reference = k.reference.ifBlank { candidate.reference }, reference = k.reference.ifBlank { candidate.reference },
note = k.note.ifBlank { candidate.note }, note = k.note.ifBlank { candidate.note },
peerAccount = k.peerAccount.ifBlank { candidate.peerAccount }, peerAccount = k.peerAccount.ifBlank { candidate.peerAccount },
receiptKey = k.receiptKey.ifBlank { candidate.receiptKey } receiptKey = k.receiptKey.ifBlank { candidate.receiptKey },
merchant = k.merchant || candidate.merchant
) )
absorbed.getOrPut(k.id) { mutableSetOf() } += r absorbed.getOrPut(k.id) { mutableSetOf() } += r
} else { } else {
@@ -359,12 +391,14 @@ object ChatStore {
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean { private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
if (a.reference.isNotBlank() && a.reference == b.reference) return true 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.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
// A payment to a business is never the same as a transfer to a person (receipts aren't tagged).
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
if (!receipt && a.merchant != b.merchant) return false
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) 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.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
if (a.dateOnly || b.dateOnly) { if (a.dateOnly || b.dateOnly) {
// A date-only entry could be any transfer that day, so the names must agree too. // 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 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 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 return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
@@ -375,7 +409,63 @@ object ChatStore {
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis }.timeInMillis
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() } fun clearAll(context: Context) = synchronized(lock) {
File(context.filesDir, FILE_NAME).delete()
File(context.filesDir, PINS_FILE_NAME).delete()
File(context.filesDir, QR_FILE_NAME).delete()
}
// ─── Saved merchant QRs ───────────────────────────────────────────────────
/**
* The static QR (no preset amount) last paid at the business named [merchantName], or null.
* The app already remembers such QRs among its recents, but only the last few; they are
* copied here so a business's QR is kept for good. Call off the main thread.
*/
fun merchantQr(context: Context, merchantName: String): String? = synchronized(lock) {
val file = File(context.filesDir, QR_FILE_NAME)
val saved = try {
if (!file.exists()) JSONObject() else JSONObject(CacheEncryption.decrypt(file.readText()))
} catch (_: Exception) { JSONObject() }
var changed = false
for (recent in RecentsCache.load(context)) {
if (!recent.accountNumber.startsWith(RECENT_QR_PREFIX)) continue
val key = normalise(recent.displayName)
val url = recent.accountNumber.removePrefix(RECENT_QR_PREFIX)
if (key.isNotBlank() && saved.optString(key) != url) { saved.put(key, url); changed = true }
}
if (changed) try { file.writeText(CacheEncryption.encrypt(saved.toString())) } catch (_: Exception) {}
// Names differ slightly between the QR, history and alerts; accept one starting the other.
val name = normalise(merchantName)
val keys = saved.keys().asSequence().toList()
val match = keys.firstOrNull { it == name }
?: keys.filter { it.startsWith(name) || name.startsWith(it) }.maxByOrNull { it.length }
match?.let { saved.optString(it).takeIf { url -> url.isNotBlank() } }
}
// ─── Pinned chats ─────────────────────────────────────────────────────────
/** Pinned chat keys, most recently pinned first. */
fun pins(context: Context): List<String> = synchronized(lock) {
val file = File(context.filesDir, PINS_FILE_NAME)
if (!file.exists()) return emptyList()
try {
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun setPinned(context: Context, peerKey: String, pinned: Boolean) {
val current = pins(context).filter { it != peerKey }
val updated = if (pinned) listOf(peerKey) + current else current
synchronized(lock) {
try {
File(context.filesDir, PINS_FILE_NAME)
.writeText(CacheEncryption.encrypt(JSONArray(updated).toString()))
} catch (_: Exception) {}
}
}
private fun load(context: Context): State { private fun load(context: Context): State {
val file = File(context.filesDir, FILE_NAME) val file = File(context.filesDir, FILE_NAME)
@@ -400,7 +490,8 @@ object ChatStore {
note = o.optString("note"), note = o.optString("note"),
peerAccount = o.optString("peerAccount"), peerAccount = o.optString("peerAccount"),
receiptKey = o.optString("receiptKey"), receiptKey = o.optString("receiptKey"),
dateOnly = o.optBoolean("dateOnly", false) dateOnly = o.optBoolean("dateOnly", false),
merchant = o.optBoolean("merchant", false)
) )
} }
) )
@@ -424,6 +515,7 @@ object ChatStore {
put("peerAccount", m.peerAccount) put("peerAccount", m.peerAccount)
put("receiptKey", m.receiptKey) put("receiptKey", m.receiptKey)
put("dateOnly", m.dateOnly) put("dateOnly", m.dateOnly)
put("merchant", m.merchant)
}) })
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr) val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString())) File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="rectangle">
<corners android:radius="8dp" /> <corners android:radius="10dp" />
<solid android:color="?attr/colorSurfaceContainerHigh" /> <stroke android:width="1dp" android:color="?attr/colorOutline" />
<stroke android:width="1dp" android:color="?attr/colorOutlineVariant" />
</shape> </shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="14dp" />
<solid android:color="?attr/colorSurfaceContainerHigh" />
</shape>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
</vector>
+12
View File
@@ -0,0 +1,12 @@
<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="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineJoin="round"
android:pathData="M4,9l1.5,-5h13L20,9M4,9v11h16V9M4,9h16M9,20v-6h6v6" />
</vector>
+150 -47
View File
@@ -10,7 +10,8 @@
android:id="@+id/toAccountBar" android:id="@+id/toAccountBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground" android:background="?attr/colorSecondaryContainer"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
@@ -52,7 +53,7 @@
android:id="@+id/tvToCurrency" android:id="@+id/tvToCurrency"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/bg_chat_date" android:background="@drawable/bg_chat_chip"
android:paddingHorizontal="10dp" android:paddingHorizontal="10dp"
android:paddingVertical="3dp" android:paddingVertical="3dp"
android:textAppearance="?attr/textAppearanceLabelLarge" android:textAppearance="?attr/textAppearanceLabelLarge"
@@ -101,6 +102,88 @@
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/summaryCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_chat_summary"
android:orientation="horizontal"
android:paddingHorizontal="14dp"
android:paddingVertical="10dp"
android:visibility="gone">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages" android:id="@+id/rvMessages"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -135,76 +218,78 @@
android:paddingBottom="8dp"> android:paddingBottom="8dp">
<TextView <TextView
android:id="@+id/tvCurrencyWarning"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="4dp" android:layout_marginBottom="4dp"
android:layout_marginStart="4dp" android:textAppearance="?attr/textAppearanceBodySmall"
android:text="@string/chat_from_account" android:textColor="?attr/colorError"
android:textAppearance="?attr/textAppearanceLabelMedium" android:visibility="gone" />
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.card.MaterialCardView <com.google.android.material.button.MaterialButton
android:id="@+id/cardFromAccount" android:id="@+id/btnFrom"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:gravity="start|center_vertical"
android:focusable="true" android:ellipsize="middle"
app:cardCornerRadius="12dp" android:maxLines="1"
app:cardElevation="0dp" android:textColor="?attr/colorOnSecondaryContainer"
app:strokeWidth="1dp" app:icon="@drawable/ic_arrow_right"
app:strokeColor="?attr/colorOutline"> app:iconGravity="end"
app:iconSize="16dp"
<LinearLayout app:iconTint="?attr/colorOnSecondaryContainer" />
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 <LinearLayout
android:id="@+id/amountRow"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="4dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmount" android:id="@+id/tilAmount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1.2"
android:hint="@string/chat_amount" android:hint="@string/chat_amount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox" app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusTopStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusBottomStart="28dp" app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusBottomEnd="28dp"> app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAmount" android:id="@+id/etAmount"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="numberDecimal" android:inputType="numberDecimal"
android:maxLines="1" android:maxLines="1" />
android:imeOptions="actionSend" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilNote"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="@string/chat_note"
app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionSend"
android:inputType="textCapSentences"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
@@ -224,4 +309,22 @@
</LinearLayout> </LinearLayout>
<!-- Hosts the Transfer page off-screen when only its confirm dialog is wanted. -->
<FrameLayout
android:id="@+id/hiddenTransferHost"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPayAgain"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="12dp"
android:text="@string/chat_pay_again"
android:visibility="gone"
app:cornerRadius="28dp" />
</LinearLayout> </LinearLayout>
+16 -1
View File
@@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:orientation="vertical">
@@ -64,3 +68,14 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout> </LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabScanPay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_margin="16dp"
android:contentDescription="@string/chat_scan_to_pay"
app:srcCompat="@drawable/ic_qr_scan" />
</FrameLayout>
+13 -1
View File
@@ -55,8 +55,20 @@
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivAvatar" app:layout_constraintStart_toEndOf="@id/ivAvatar"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toStartOf="@id/ivPin"
app:layout_constraintTop_toBottomOf="@id/tvName" app:layout_constraintTop_toBottomOf="@id/tvName"
app:layout_constraintBottom_toBottomOf="parent" /> app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/ivPin"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/chat_pinned"
android:src="@drawable/ic_pin"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tvPreview"
app:layout_constraintBottom_toBottomOf="@id/tvPreview" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
+59 -50
View File
@@ -6,23 +6,24 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingVertical="4dp"> android:paddingVertical="3dp">
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/cardBubble" android:id="@+id/cardBubble"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minWidth="200dp"
app:cardCornerRadius="18dp"
app:cardElevation="0dp" app:cardElevation="0dp"
app:strokeWidth="0dp"> app:strokeWidth="0dp">
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minWidth="200dp" android:minWidth="150dp"
android:orientation="vertical" android:orientation="vertical"
android:padding="14dp"> android:paddingStart="14dp"
android:paddingTop="10dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -31,70 +32,78 @@
android:orientation="horizontal"> android:orientation="horizontal">
<TextView <TextView
android:id="@+id/tvLabel" android:id="@+id/tvAmount"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:maxWidth="240dp"
android:layout_marginEnd="12dp" android:textAppearance="?attr/textAppearanceTitleLarge"
android:textAppearance="?attr/textAppearanceBodySmall" android:textStyle="bold" />
android:maxLines="1"
android:ellipsize="end" /> <Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton <ImageButton
android:id="@+id/btnReceipt" android:id="@+id/btnReceipt"
android:layout_width="32dp" android:layout_width="40dp"
android:layout_height="32dp" android:layout_height="40dp"
android:layout_marginEnd="6dp" android:layout_marginStart="12dp"
android:padding="6dp" android:layout_marginEnd="-8dp"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/chat_view_receipt" android:contentDescription="@string/chat_view_receipt"
android:padding="8dp"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:src="@drawable/ic_receipt" android:src="@drawable/ic_receipt"
android:visibility="gone" /> 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> </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 <TextView
android:id="@+id/tvNote" android:id="@+id/tvNote"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodyMedium" /> android:maxWidth="240dp"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="150dp"
android:layout_marginEnd="8dp"
android:alpha="0.8"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.8"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTicks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </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> </LinearLayout>
+1 -2
View File
@@ -10,10 +10,9 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:background="@drawable/bg_chat_date"
android:paddingHorizontal="12dp" android:paddingHorizontal="12dp"
android:paddingVertical="4dp" android:paddingVertical="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium" android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" /> android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout> </FrameLayout>
+1
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<item name="action_open_contacts" type="id" /> <item name="action_open_contacts" type="id" />
<item name="action_pin_chat" type="id" />
</resources> </resources>
+33 -9
View File
@@ -410,28 +410,52 @@
<string name="chats_no_results">No chats found</string> <string name="chats_no_results">No chats found</string>
<string name="chat_preview_sent">You sent %s</string> <string name="chat_preview_sent">You sent %s</string>
<string name="chat_preview_received">Received %s</string> <string name="chat_preview_received">Received %s</string>
<string name="chat_from_label">From %s</string> <string name="chat_preview_paid">Paid %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_today">Today</string>
<string name="chat_yesterday">Yesterday</string> <string name="chat_yesterday">Yesterday</string>
<string name="chat_from_account">From account</string> <string name="chat_from_account">From account</string>
<string name="chat_pick_account">Send from</string> <string name="chat_pick_account">Send from</string>
<string name="chat_pick_card">Pay with</string>
<string name="chat_from_chip">%1$s · %2$s · %3$s</string>
<string name="chat_currency_warning">⚠ Sending USD to an MVR account. It will be converted at BML\'s rate and can\'t be reversed.</string>
<string name="chat_currency_blocked">MVR can\'t be sent to a USD account. Pick a USD account to send from.</string>
<string name="chat_cant_send_mvr_to_usd">Can\'t send MVR to a USD account</string>
<string name="chat_needs_usd_source">Needs a USD account to send from</string>
<string name="chat_transfer_sent">Transfer sent</string> <string name="chat_transfer_sent">Transfer sent</string>
<string name="chat_payment_sent">Payment sent</string>
<string name="chat_view_receipt">Receipt</string> <string name="chat_view_receipt">Receipt</string>
<string name="chat_not_in_contacts">Not in your contacts</string> <string name="chat_not_in_contacts">Not in your contacts</string>
<string name="chat_save_contact">Save contact</string> <string name="chat_save_contact">Save contact</string>
<string name="chat_send">Send</string> <string name="chat_send">Send</string>
<string name="chat_to">To</string> <string name="chat_to">To</string>
<string name="chat_pick_recipient_account">Send 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_receipt_missing">Receipt no longer available</string>
<string name="chat_amount">Amount</string> <string name="chat_amount">Amount</string>
<string name="chat_amount_invalid">Enter an 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> <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>
<string name="chat_into_short">into %s</string>
<string name="chat_account_to">%1$s → %2$s</string>
<string name="chat_tick_seen">Seen in BML alert</string>
<string name="chat_tick_booked">Booked in BML history</string>
<string name="chat_note">Note</string>
<string name="chat_pay_again">Pay again with Scan to Pay</string>
<string name="chat_pay_business">Pay %s</string>
<string name="chat_scan_to_pay">Scan to Pay</string>
<string name="chat_merchant_subtitle">Business · Scan to Pay &amp; card</string>
<string name="chat_sum_sent">%s · sent</string>
<string name="chat_sum_received">received</string>
<string name="chat_sum_net">net</string>
<string name="chat_sum_spent">Spent here in %s</string>
<string name="chat_sum_payments">payments</string>
<string name="chat_send_again">Send again · %s</string>
<string name="chat_view_receipt_full">View receipt</string>
<string name="chat_copy_reference">Copy reference</string>
<string name="chat_reference_copied">Reference copied</string>
<string name="chat_pinned">Pinned</string>
<string name="chat_pin">Pin</string>
<string name="chat_unpin">Unpin</string>
<string name="chat_selected_one">1 selected</string>
<string name="chat_pinned_msg">%s pinned</string>
<string name="chat_unpinned_msg">%s unpinned</string>
<string name="chat_undo">Undo</string>
</resources> </resources>