Add Chats: transfers grouped per person as a chat #57

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