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