diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt index 2564e7d..44d0358 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt @@ -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 = emptyList() private var selectedFrom: BankAccount? = null private var selectedTo: ChatAccount? = null + private var merchantQr: String? = null + private var cardAccounts: List = 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) } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt index 32412c8..4ffea29 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt @@ -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() { - 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) : Item() data class Bubble(val message: ChatMessage) : Item() } private val items = mutableListOf() 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, 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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt index 526cdf9..192b6c0 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt @@ -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() { private var allThreads: List = emptyList() private var displayed: List = emptyList() + private var pinned: List = emptyList() private var searchQuery = "" private var hideAmounts = false - fun updateThreads(threads: List) { + /** Chat key shown as selected (long-pressed), or null. */ + var selectedKey: String? = null + set(value) { field = value; notifyDataSetChanged() } + + fun updateThreads(threads: List, pinnedKeys: List) { 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 + } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt index c6052b9..3d959d2 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt @@ -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 + } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index d36bf63..8ecd01f 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -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) { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt index a298e39..5774110 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt @@ -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 } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt index 8a5bcbe..5834688 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt @@ -351,6 +351,7 @@ class BmlTransferHandler( ) { fragment.clearForm() host?.triggerRefresh() + fragment.returnToCallerIfRequested() } } else { dialog.dismiss() diff --git a/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt index 3835591..b435d1e 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt @@ -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:" + val peerKey: String, // "name:" or "shop:" 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, // empty = no known account, sending from the chat is disabled - val messages: List // oldest first + val messages: List, // 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) @@ -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 = 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())) diff --git a/app/src/main/res/drawable/bg_chat_date.xml b/app/src/main/res/drawable/bg_chat_chip.xml similarity index 65% rename from app/src/main/res/drawable/bg_chat_date.xml rename to app/src/main/res/drawable/bg_chat_chip.xml index 76a90c1..32d67de 100644 --- a/app/src/main/res/drawable/bg_chat_date.xml +++ b/app/src/main/res/drawable/bg_chat_chip.xml @@ -1,7 +1,6 @@ - - - + + diff --git a/app/src/main/res/drawable/bg_chat_summary.xml b/app/src/main/res/drawable/bg_chat_summary.xml new file mode 100644 index 0000000..4620af9 --- /dev/null +++ b/app/src/main/res/drawable/bg_chat_summary.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pin.xml b/app/src/main/res/drawable/ic_pin.xml new file mode 100644 index 0000000..d7ba779 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_store.xml b/app/src/main/res/drawable/ic_store.xml new file mode 100644 index 0000000..aa17f0f --- /dev/null +++ b/app/src/main/res/drawable/ic_store.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml index e45994d..d1f01b0 100644 --- a/app/src/main/res/layout/fragment_chat.xml +++ b/app/src/main/res/layout/fragment_chat.xml @@ -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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:textAppearance="?attr/textAppearanceBodySmall" + android:textColor="?attr/colorError" + android:visibility="gone" /> - - - - - - - - - - - + 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" /> + app:boxCornerRadiusTopEnd="28dp" + app:boxCornerRadiusTopStart="28dp"> + android:maxLines="1" /> + + + + + + @@ -224,4 +309,22 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_chats.xml b/app/src/main/res/layout/fragment_chats.xml index 95b412d..f5c8c80 100644 --- a/app/src/main/res/layout/fragment_chats.xml +++ b/app/src/main/res/layout/fragment_chats.xml @@ -1,7 +1,11 @@ - + + @@ -64,3 +68,14 @@ + + + + diff --git a/app/src/main/res/layout/item_chat.xml b/app/src/main/res/layout/item_chat.xml index e2cb96e..ed6668d 100644 --- a/app/src/main/res/layout/item_chat.xml +++ b/app/src/main/res/layout/item_chat.xml @@ -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" /> + + diff --git a/app/src/main/res/layout/item_chat_bubble.xml b/app/src/main/res/layout/item_chat_bubble.xml index cf23947..33c2ae7 100644 --- a/app/src/main/res/layout/item_chat_bubble.xml +++ b/app/src/main/res/layout/item_chat_bubble.xml @@ -6,23 +6,24 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingVertical="4dp"> + android:paddingVertical="3dp"> + android:paddingStart="14dp" + android:paddingTop="10dp" + android:paddingEnd="14dp" + android:paddingBottom="8dp"> + android:maxWidth="240dp" + android:textAppearance="?attr/textAppearanceTitleLarge" + android:textStyle="bold" /> + + - - - - - - + android:layout_marginTop="2dp" + android:maxWidth="240dp" + android:textAppearance="?attr/textAppearanceBodyLarge" /> + + + + + + + + + + - - diff --git a/app/src/main/res/layout/item_chat_date.xml b/app/src/main/res/layout/item_chat_date.xml index d055751..d4e859f 100644 --- a/app/src/main/res/layout/item_chat_date.xml +++ b/app/src/main/res/layout/item_chat_date.xml @@ -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" /> diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 7501586..8ec4eb2 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,4 +1,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 657d92c..d7ab4ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,28 +410,52 @@ No chats found You sent %s Received %s - From %s - Received - Sent - To %s - Debit - Credit - ✓ Sent · %s + Paid %s Today Yesterday From account Send from + Pay with + %1$s · %2$s · %3$s + ⚠ Sending USD to an MVR account. It will be converted at BML\'s rate and can\'t be reversed. + MVR can\'t be sent to a USD account. Pick a USD account to send from. + Can\'t send MVR to a USD account + Needs a USD account to send from Transfer sent + Payment sent Receipt Not in your contacts Save contact Send To Send to - From %1$s → %2$s Receipt no longer available Amount Enter an amount - Account %s Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here. + into %s + %1$s → %2$s + Seen in BML alert + Booked in BML history + Note + Pay again with Scan to Pay + Pay %s + Scan to Pay + Business · Scan to Pay & card + %s · sent + received + net + Spent here in %s + payments + Send again · %s + View receipt + Copy reference + Reference copied + Pinned + Pin + Unpin + 1 selected + %s pinned + %s unpinned + Undo