forked from thijooree/android
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:
@@ -26,6 +26,7 @@ import sh.sar.basedbank.databinding.FragmentChatBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactListParser
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
@@ -35,9 +36,12 @@ import sh.sar.basedbank.util.ChatStore
|
||||
import sh.sar.basedbank.util.ChatThread
|
||||
|
||||
/**
|
||||
* One person's transfers as a chat. A person can have several accounts (e.g. MVR and USD); the
|
||||
* "To" bar picks which one to send to. Sending opens [TransferFragment] in a sheet, prefilled with
|
||||
* the recipient, source account and amount, so the usual confirm/OTP flow still applies.
|
||||
* One person's transfers as a chat, or one business's card / Scan to Pay payments.
|
||||
*
|
||||
* 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 {
|
||||
|
||||
@@ -48,11 +52,19 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
|
||||
private var peerKey = ""
|
||||
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 fromAccounts: List<BankAccount> = emptyList()
|
||||
private var selectedFrom: BankAccount? = 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 {
|
||||
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||
@@ -67,21 +79,26 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||
adapter.setHideAmounts(it)
|
||||
selectedFrom?.let { acc -> bindAccountRow(binding.fromAccountRow, acc) }
|
||||
thread?.let { t -> bindSummary(t) }
|
||||
bindFromChip()
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
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()
|
||||
}
|
||||
|
||||
binding.cardFromAccount.setOnClickListener { showAccountPicker() }
|
||||
binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
|
||||
binding.btnSaveContact.setOnClickListener { openContact() }
|
||||
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
|
||||
binding.btnSend.setOnClickListener { send() }
|
||||
binding.etAmount.setOnEditorActionListener { _, actionId, _ ->
|
||||
binding.etNote.setOnEditorActionListener { _, actionId, _ ->
|
||||
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.
|
||||
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||
@@ -112,13 +129,14 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
adapter.showDestination = t.accounts.size > 1
|
||||
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
|
||||
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
|
||||
updateToolbar()
|
||||
bindSummary(t)
|
||||
|
||||
bindFromAccounts()
|
||||
val previous = selectedTo
|
||||
val to = t.accounts.firstOrNull { it.account == previous?.account }
|
||||
?: t.accounts.firstOrNull { it.account == t.peerAccount }
|
||||
selectTo(to, matchFromCurrency = previous == null)
|
||||
updateToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +146,19 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
val t = thread ?: return
|
||||
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) {
|
||||
b.tvToAccount.text = to.account
|
||||
b.tvToCurrency.text = to.currency
|
||||
@@ -151,6 +181,7 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
|
||||
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
|
||||
}
|
||||
updateCurrencyWarning()
|
||||
}
|
||||
|
||||
private fun showRecipientPicker() {
|
||||
@@ -171,12 +202,17 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
?: getString(R.string.chat_not_in_contacts)
|
||||
row.tvDropdownAccountNumber.text = account.account
|
||||
row.tvDropdownBalance.text = account.currency
|
||||
row.tvDropdownAccountType.visibility = View.GONE
|
||||
row.ivDropdownCardLogo.visibility = View.GONE
|
||||
// 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)
|
||||
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||
row.root.setOnClickListener {
|
||||
if (!unavailable) row.root.setOnClickListener {
|
||||
selectTo(account, matchFromCurrency = true)
|
||||
updateToolbar()
|
||||
dialog.dismiss()
|
||||
}
|
||||
list.addView(row.root)
|
||||
@@ -204,7 +240,11 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
}
|
||||
|
||||
private fun onTransferDone() {
|
||||
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
|
||||
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
|
||||
}
|
||||
binding.etAmount.text = null
|
||||
binding.etNote.text = null
|
||||
// The receipt shows the new bubble right away; history catches up a little later and
|
||||
// replaces it with the bank's own entry.
|
||||
sync()
|
||||
@@ -212,12 +252,12 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
delay(HISTORY_CATCH_UP_MS)
|
||||
sync()
|
||||
}
|
||||
Snackbar.make(binding.root, R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
|
||||
.setAnchorView(binding.composer)
|
||||
.setAction(R.string.chat_view_receipt) {
|
||||
val latest = ReceiptStore.loadAll(requireContext()).firstOrNull() ?: return@setAction
|
||||
openReceipt(latest.savedAt.toString())
|
||||
}
|
||||
// Only offer the receipt this payment made; QR payments don't save one.
|
||||
val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
|
||||
val merchant = thread?.isMerchant == true
|
||||
Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -243,8 +283,208 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
private fun selectFrom(account: BankAccount) {
|
||||
val b = _binding ?: return
|
||||
selectedFrom = account
|
||||
bindAccountRow(b.fromAccountRow, account)
|
||||
bindFromChip()
|
||||
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). */
|
||||
@@ -275,9 +515,16 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
for (account in fromAccounts) {
|
||||
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||
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)
|
||||
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||
row.root.setOnClickListener {
|
||||
if (!blocked) row.root.setOnClickListener {
|
||||
selectFrom(account)
|
||||
dialog.dismiss()
|
||||
}
|
||||
@@ -291,6 +538,7 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
val t = thread ?: return
|
||||
val from = selectedFrom ?: return
|
||||
val to = selectedTo ?: return
|
||||
if (isBlockedPair(from.currencyName, to.currency)) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0.0) {
|
||||
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
|
||||
@@ -301,12 +549,39 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
accountNumber = to.account,
|
||||
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
|
||||
amount = "%.2f".format(amount),
|
||||
remarks = null,
|
||||
remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
|
||||
fromAccountNumber = from.accountNumber,
|
||||
returnOnSuccess = true
|
||||
returnOnSuccess = true,
|
||||
autoConfirm = true
|
||||
)
|
||||
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||
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. */
|
||||
@@ -318,13 +593,23 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
h.root.setOnClickListener { openContact() }
|
||||
}
|
||||
h.tvHeaderName.text = t.peerName
|
||||
// Show the real account-holder name when the chat is titled with a nickname.
|
||||
val subtitle = t.realName.takeIf { !it.equals(t.peerName, ignoreCase = true) }.orEmpty()
|
||||
// Real name (when titled with a nickname), plus the account and currency when there's only one.
|
||||
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.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
|
||||
val sizePx = (40 * resources.displayMetrics.density).toInt()
|
||||
if (t.isMerchant) {
|
||||
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
|
||||
bar.setDisplayShowTitleEnabled(false)
|
||||
@@ -334,6 +619,7 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
|
||||
private fun openContact() {
|
||||
val t = thread ?: return
|
||||
if (t.isMerchant) return
|
||||
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
|
||||
if (display != null) {
|
||||
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||
@@ -389,7 +675,9 @@ class ChatFragment : Fragment(), ContactSheetHost {
|
||||
companion object {
|
||||
private const val ARG_PEER_KEY = "peer_key"
|
||||
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_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
|
||||
|
||||
fun newInstance(peerKey: String) = ChatFragment().apply {
|
||||
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.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.style.StyleSpan
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
|
||||
import sh.sar.basedbank.databinding.ItemChatDateBinding
|
||||
import sh.sar.basedbank.util.ChatMessage
|
||||
|
||||
/** Transfers with one person as chat bubbles: sent on the right, received on the left. */
|
||||
/**
|
||||
* 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(
|
||||
private val onReceiptClick: (ChatMessage) -> Unit
|
||||
private val onReceiptClick: (ChatMessage) -> Unit,
|
||||
private val onLongPress: (ChatMessage, View) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateChip(val label: String) : Item()
|
||||
/** Rows of the list: a day separator or a bubble. */
|
||||
sealed class Item {
|
||||
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
|
||||
data class Bubble(val message: ChatMessage) : Item()
|
||||
}
|
||||
|
||||
private val items = mutableListOf<Item>()
|
||||
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. */
|
||||
var showDestination = false
|
||||
@@ -41,20 +54,12 @@ class ChatMessagesAdapter(
|
||||
|
||||
/** [messages] must be oldest first. */
|
||||
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
|
||||
this.todayLabel = todayLabel
|
||||
this.yesterdayLabel = yesterdayLabel
|
||||
items.clear()
|
||||
var lastDay = ""
|
||||
for (m in messages) {
|
||||
val day = m.date.take(10)
|
||||
if (day != lastDay) {
|
||||
val label = when {
|
||||
DateUtils.isToday(m.timeMillis) -> todayLabel
|
||||
DateUtils.isToday(m.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
|
||||
else -> AccountHistoryAdapter.formatDateHeader(m.date)
|
||||
}
|
||||
items.add(Item.DateChip(label))
|
||||
lastDay = day
|
||||
}
|
||||
items.add(Item.Bubble(m))
|
||||
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
|
||||
items.add(Item.DateChip(day, dayMessages))
|
||||
dayMessages.forEach { items.add(Item.Bubble(it)) }
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
@@ -74,78 +79,117 @@ class ChatMessagesAdapter(
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is Item.DateChip -> (holder as DateVH).b.tvDate.text = item.label
|
||||
is Item.DateChip -> (holder as DateVH).bind(item)
|
||||
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) {
|
||||
fun bind(m: ChatMessage) {
|
||||
val ctx = b.root.context
|
||||
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.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||
(b.tvMeta.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||
|
||||
// Sent: tinted like the mockup. Received: neutral grey with an outline, so the two
|
||||
// read apart even on a red-tinted theme.
|
||||
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
// Rounded, with a small "tail" corner on the sender's side at the bottom.
|
||||
val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||
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)
|
||||
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)
|
||||
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.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.tvLabel.setTextColor(fg)
|
||||
b.tvAmount.setTextColor(fg)
|
||||
b.tvAccount.setTextColor(fg)
|
||||
b.tvNote.setTextColor(fg)
|
||||
b.btnReceipt.setColorFilter(fg)
|
||||
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
|
||||
|
||||
b.tvLabel.text = ctx.getString(if (sent) R.string.chat_sent_label else R.string.chat_received_label)
|
||||
// Own account on its own line so long names aren't cut off by the receipt icon and tag.
|
||||
b.tvAccount.text = when {
|
||||
!sent -> ctx.getString(R.string.chat_into_label, m.accountDisplayName)
|
||||
showDestination && m.peerAccount.isNotBlank() ->
|
||||
ctx.getString(R.string.chat_from_to_label, m.accountDisplayName, m.peerAccount)
|
||||
else -> ctx.getString(R.string.chat_from_label, m.accountDisplayName)
|
||||
}
|
||||
b.tvAccount.visibility = if (m.accountDisplayName.isBlank()) View.GONE else View.VISIBLE
|
||||
b.tvBadge.text = ctx.getString(if (sent) R.string.chat_debit else R.string.chat_credit)
|
||||
// Debit uses the bubble's own text colour; Credit a green that stays readable in both themes.
|
||||
val badgeFg = if (sent) fg else Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||
b.tvBadge.setTextColor(badgeFg)
|
||||
b.tvBadge.background = GradientDrawable().apply {
|
||||
cornerRadius = 10 * ctx.resources.displayMetrics.density
|
||||
setColor(androidx.core.graphics.ColorUtils.setAlphaComponent(badgeFg, 0x2E))
|
||||
}
|
||||
|
||||
b.tvAmount.text = if (hideAmounts) "${m.currency} ••••••"
|
||||
else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
|
||||
b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
|
||||
b.tvAmount.setTextColor(if (sent) fg else green)
|
||||
b.tvNote.text = m.note
|
||||
b.tvNote.setTextColor(fg)
|
||||
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
|
||||
|
||||
val time = AccountHistoryAdapter.formatTime(m.date)
|
||||
b.tvMeta.text = if (sent) ctx.getString(R.string.chat_sent_meta, time) else time
|
||||
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||
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() ->
|
||||
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
|
||||
else -> m.accountDisplayName
|
||||
}
|
||||
b.tvAccount.setTextColor(fg)
|
||||
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
|
||||
b.tvTime.setTextColor(fg)
|
||||
b.tvTicks.text = ticksFor(m)
|
||||
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(m: ChatMessage) {
|
||||
val ctx = b.root.context
|
||||
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}")
|
||||
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)
|
||||
@@ -153,7 +197,6 @@ class ChatMessagesAdapter(
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_DATE = 0
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
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.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
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.databinding.ItemChatBinding
|
||||
import sh.sar.basedbank.util.ChatThread
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
|
||||
/** Telegram-style chat list: one row per person, latest transfer as the preview. */
|
||||
/**
|
||||
* 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(
|
||||
private val onChatClick: (ChatThread) -> Unit
|
||||
private val onChatClick: (ChatThread) -> Unit,
|
||||
private val onChatLongPress: (ChatThread) -> Unit
|
||||
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
|
||||
|
||||
private var allThreads: List<ChatThread> = emptyList()
|
||||
private var displayed: List<ChatThread> = emptyList()
|
||||
private var pinned: List<String> = emptyList()
|
||||
private var searchQuery = ""
|
||||
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
|
||||
pinned = pinnedKeys
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
@@ -35,15 +55,20 @@ class ChatsAdapter(
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun isPinned(key: String) = key in pinned
|
||||
|
||||
val isEmpty get() = displayed.isEmpty()
|
||||
|
||||
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.realName.contains(searchQuery, ignoreCase = true) ||
|
||||
it.peerAccount.contains(searchQuery) ||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -53,6 +78,11 @@ class ChatsAdapter(
|
||||
val pos = holder.bindingAdapterPosition
|
||||
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
|
||||
}
|
||||
|
||||
@@ -63,31 +93,80 @@ class ChatsAdapter(
|
||||
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(thread: ChatThread) {
|
||||
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
|
||||
binding.tvName.text = thread.peerName
|
||||
|
||||
val amount = if (hideAmounts) "${last.currency} ••••••"
|
||||
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
|
||||
binding.tvPreview.text = if (last.isSent) {
|
||||
val ticks = if (last.isBooked) " ✓✓" else " ✓"
|
||||
binding.tvPreview.text = when {
|
||||
last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
|
||||
last.isSent -> {
|
||||
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
|
||||
ctx.getString(R.string.chat_preview_sent, amount) + note
|
||||
} else {
|
||||
ctx.getString(R.string.chat_preview_received, amount)
|
||||
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)
|
||||
else AccountHistoryAdapter.formatDateOnly(last.date)
|
||||
|
||||
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
|
||||
if (thread.isMerchant) {
|
||||
// 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 {
|
||||
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. */
|
||||
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
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentChatsBinding
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.ChatStore
|
||||
import sh.sar.basedbank.util.ChatThread
|
||||
import java.text.DateFormat
|
||||
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() {
|
||||
|
||||
private var _binding: FragmentChatsBinding? = null
|
||||
@@ -33,6 +48,13 @@ class ChatsFragment : Fragment() {
|
||||
private val app get() = requireActivity().application as BasedBankApp
|
||||
|
||||
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 syncedWithContacts = false
|
||||
|
||||
@@ -42,9 +64,13 @@ class ChatsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = ChatsAdapter { thread ->
|
||||
(requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
|
||||
}
|
||||
adapter = ChatsAdapter(
|
||||
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.adapter = adapter
|
||||
|
||||
@@ -55,6 +81,31 @@ class ChatsFragment : Fragment() {
|
||||
|
||||
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 {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
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 contacts = viewModel.contacts.value ?: emptyList()
|
||||
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
|
||||
adapter.updateThreads(threads)
|
||||
adapter.updateThreads(threads, pins)
|
||||
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() {
|
||||
val b = _binding ?: return
|
||||
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
|
||||
@@ -132,7 +250,12 @@ class ChatsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
actionMode?.finish()
|
||||
super.onDestroyView()
|
||||
_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_AUTO_SCAN = "auto_scan"
|
||||
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. */
|
||||
const val RESULT_TRANSFER_DONE = "transfer_done"
|
||||
@@ -254,10 +255,11 @@ class TransferFragment : Fragment() {
|
||||
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 {
|
||||
putString(ARG_BML_QR_URL, qrUrl)
|
||||
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?,
|
||||
remarks: String?,
|
||||
fromAccountNumber: String? = null,
|
||||
returnOnSuccess: Boolean = false
|
||||
returnOnSuccess: Boolean = false,
|
||||
autoConfirm: Boolean = false
|
||||
) = TransferFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT, accountNumber)
|
||||
@@ -298,6 +301,7 @@ class TransferFragment : Fragment() {
|
||||
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||
if (autoConfirm) putBoolean(ARG_AUTO_CONFIRM, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,15 +312,21 @@ class TransferFragment : Fragment() {
|
||||
* the receipt is already in ReceiptStore.
|
||||
*/
|
||||
private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) {
|
||||
val activity = requireActivity() as HomeActivity
|
||||
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) == true) {
|
||||
activity.supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
|
||||
// Hosted in a sheet (e.g. over a chat): close the sheet; otherwise leave this page.
|
||||
if (!returnToCallerIfRequested()) {
|
||||
(requireActivity() as HomeActivity).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()
|
||||
} else {
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
@@ -401,6 +411,16 @@ class TransferFragment : Fragment() {
|
||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.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) }
|
||||
|
||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||
|
||||
@@ -38,9 +38,11 @@ class TransferSheetFragment : BottomSheetDialogFragment() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
(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")
|
||||
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.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ class BmlTransferHandler(
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
fragment.returnToCallerIfRequested()
|
||||
}
|
||||
} else {
|
||||
dialog.dismiss()
|
||||
|
||||
@@ -32,9 +32,12 @@ data class ChatMessage(
|
||||
val note: String = "", // remarks — only known for transfers sent from this app
|
||||
val peerAccount: String = "", // only known for transfers sent from this app
|
||||
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
|
||||
val dateOnly: Boolean = false // bank gave only a date (Favara); time of day unknown
|
||||
val 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
|
||||
/** 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. */
|
||||
val date: String get() = DATE_FMT.format(Date(timeMillis))
|
||||
|
||||
@@ -51,11 +54,12 @@ data class ChatAccount(
|
||||
)
|
||||
|
||||
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 realName: String, // account holder's name as the bank knows it; may be blank
|
||||
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
|
||||
val messages: List<ChatMessage> // oldest first
|
||||
val 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 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
|
||||
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
|
||||
@@ -77,16 +81,24 @@ data class ChatThread(
|
||||
object ChatStore {
|
||||
|
||||
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 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")
|
||||
/** 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" */
|
||||
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
|
||||
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
|
||||
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
|
||||
/** Masked own account in notifications, e.g. "7730*****1234". */
|
||||
/** "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,})""")
|
||||
|
||||
/** 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 NAME_KEY_PREFIX = "name:"
|
||||
private const val MERCHANT_KEY_PREFIX = "shop:"
|
||||
|
||||
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
|
||||
|
||||
@@ -125,7 +138,14 @@ object ChatStore {
|
||||
lookedUp[account]?.name?.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 {
|
||||
if (m.merchant) return MERCHANT_KEY_PREFIX + merchantKey(m.peerKey)
|
||||
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
|
||||
return NAME_KEY_PREFIX + normalise(name)
|
||||
}
|
||||
@@ -145,6 +165,12 @@ object ChatStore {
|
||||
|
||||
return messages.groupBy(::keyOf).map { (key, msgs) ->
|
||||
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()
|
||||
// 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() } }
|
||||
@@ -221,7 +247,8 @@ object ChatStore {
|
||||
|
||||
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
|
||||
val name = counterpartyName?.trim().orEmpty()
|
||||
if (description !in TRANSFER_TYPES || name.isBlank()) return null
|
||||
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.
|
||||
val dateOnly = date.contains("T00:00:00")
|
||||
if (if (dateOnly) time < startOfDay(since) else time < since) return null
|
||||
@@ -235,7 +262,8 @@ object ChatStore {
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
reference = reference.orEmpty(),
|
||||
dateOnly = dateOnly
|
||||
dateOnly = dateOnly,
|
||||
merchant = merchant
|
||||
)
|
||||
}
|
||||
|
||||
@@ -266,9 +294,11 @@ object ChatStore {
|
||||
val text = n.message.trim()
|
||||
val received = NOTIF_RECEIVED.matchEntire(text)
|
||||
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 {
|
||||
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) }
|
||||
paid != null -> paid.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
|
||||
else -> return null
|
||||
}
|
||||
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
|
||||
@@ -285,7 +315,8 @@ object ChatStore {
|
||||
currency = currency,
|
||||
timeMillis = n.timestampMs,
|
||||
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 },
|
||||
note = k.note.ifBlank { candidate.note },
|
||||
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
|
||||
} else {
|
||||
@@ -359,12 +391,14 @@ object ChatStore {
|
||||
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
|
||||
if (a.reference.isNotBlank() && a.reference == b.reference) return true
|
||||
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
|
||||
// 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.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
|
||||
if (a.dateOnly || b.dateOnly) {
|
||||
// A date-only entry could be any transfer that day, so the names must agree too.
|
||||
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
|
||||
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
|
||||
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
|
||||
}
|
||||
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
|
||||
@@ -375,7 +409,63 @@ object ChatStore {
|
||||
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
|
||||
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
|
||||
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 {
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
@@ -400,7 +490,8 @@ object ChatStore {
|
||||
note = o.optString("note"),
|
||||
peerAccount = o.optString("peerAccount"),
|
||||
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("receiptKey", m.receiptKey)
|
||||
put("dateOnly", m.dateOnly)
|
||||
put("merchant", m.merchant)
|
||||
})
|
||||
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
|
||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||
|
||||
+2
-3
@@ -1,7 +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="8dp" />
|
||||
<solid android:color="?attr/colorSurfaceContainerHigh" />
|
||||
<stroke android:width="1dp" android:color="?attr/colorOutlineVariant" />
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="?attr/colorOutline" />
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -10,7 +10,8 @@
|
||||
android:id="@+id/toAccountBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:background="?attr/colorSecondaryContainer"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
@@ -52,7 +53,7 @@
|
||||
android:id="@+id/tvToCurrency"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chat_date"
|
||||
android:background="@drawable/bg_chat_chip"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="3dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelLarge"
|
||||
@@ -101,6 +102,88 @@
|
||||
|
||||
</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
|
||||
android:id="@+id/rvMessages"
|
||||
android:layout_width="match_parent"
|
||||
@@ -135,76 +218,78 @@
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrencyWarning"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="@string/chat_from_account"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardFromAccount"
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnFrom"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
android:gravity="start|center_vertical"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
app:icon="@drawable/ic_arrow_right"
|
||||
app:iconGravity="end"
|
||||
app:iconSize="16dp"
|
||||
app:iconTint="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/amountRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<include
|
||||
android:id="@+id/fromAccountRow"
|
||||
layout="@layout/item_account_dropdown"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:rotation="90"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAmount"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_weight="1.2"
|
||||
android:hint="@string/chat_amount"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
app:boxCornerRadiusTopStart="28dp"
|
||||
app:boxCornerRadiusTopEnd="28dp"
|
||||
app:boxCornerRadiusBottomEnd="28dp"
|
||||
app:boxCornerRadiusBottomStart="28dp"
|
||||
app:boxCornerRadiusBottomEnd="28dp">
|
||||
app:boxCornerRadiusTopEnd="28dp"
|
||||
app:boxCornerRadiusTopStart="28dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAmount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="numberDecimal"
|
||||
android:maxLines="1"
|
||||
android:imeOptions="actionSend" />
|
||||
android:maxLines="1" />
|
||||
|
||||
</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>
|
||||
|
||||
@@ -224,4 +309,22 @@
|
||||
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
@@ -64,3 +68,14 @@
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</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>
|
||||
|
||||
@@ -55,8 +55,20 @@
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
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_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>
|
||||
|
||||
@@ -6,23 +6,24 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="4dp">
|
||||
android:paddingVertical="3dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardBubble"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="200dp"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="200dp"
|
||||
android:minWidth="150dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="14dp">
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -31,70 +32,78 @@
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLabel"
|
||||
android:layout_width="0dp"
|
||||
android:id="@+id/tvAmount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
android:maxWidth="240dp"
|
||||
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnReceipt"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:padding="6dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="-8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/chat_view_receipt"
|
||||
android:padding="8dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_receipt"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBadge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:paddingVertical="2dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAmount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:alpha="0.8"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNote"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||
android:layout_marginTop="2dp"
|
||||
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>
|
||||
|
||||
</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>
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:background="@drawable/bg_chat_date"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="action_open_contacts" type="id" />
|
||||
<item name="action_pin_chat" type="id" />
|
||||
</resources>
|
||||
|
||||
@@ -410,28 +410,52 @@
|
||||
<string name="chats_no_results">No chats found</string>
|
||||
<string name="chat_preview_sent">You sent %s</string>
|
||||
<string name="chat_preview_received">Received %s</string>
|
||||
<string name="chat_from_label">From %s</string>
|
||||
<string name="chat_received_label">Received</string>
|
||||
<string name="chat_sent_label">Sent</string>
|
||||
<string name="chat_into_label">To %s</string>
|
||||
<string name="chat_debit">Debit</string>
|
||||
<string name="chat_credit">Credit</string>
|
||||
<string name="chat_sent_meta">✓ Sent · %s</string>
|
||||
<string name="chat_preview_paid">Paid %s</string>
|
||||
<string name="chat_today">Today</string>
|
||||
<string name="chat_yesterday">Yesterday</string>
|
||||
<string name="chat_from_account">From account</string>
|
||||
<string name="chat_pick_account">Send from</string>
|
||||
<string name="chat_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_payment_sent">Payment sent</string>
|
||||
<string name="chat_view_receipt">Receipt</string>
|
||||
<string name="chat_not_in_contacts">Not in your contacts</string>
|
||||
<string name="chat_save_contact">Save contact</string>
|
||||
<string name="chat_send">Send</string>
|
||||
<string name="chat_to">To</string>
|
||||
<string name="chat_pick_recipient_account">Send to</string>
|
||||
<string name="chat_from_to_label">From %1$s → %2$s</string>
|
||||
<string name="chat_receipt_missing">Receipt no longer available</string>
|
||||
<string name="chat_amount">Amount</string>
|
||||
<string name="chat_amount_invalid">Enter an amount</string>
|
||||
<string name="chat_account_subtitle">Account %s</string>
|
||||
<string name="chat_cannot_send">Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.</string>
|
||||
<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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user