From da5cca79c62e3fd7dbf8bafa4283b1e78aea0e2d Mon Sep 17 00:00:00 2001 From: blackluga Date: Fri, 25 Sep 2026 16:56:31 +0500 Subject: [PATCH 1/2] add chats: transfers grouped per person as a chat Replaces Contacts in the default bottom nav slot with a Chats page (Contacts moves to More and opens from the Chats toolbar). - Chats start when the page is first opened; no old history is loaded - Transfers come from BML notifications (instant), account history (booked later) and in-app receipts, merged into one bubble each - Chats are keyed by the account holder's real name, looked up via BML account validation, so nickname contacts and their MVR/USD accounts share one chat; the To bar picks the account to send to - Sending opens the Transfer page in a bottom sheet over the chat and returns to it on success; sent bubbles link to their receipt - Chat and name data are stored encrypted with CacheEncryption Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC --- .../sh/sar/basedbank/ui/home/ChatFragment.kt | 398 ++++++++++++++++ .../basedbank/ui/home/ChatMessagesAdapter.kt | 162 +++++++ .../sh/sar/basedbank/ui/home/ChatsAdapter.kt | 93 ++++ .../sh/sar/basedbank/ui/home/ChatsFragment.kt | 138 ++++++ .../ui/home/ContactDetailsSheetFragment.kt | 4 +- .../sar/basedbank/ui/home/ContactSheetHost.kt | 9 + .../sar/basedbank/ui/home/ContactsFragment.kt | 6 +- .../sh/sar/basedbank/ui/home/HomeActivity.kt | 2 + .../sar/basedbank/ui/home/NavCustomization.kt | 9 +- .../sar/basedbank/ui/home/TransferFragment.kt | 31 +- .../ui/home/TransferSheetFragment.kt | 60 +++ .../sh/sar/basedbank/util/AccountNameStore.kt | 85 ++++ .../java/sh/sar/basedbank/util/ChatStore.kt | 432 ++++++++++++++++++ app/src/main/res/drawable/bg_chat_date.xml | 7 + app/src/main/res/drawable/ic_chat.xml | 9 + app/src/main/res/drawable/ic_receipt.xml | 9 + app/src/main/res/layout/fragment_chat.xml | 227 +++++++++ app/src/main/res/layout/fragment_chats.xml | 66 +++ app/src/main/res/layout/item_chat.xml | 62 +++ app/src/main/res/layout/item_chat_bubble.xml | 100 ++++ app/src/main/res/layout/item_chat_date.xml | 19 + app/src/main/res/layout/sheet_transfer.xml | 17 + app/src/main/res/layout/view_chat_header.xml | 44 ++ app/src/main/res/menu/bottom_nav_menu.xml | 6 +- app/src/main/res/menu/drawer_menu.xml | 3 + app/src/main/res/values/ids.xml | 4 + app/src/main/res/values/strings.xml | 34 ++ 27 files changed, 2020 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ContactSheetHost.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt create mode 100644 app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt create mode 100644 app/src/main/java/sh/sar/basedbank/util/ChatStore.kt create mode 100644 app/src/main/res/drawable/bg_chat_date.xml create mode 100644 app/src/main/res/drawable/ic_chat.xml create mode 100644 app/src/main/res/drawable/ic_receipt.xml create mode 100644 app/src/main/res/layout/fragment_chat.xml create mode 100644 app/src/main/res/layout/fragment_chats.xml create mode 100644 app/src/main/res/layout/item_chat.xml create mode 100644 app/src/main/res/layout/item_chat_bubble.xml create mode 100644 app/src/main/res/layout/item_chat_date.xml create mode 100644 app/src/main/res/layout/sheet_transfer.xml create mode 100644 app/src/main/res/layout/view_chat_header.xml create mode 100644 app/src/main/res/values/ids.xml 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 new file mode 100644 index 0000000..2564e7d --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt @@ -0,0 +1,398 @@ +package sh.sar.basedbank.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.R +import sh.sar.basedbank.api.models.BankAccount +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.ContactImageCache +import sh.sar.basedbank.util.ContactListParser +import sh.sar.basedbank.util.ReceiptStore +import sh.sar.basedbank.util.AccountListParser +import sh.sar.basedbank.util.ChatAccount +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. + */ +class ChatFragment : Fragment(), ContactSheetHost { + + private var _binding: FragmentChatBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + private val app get() = requireActivity().application as BasedBankApp + + private var peerKey = "" + private var header: ViewChatHeaderBinding? = null + private val adapter = ChatMessagesAdapter { openReceipt(it.receiptKey) } + private var thread: ChatThread? = null + private var fromAccounts: List = emptyList() + private var selectedFrom: BankAccount? = null + private var selectedTo: ChatAccount? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentChatBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty() + binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true } + binding.rvMessages.adapter = adapter + + adapter.setHideAmounts(viewModel.hideAmounts.value ?: false) + viewModel.hideAmounts.observe(viewLifecycleOwner) { + adapter.setHideAmounts(it) + selectedFrom?.let { acc -> bindAccountRow(binding.fromAccountRow, acc) } + } + + viewModel.accounts.observe(viewLifecycleOwner) { accounts -> + fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN } + bindFromAccounts() + } + + binding.cardFromAccount.setOnClickListener { showAccountPicker() } + binding.btnSaveContact.setOnClickListener { openContact() } + binding.toAccountBar.setOnClickListener { showRecipientPicker() } + binding.btnSend.setOnClickListener { send() } + binding.etAmount.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false + } + + // Transfers started from this chat come straight back here instead of opening the receipt. + parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ -> + onTransferDone() + } + + // A newly saved contact adds an account (and maybe a nickname) to this chat; reload. + viewModel.contacts.observe(viewLifecycleOwner) { loadThread() } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(ARG_PEER_KEY, peerKey) + } + + private fun loadThread() { + val ctx = requireContext().applicationContext + val contacts = viewModel.contacts.value ?: emptyList() + viewLifecycleOwner.lifecycleScope.launch { + val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) } + val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty() + val t = threads.firstOrNull { it.peerKey == peerKey } + ?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } } + if (_binding == null) return@launch + thread = t + if (t == null) return@launch + peerKey = t.peerKey + 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() + + 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) + } + } + + /** Picks which of the person's accounts to send to and updates everything that depends on it. */ + private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) { + val b = _binding ?: return + val t = thread ?: return + selectedTo = to + + b.toAccountBar.visibility = if (to == null) View.GONE else View.VISIBLE + if (to != null) { + b.tvToAccount.text = to.account + b.tvToCurrency.text = to.currency + b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE + val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) } + b.tvToNickname.text = nickname + b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE + b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE + } + + // Offer to save the selected account (or the person, when no account is known yet). + b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE + + val canSend = to != null + b.composer.visibility = if (canSend) View.VISIBLE else View.GONE + b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE + + // Sending to a USD account: default to a USD source account, and likewise for MVR. + if (matchFromCurrency && to != null && to.currency.isNotBlank() && + !selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) { + fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom) + } + } + + private fun showRecipientPicker() { + val t = thread ?: return + if (t.accounts.size < 2) 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_recipient_account))) + val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer) + for (account in t.accounts) { + val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false) + row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() } + ?: 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 + if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg) + else row.root.setBackgroundResource(android.R.drawable.list_selector_background) + row.root.setOnClickListener { + selectTo(account, matchFromCurrency = true) + dialog.dismiss() + } + list.addView(row.root) + } + dialog.setContentView(list) + dialog.show() + } + + private fun sheetTitle(text: String) = TextView(requireContext()).apply { + this.text = text + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium) + val pad = (16 * resources.displayMetrics.density).toInt() + setPadding(pad, pad / 2, pad, pad / 2) + } + + private fun bindFromAccounts() { + val b = _binding ?: return + if (fromAccounts.isEmpty()) return + // Default to the account last used with this person. + val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber + val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } } + selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first() + + selectFrom(selectedFrom!!) + } + + private fun onTransferDone() { + binding.etAmount.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() + viewLifecycleOwner.lifecycleScope.launch { + 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()) + } + .show() + } + + private fun openReceipt(receiptKey: String) { + val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey } + if (entry == null) { + Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show() + return + } + (requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null)) + } + + private fun sync() { + val ctx = requireContext().applicationContext + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) } + if (_binding != null) loadThread() + } + } + + private fun selectFrom(account: BankAccount) { + val b = _binding ?: return + selectedFrom = account + bindAccountRow(b.fromAccountRow, account) + b.tilAmount.prefixText = account.currencyName + } + + /** Fills an account row: name, full account number, balance (masked when amounts are hidden). */ + private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) { + val display = AccountListParser.from(account) + row.tvDropdownAccountName.text = account.accountBriefName + row.tvDropdownAccountNumber.text = account.accountNumber + val balance = display?.balance.orEmpty() + val hide = viewModel.hideAmounts.value ?: false + row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance + val type = display?.typeLabel.orEmpty() + row.tvDropdownAccountType.text = type + row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE + row.ivDropdownCardLogo.visibility = View.GONE + } + + private fun showAccountPicker() { + if (fromAccounts.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_account))) + val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer) + for (account in fromAccounts) { + val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false) + bindAccountRow(row, account) + if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg) + else row.root.setBackgroundResource(android.R.drawable.list_selector_background) + row.root.setOnClickListener { + selectFrom(account) + dialog.dismiss() + } + list.addView(row.root) + } + dialog.setContentView(list) + dialog.show() + } + + private fun send() { + val t = thread ?: return + val from = selectedFrom ?: return + val to = selectedTo ?: return + val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() + if (amount == null || amount <= 0.0) { + binding.tilAmount.error = getString(R.string.chat_amount_invalid) + return + } + binding.tilAmount.error = null + val transfer = TransferFragment.newInstanceFromQr( + accountNumber = to.account, + displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName, + amount = "%.2f".format(amount), + remarks = null, + fromAccountNumber = from.accountNumber, + returnOnSuccess = true + ) + if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return + TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet") + } + + /** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */ + private fun updateToolbar() { + val t = thread ?: return + val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return + val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h -> + header = h + 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() + 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)) + + requireActivity().title = t.peerName + bar.setDisplayShowTitleEnabled(false) + bar.setDisplayShowCustomEnabled(true) + bar.customView = h.root + } + + private fun openContact() { + val t = thread ?: return + val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) } + if (display != null) { + if (childFragmentManager.findFragmentByTag("contact_details") != null) return + ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details") + } else { + // Not a saved contact yet: offer to save them, prefilled with what we know. + if (childFragmentManager.findFragmentByTag("add_contact") != null) return + AddContactSheetFragment.newInstance( + bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() }, + accountNumber = selectedTo?.account, + recipientName = t.realName.ifBlank { t.peerName }, + currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName + ).show(childFragmentManager, "add_contact") + } + } + + override fun openTransfer(contact: ContactDisplay) { + val fragment = TransferFragment.newInstance( + accountNumber = contact.accountNumber, + displayName = contact.name, + subtitle = contact.transferSubtitle, + colorHex = contact.bankColor, + imageHash = contact.imageHash + ) + (requireActivity() as HomeActivity).showWithBackStack(fragment) + } + + /** Deleting lives on the Contacts page, which also keeps its caches in sync. */ + override fun confirmDelete(contact: ContactDisplay) { + (requireActivity() as HomeActivity).showWithBackStack(ContactsFragment()) + } + + override fun onResume() { + super.onResume() + updateToolbar() + } + + override fun onPause() { + super.onPause() + (activity as? AppCompatActivity)?.supportActionBar?.apply { + setDisplayShowCustomEnabled(false) + setDisplayShowTitleEnabled(true) + customView = null + } + } + + override fun onDestroyView() { + super.onDestroyView() + header = null + _binding = null + } + + companion object { + private const val ARG_PEER_KEY = "peer_key" + private const val HISTORY_CATCH_UP_MS = 10_000L + private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN") + + 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 new file mode 100644 index 0000000..32412c8 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt @@ -0,0 +1,162 @@ +package sh.sar.basedbank.ui.home + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.text.format.DateUtils +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +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. */ +class ChatMessagesAdapter( + private val onReceiptClick: (ChatMessage) -> Unit +) : RecyclerView.Adapter() { + + private sealed class Item { + data class DateChip(val label: String) : Item() + data class Bubble(val message: ChatMessage) : Item() + } + + private val items = mutableListOf() + private var hideAmounts = false + + /** When the person has several accounts, sent bubbles say which one the money went to. */ + var showDestination = false + + fun setHideAmounts(hide: Boolean) { + if (hideAmounts == hide) return + hideAmounts = hide + notifyDataSetChanged() + } + + /** [messages] must be oldest first. */ + fun setMessages(messages: List, todayLabel: String, yesterdayLabel: String) { + 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)) + } + notifyDataSetChanged() + } + + override fun getItemCount() = items.size + + override fun getItemViewType(position: Int) = when (items[position]) { + is Item.DateChip -> TYPE_DATE + is Item.Bubble -> TYPE_BUBBLE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false)) + else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false)) + } + + 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.Bubble -> (holder as BubbleVH).bind(item.message) + } + } + + class DateVH(val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) + + inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) { + fun bind(m: ChatMessage) { + val ctx = b.root.context + val sent = m.isSent + + 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 + val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer) + else Color.parseColor(if (night) "#2A2C2E" else "#F1F2F4") + 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) + b.cardBubble.setCardBackgroundColor(bg) + b.cardBubble.strokeWidth = if (sent) 0 else (1 * ctx.resources.displayMetrics.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.tvNote.text = m.note + 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.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}") + } + 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 new file mode 100644 index 0000000..526cdf9 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt @@ -0,0 +1,93 @@ +package sh.sar.basedbank.ui.home + +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +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. */ +class ChatsAdapter( + private val onChatClick: (ChatThread) -> Unit +) : RecyclerView.Adapter() { + + private var allThreads: List = emptyList() + private var displayed: List = emptyList() + private var searchQuery = "" + private var hideAmounts = false + + fun updateThreads(threads: List) { + allThreads = threads + applyFilter() + } + + fun setSearch(query: String) { + searchQuery = query + applyFilter() + } + + fun setHideAmounts(hide: Boolean) { + if (hideAmounts == hide) return + hideAmounts = hide + notifyDataSetChanged() + } + + val isEmpty get() = displayed.isEmpty() + + private fun applyFilter() { + displayed = 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) } + } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + holder.binding.root.setOnClickListener { + val pos = holder.bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos]) + } + return holder + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position]) + + override fun getItemCount() = displayed.size + + inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(thread: ChatThread) { + val ctx = binding.root.context + 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) + } + + 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)) + } + } + + companion object { + private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65") + + /** 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] + } +} 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 new file mode 100644 index 0000000..c6052b9 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt @@ -0,0 +1,138 @@ +package sh.sar.basedbank.ui.home + +import android.os.Bundle +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.core.view.MenuProvider +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 kotlinx.coroutines.Dispatchers +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.ChatStore +import java.text.DateFormat +import java.util.Date + +/** Transfers grouped by person, shown as a chat list. Contacts is reachable from the toolbar. */ +class ChatsFragment : Fragment() { + + private var _binding: FragmentChatsBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + private val app get() = requireActivity().application as BasedBankApp + + private lateinit var adapter: ChatsAdapter + private var syncedThisView = false + private var syncedWithContacts = false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentChatsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + adapter = ChatsAdapter { thread -> + (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey)) + } + binding.rvChats.layoutManager = LinearLayoutManager(requireContext()) + binding.rvChats.adapter = adapter + + binding.etSearch.addTextChangedListener { text -> + adapter.setSearch(text?.toString() ?: "") + updateEmptyView() + } + + binding.swipeRefresh.setOnRefreshListener { sync() } + + 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) + .setIcon(R.drawable.ic_contacts) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId != R.id.action_open_contacts) return false + (requireActivity() as HomeActivity).showWithBackStack(ContactsFragment()) + return true + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + + adapter.setHideAmounts(viewModel.hideAmounts.value ?: false) + viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) } + + // Contacts give chats a saved name and an account number to send to. + (activity as? HomeActivity)?.loadAllContacts() + viewModel.contacts.observe(viewLifecycleOwner) { contacts -> + loadThreads() + // Contacts usually load after the first sync; sync again so their real names get looked up. + if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync() + } + + // Accounts arrive after login; sync once they are available. + viewModel.accounts.observe(viewLifecycleOwner) { accounts -> + if (!syncedThisView && accounts.any { it.bank == "BML" }) sync() + } + + loadThreads() + } + + private fun sync() { + syncedThisView = true + binding.swipeRefresh.isRefreshing = true + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + if (contacts.isNotEmpty()) syncedWithContacts = true + val ctx = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) } + _binding?.swipeRefresh?.isRefreshing = false + loadThreads() + } + } + + private fun loadThreads() { + val ctx = requireContext().applicationContext + val contacts = viewModel.contacts.value ?: emptyList() + viewLifecycleOwner.lifecycleScope.launch { + val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) } + if (_binding == null) return@launch + adapter.updateThreads(threads) + updateEmptyView() + } + } + + private fun updateEmptyView() { + val b = _binding ?: return + b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE + if (!adapter.isEmpty) return + val since = ChatStore.sinceMillis(requireContext()) + b.emptyView.text = when { + b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results) + since > 0L -> getString(R.string.chats_empty_since, + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since))) + else -> getString(R.string.chats_empty) + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.nav_chats) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt index b0c756b..66050dd 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt @@ -24,14 +24,14 @@ import sh.sar.basedbank.util.ContactListParser import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.TransferNetwork -/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */ +/** Contact details drawer shown when a contact is tapped in [ContactsFragment] or a chat header. */ class ContactDetailsSheetFragment : BottomSheetDialogFragment() { private var _binding: SheetContactDetailsBinding? = null private val binding get() = _binding!! private val viewModel: HomeViewModel by activityViewModels() - private val contactsFragment get() = parentFragment as? ContactsFragment + private val contactsFragment get() = parentFragment as? ContactSheetHost override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = SheetContactDetailsBinding.inflate(inflater, container, false) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactSheetHost.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactSheetHost.kt new file mode 100644 index 0000000..c75df41 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactSheetHost.kt @@ -0,0 +1,9 @@ +package sh.sar.basedbank.ui.home + +import sh.sar.basedbank.util.ContactDisplay + +/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */ +interface ContactSheetHost { + fun openTransfer(contact: ContactDisplay) + fun confirmDelete(contact: ContactDisplay) +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt index f1cb4a0..6a9f4d1 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt @@ -40,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.TransferNetwork -class ContactsFragment : Fragment() { +class ContactsFragment : Fragment(), ContactSheetHost { private var _binding: FragmentContactsBinding? = null private val binding get() = _binding!! @@ -182,7 +182,7 @@ class ContactsFragment : Fragment() { ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details") } - internal fun openTransfer(contact: ContactDisplay) { + override fun openTransfer(contact: ContactDisplay) { val fragment = TransferFragment.newInstance( accountNumber = contact.accountNumber, displayName = contact.name, @@ -193,7 +193,7 @@ class ContactsFragment : Fragment() { (requireActivity() as HomeActivity).showWithBackStack(fragment) } - internal fun confirmDelete(contact: ContactDisplay) { + override fun confirmDelete(contact: ContactDisplay) { val ctx = requireContext() val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError) val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 947b654..1b433e7 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -176,6 +176,7 @@ class HomeActivity : AppCompatActivity() { val frag = when (item.itemId) { R.id.nav_dashboard -> DashboardFragment() R.id.nav_accounts -> AccountsFragment() + R.id.nav_chats -> ChatsFragment() R.id.nav_contacts -> ContactsFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_pay_mv_qr -> PayMvQrFragment() @@ -473,6 +474,7 @@ fun applyNavLabelVisibility() { val dest = fragment ?: when (itemId) { R.id.nav_dashboard -> DashboardFragment() R.id.nav_accounts -> AccountsFragment() + R.id.nav_chats -> ChatsFragment() R.id.nav_contacts -> ContactsFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_pay_mv_qr -> PayMvQrFragment() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt b/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt index c5be4ea..efadbe2 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt @@ -32,6 +32,7 @@ object NavCustomization { /** All items that can occupy either a bottom nav slot or the "More" screen. */ val ALL_SWAPPABLE = listOf( NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts), + NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats), NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts), NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer), NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr), @@ -51,14 +52,14 @@ object NavCustomization { fun getSlots(prefs: SharedPreferences): List = listOf( keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts), - keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts), + keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats), keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer), ) fun saveSlots(prefs: SharedPreferences, slots: List) { prefs.edit() .putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts") - .putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts") + .putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats") .putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer") .apply() } @@ -79,7 +80,7 @@ object NavCustomization { fun getCircularSlots(prefs: SharedPreferences): List = listOf( keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer), keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card), - keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts), + keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_chats), keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts), ) @@ -87,7 +88,7 @@ object NavCustomization { prefs.edit() .putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer") .putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card") - .putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts") + .putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_chats") .putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts") .apply() } 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 0a6c4a8..d36bf63 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 @@ -115,7 +115,7 @@ class TransferFragment : Fragment() { clearForm() val activity = requireActivity() as HomeActivity activity.triggerRefresh() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) + showReceipt(receipt, avatar) } ).also { bmlHandler = it } @@ -155,7 +155,7 @@ class TransferFragment : Fragment() { clearForm() val activity = requireActivity() as HomeActivity activity.triggerRefresh() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) + showReceipt(receipt, avatar) } ).also { mfaisaHandler = it } @@ -245,6 +245,10 @@ class TransferFragment : Fragment() { private const val ARG_REMARKS_PREFILL = "remarks_prefill" 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" + + /** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */ + const val RESULT_TRANSFER_DONE = "transfer_done" fun newInstanceWithAutoScan() = TransferFragment().apply { arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) } @@ -282,7 +286,8 @@ class TransferFragment : Fragment() { displayName: String, amount: String?, remarks: String?, - fromAccountNumber: String? = null + fromAccountNumber: String? = null, + returnOnSuccess: Boolean = false ) = TransferFragment().apply { arguments = Bundle().apply { putString(ARG_ACCOUNT, accountNumber) @@ -292,10 +297,28 @@ class TransferFragment : Fragment() { if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber) if (amount != null) putString(ARG_AMOUNT_PREFILL, amount) if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks) + if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true) } } } + /** + * Opens the receipt, or — when opened with returnOnSuccess (e.g. from a chat) — goes back to + * the caller and tells it via [RESULT_TRANSFER_DONE] on the activity's fragment manager; + * 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)) + } + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentTransferBinding.inflate(inflater, container, false) return binding.root @@ -1205,7 +1228,7 @@ class TransferFragment : Fragment() { val activity = requireActivity() as HomeActivity activity.triggerRefresh() dialog.dismiss() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) + showReceipt(receipt, capturedToAvatar) } else if (!ok) { dialog.dismiss() if (msg == "CONNECTIVITY") { 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 new file mode 100644 index 0000000..a298e39 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt @@ -0,0 +1,60 @@ +package sh.sar.basedbank.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import sh.sar.basedbank.databinding.SheetTransferBinding + +/** + * Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible. + * The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet. + */ +class TransferSheetFragment : BottomSheetDialogFragment() { + + private var _binding: SheetTransferBinding? = null + private val binding get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = SheetTransferBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // Leave the top of the chat visible above the sheet. + binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt() + if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) { + val transferArgs = requireArguments() + childFragmentManager.beginTransaction() + .replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs }) + .commitNow() + } + } + + override fun onStart() { + super.onStart() + (dialog as? BottomSheetDialog)?.let { d -> + // Keep the amount/remarks fields above the keyboard. + @Suppress("DEPRECATION") + d.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + d.behavior.skipCollapsed = true + d.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val SHEET_HEIGHT_FRACTION = 0.8f + + /** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */ + fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt b/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt new file mode 100644 index 0000000..eec1880 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt @@ -0,0 +1,85 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONObject +import sh.sar.basedbank.api.bml.BmlSession +import sh.sar.basedbank.api.bml.BmlValidateClient +import java.io.File + +/** + * Encrypted directory of account number → real account-holder name and currency, looked up through BML's + * account validation. Saved contacts often carry a nickname, while bank history only shows the + * real name; this lets a name-only transfer be matched to the right account. + */ +object AccountNameStore { + + private const val FILE_NAME = "account_names.json" + private val lock = Any() + + /** Names are re-checked after this long, in case an account changes hands. */ + private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000 + /** Failed lookups (closed or foreign accounts) are retried after this long. */ + private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000 + /** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */ + private const val MAX_LOOKUPS_PER_RUN = 20 + + data class Info(val name: String, val currency: String) + + private data class Entry(val name: String, val currency: String, val checkedAt: Long) + + /** Account number → real name and currency, for every account resolved so far. */ + fun all(context: Context): Map = synchronized(lock) { + load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) } + } + + /** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */ + fun resolve(context: Context, session: BmlSession, accounts: Collection) { + val now = System.currentTimeMillis() + val known = synchronized(lock) { load(context) } + val due = accounts.filter { it.isNotBlank() }.distinct().filter { account -> + val e = known[account] ?: return@filter true + val age = now - e.checkedAt + if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS + }.take(MAX_LOOKUPS_PER_RUN) + if (due.isEmpty()) return + + val client = BmlValidateClient() + // null = network error: leave the account due for next time. A failed lookup is stored blank. + val found = due.associateWith { account -> + try { + val v = client.validateAccount(session, account) + Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now) + } catch (_: Exception) { null } + } + + synchronized(lock) { + val current = load(context).toMutableMap() + found.forEach { (account, entry) -> if (entry != null) current[account] = entry } + save(context, current) + } + } + + fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() } + + private fun load(context: Context): Map { + val file = File(context.filesDir, FILE_NAME) + if (!file.exists()) return emptyMap() + return try { + val root = JSONObject(CacheEncryption.decrypt(file.readText())) + root.keys().asSequence().associateWith { key -> + val o = root.getJSONObject(key) + Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L)) + } + } catch (_: Exception) { emptyMap() } + } + + private fun save(context: Context, entries: Map) { + try { + val root = JSONObject() + entries.forEach { (account, e) -> + root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt)) + } + File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString())) + } catch (_: Exception) {} + } +} diff --git a/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt new file mode 100644 index 0000000..3835591 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt @@ -0,0 +1,432 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.api.models.BankAccount +import sh.sar.basedbank.api.bml.BmlNotificationsClient +import sh.sar.basedbank.api.models.BankContact +import sh.sar.basedbank.ui.home.AppNotification +import sh.sar.basedbank.ui.home.AccountHistoryAdapter +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * One transfer shown as a chat bubble. BML history only carries the counterparty's name, so + * [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name. + */ +data class ChatMessage( + val id: String, + val peerKey: String, + val peerName: String, + val amount: Double, // negative = sent, positive = received + val currency: String, + val timeMillis: Long, + val accountNumber: String, // own account the money moved in/out of + val accountDisplayName: String, + val reference: String = "", + 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 isSent get() = amount < 0 + /** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */ + val date: String get() = DATE_FMT.format(Date(timeMillis)) + + companion object { + private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + } +} + +/** One of a person's accounts that can be sent to from their chat. */ +data class ChatAccount( + val account: String, + val currency: String, // "MVR", "USD"; blank if not known yet + val contact: BankContact? // saved contact for this account, if any +) + +data class ChatThread( + val peerKey: String, // "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, // empty = no known account, sending from the chat is disabled + val messages: List // oldest first +) { + val last get() = messages.last() + val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact } + /** The account last sent to, else the first known one; blank when none is known. */ + val peerAccount: String get() = + messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount + ?.takeIf { a -> accounts.any { it.account == a } } + ?: accounts.firstOrNull()?.account.orEmpty() +} + +/** + * Encrypted local record of BML transfers, 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), + * pages back through the latest history of every BML current/savings account (booked later), and + * merges in receipts of transfers sent from this app, keeping one message per transfer. + */ +object ChatStore { + + private const val FILE_NAME = "chats.json" + 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") + + /** "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". */ + private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""") + + /** How far apart two records of the same transfer can be timestamped. */ + private const val MATCH_WINDOW_MS = 15 * 60 * 1000L + + /** Upper bound on history pages fetched per account in one sync. */ + private const val MAX_PAGES_PER_SYNC = 5 + + private const val NAME_KEY_PREFIX = "name:" + + private data class State(val sinceMillis: Long, val messages: List) + + fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US) + + fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis } + + fun isStarted(context: Context) = sinceMillis(context) > 0L + + /** + * Groups stored messages into chats, most recent chat first. Call off the main thread. + * + * Chats are per person, keyed by the account holder's real name: bank history only names the + * sender, never their account, so money can't be told apart per account anyway. Every known + * account with that real name (saved contacts, accounts sent to) belongs to the chat, and the + * user picks which one to send to — typically their MVR or USD account. + * + * Real names come from [AccountNameStore], so a contact saved as "Ali" joins the + * "Mohamed Ali" chat. + */ + fun threads(context: Context, contacts: List): List { + val messages = synchronized(lock) { load(context).messages } + val lookedUp = AccountNameStore.all(context) + val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount } + + fun realNameOf(account: String): String? = + lookedUp[account]?.name?.takeIf { it.isNotBlank() } + ?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() } + + fun keyOf(m: ChatMessage): String { + val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName + return NAME_KEY_PREFIX + normalise(name) + } + + // Every known account, grouped under its holder's real name. + val accountsByKey = HashMap>() + fun addAccount(account: String, fallbackCurrency: String) { + val name = realNameOf(account) ?: return + val contact = contactsByAccount[account] + val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() } + ?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency + accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() } + .getOrPut(account) { ChatAccount(account, currency, contact) } + } + contactsByAccount.keys.forEach { addAccount(it, "") } + messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) } + + return messages.groupBy(::keyOf).map { (key, msgs) -> + val sorted = msgs.sortedBy { it.timeMillis } + 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() } } + ?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName + ?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } } + ?: "" + val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } } + ChatThread( + peerKey = key, + peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName, + realName = realName, + accounts = accounts, + messages = sorted + ) + }.sortedByDescending { it.last.timeMillis } + } + + /** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */ + suspend fun sync(context: Context, app: BasedBankApp, accounts: List, contacts: List) { + val state = synchronized(lock) { load(context) } + if (state.sinceMillis == 0L) { + synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) } + return + } + val since = state.sinceMillis + + val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id }) + val notifications = fetchNotifications(context, app, accounts, since) + val receipts = receiptMessages(context, accounts, since) + + synchronized(lock) { + val current = load(context) + save(context, current.copy(messages = merge(current.messages, history + notifications + receipts))) + } + + // Learn real names for contacts and people we've sent to, so name-only transfers match them. + val session = app.anyBmlSession() ?: return + val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } } + // SWIFT (foreign) beneficiaries can't be looked up through BML. + val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo + AccountNameStore.resolve(context, session, toResolve) + } + + /** + * Transfers from account history. BML only adds a transaction here once it is booked (often + * the next day), so recent ones come from [fetchNotifications] first. + */ + private suspend fun fetchHistory( + app: BasedBankApp, + accounts: List, + since: Long, + knownIds: Set + ): List { + val fetched = mutableListOf() + for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) { + val fetcher = HistoryFetcher(account) + var pages = 0 + // Page back until we reach transfers we already have or ones older than the start time. + while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) { + val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break } + pages++ + if (page.isEmpty()) break + var reachedKnown = false + for (trx in page) { + val time = AccountHistoryAdapter.parseDateMillis(trx.date) + if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true + trx.toChatMessage(time, since)?.let { fetched += it } + } + if (reachedKnown) break + } + } + return fetched + } + + 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 + // 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 + return ChatMessage( + id = "bml_$id", + peerKey = normalise(name), + peerName = name, + amount = amount, + currency = currency, + timeMillis = time, + accountNumber = accountNumber, + accountDisplayName = accountDisplayName, + reference = reference.orEmpty(), + dateOnly = dateOnly + ) + } + + /** + * Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive + * instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page + * is not written back to the cache, which the notification poller uses to spot new alerts. + */ + private fun fetchNotifications( + context: Context, + app: BasedBankApp, + accounts: List, + since: Long + ): List { + val client = BmlNotificationsClient() + val notifications = LinkedHashMap() + for ((loginId, session) in app.bmlSessions.toMap()) { + NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it } + try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } } + catch (_: Exception) {} + } + return notifications.values + .filter { it.timestampMs >= since } + .mapNotNull { notificationToMessage(it, accounts) } + } + + private fun notificationToMessage(n: AppNotification, accounts: List): ChatMessage? { + val text = n.message.trim() + val received = NOTIF_RECEIVED.matchEntire(text) + val sent = if (received == null) NOTIF_SENT.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) } + else -> return null + } + if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts + val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null + val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m -> + val (head, tail) = m.destructured + accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) } + } + return ChatMessage( + id = "ntf_${n.id}", + peerKey = normalise(name), + peerName = name.trim(), + amount = if (received != null) amount else -amount, + currency = currency, + timeMillis = n.timestampMs, + accountNumber = own?.accountNumber.orEmpty(), + accountDisplayName = own?.accountBriefName ?: ownMasked + ) + } + + /** Transfers sent from this app, from their saved receipts: these carry the note and slip. */ + private fun receiptMessages(context: Context, accounts: List, since: Long): List = + ReceiptStore.loadAll(context).mapNotNull { entry -> + val r = entry.data + if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null + val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null + val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt + val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel } + ChatMessage( + id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}", + peerKey = normalise(r.toLabel), + peerName = r.toLabel, + amount = -amount, + currency = r.currency, + timeMillis = time, + accountNumber = from?.accountNumber.orEmpty(), + accountDisplayName = r.fromLabel, + reference = r.bmlReference, + note = r.remarks, + peerAccount = r.toAccount, + receiptKey = entry.savedAt.toString() + ) + } + + /** + * Merges stored and newly fetched messages so each transfer appears once. The same transfer can + * come from history, a notification and a receipt; the highest-ranked source is kept (history, + * then notification, then receipt) and picks up what the others add: the exact time, the note, + * the recipient account and the receipt. + */ + private fun merge(existing: List, incoming: List): List { + val byId = LinkedHashMap() + incoming.forEach { byId[it.id] = it } + existing.forEach { byId[it.id] = it } // stored copies already carry merged details + + val kept = mutableListOf() + val absorbed = HashMap>() // kept id -> source ranks merged into it + for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) { + val r = rank(candidate) + val index = kept.indexOfFirst { k -> + rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate) + } + if (index >= 0) { + val k = kept[index] + kept[index] = k.copy( + timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis, + dateOnly = k.dateOnly && candidate.dateOnly, + accountNumber = k.accountNumber.ifBlank { candidate.accountNumber }, + reference = k.reference.ifBlank { candidate.reference }, + note = k.note.ifBlank { candidate.note }, + peerAccount = k.peerAccount.ifBlank { candidate.peerAccount }, + receiptKey = k.receiptKey.ifBlank { candidate.receiptKey } + ) + absorbed.getOrPut(k.id) { mutableSetOf() } += r + } else { + kept += candidate + } + } + return kept.sortedBy { it.timeMillis } + } + + private fun rank(m: ChatMessage) = when { + m.id.startsWith("bml_") -> 0 + m.id.startsWith("ntf_") -> 1 + else -> 2 + } + + 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 + 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 + } + + private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply { + timeInMillis = millis + 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() } + + private fun load(context: Context): State { + val file = File(context.filesDir, FILE_NAME) + if (!file.exists()) return State(0L, emptyList()) + return try { + val root = JSONObject(CacheEncryption.decrypt(file.readText())) + val arr = root.optJSONArray("messages") ?: JSONArray() + State( + sinceMillis = root.optLong("since", 0L), + messages = (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + ChatMessage( + id = o.optString("id"), + peerKey = o.optString("peerKey"), + peerName = o.optString("peerName"), + amount = o.optDouble("amount", 0.0), + currency = o.optString("currency"), + timeMillis = o.optLong("time", 0L), + accountNumber = o.optString("account"), + accountDisplayName = o.optString("accountName"), + reference = o.optString("reference"), + note = o.optString("note"), + peerAccount = o.optString("peerAccount"), + receiptKey = o.optString("receiptKey"), + dateOnly = o.optBoolean("dateOnly", false) + ) + } + ) + } catch (_: Exception) { State(0L, emptyList()) } + } + + private fun save(context: Context, state: State) { + try { + val arr = JSONArray() + for (m in state.messages) arr.put(JSONObject().apply { + put("id", m.id) + put("peerKey", m.peerKey) + put("peerName", m.peerName) + put("amount", m.amount) + put("currency", m.currency) + put("time", m.timeMillis) + put("account", m.accountNumber) + put("accountName", m.accountDisplayName) + put("reference", m.reference) + put("note", m.note) + put("peerAccount", m.peerAccount) + put("receiptKey", m.receiptKey) + put("dateOnly", m.dateOnly) + }) + val root = JSONObject().put("since", state.sinceMillis).put("messages", arr) + File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString())) + } catch (_: Exception) {} + } +} diff --git a/app/src/main/res/drawable/bg_chat_date.xml b/app/src/main/res/drawable/bg_chat_date.xml new file mode 100644 index 0000000..76a90c1 --- /dev/null +++ b/app/src/main/res/drawable/bg_chat_date.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_chat.xml b/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000..f7014bf --- /dev/null +++ b/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_receipt.xml b/app/src/main/res/drawable/ic_receipt.xml new file mode 100644 index 0000000..04fbb41 --- /dev/null +++ b/app/src/main/res/drawable/ic_receipt.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml new file mode 100644 index 0000000..e45994d --- /dev/null +++ b/app/src/main/res/layout/fragment_chat.xml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_chats.xml b/app/src/main/res/layout/fragment_chats.xml new file mode 100644 index 0000000..95b412d --- /dev/null +++ b/app/src/main/res/layout/fragment_chats.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat.xml b/app/src/main/res/layout/item_chat.xml new file mode 100644 index 0000000..e2cb96e --- /dev/null +++ b/app/src/main/res/layout/item_chat.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat_bubble.xml b/app/src/main/res/layout/item_chat_bubble.xml new file mode 100644 index 0000000..cf23947 --- /dev/null +++ b/app/src/main/res/layout/item_chat_bubble.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat_date.xml b/app/src/main/res/layout/item_chat_date.xml new file mode 100644 index 0000000..d055751 --- /dev/null +++ b/app/src/main/res/layout/item_chat_date.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/layout/sheet_transfer.xml b/app/src/main/res/layout/sheet_transfer.xml new file mode 100644 index 0000000..b6041df --- /dev/null +++ b/app/src/main/res/layout/sheet_transfer.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/layout/view_chat_header.xml b/app/src/main/res/layout/view_chat_header.xml new file mode 100644 index 0000000..5cb8953 --- /dev/null +++ b/app/src/main/res/layout/view_chat_header.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml index f99f6fd..20bdbd4 100644 --- a/app/src/main/res/menu/bottom_nav_menu.xml +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -6,9 +6,9 @@ - + diff --git a/app/src/main/res/menu/drawer_menu.xml b/app/src/main/res/menu/drawer_menu.xml index b6e1fdb..d52275f 100644 --- a/app/src/main/res/menu/drawer_menu.xml +++ b/app/src/main/res/menu/drawer_menu.xml @@ -17,6 +17,9 @@ + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..7501586 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee06fdc..657d92c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,7 @@ Add Login Accounts Contacts + Chats Recent Transfers Transaction History Finances @@ -94,6 +95,7 @@ More View all your bank accounts Manage your transfer contacts + Your transfers with each person, as chats Send money to a contact Scan or generate a PayMV QR code View your recent transfers @@ -400,4 +402,36 @@ Please check your connection and reload Thijooree Connectivity issue with %s Drag to reorder + + + Search chats + Chats start from now. New BML transfers will show up here. + No transfers since %s. New BML transfers will show up here. + No chats found + You sent %s + Received %s + From %s + Received + Sent + To %s + Debit + Credit + ✓ Sent · %s + Today + Yesterday + From account + Send from + Transfer 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. -- 2.54.0 From 83ca37eadebb472cb15062c2858799e8c622bbaa Mon Sep 17 00:00:00 2001 From: blackluga Date: Sat, 26 Sep 2026 04:10:06 +0500 Subject: [PATCH 2/2] chats: business payments, new bubbles, pinning, Scan to Pay - Card / Scan to Pay payments show as business chats (from BML "Payment Completed" alerts and "Purchase" history), kept apart from people; static merchant QRs are kept so a business can be paid again from its chat, with a card picker - Scan to Pay button on the chat list; returns and re-checks after paying - Compact bubbles with ticks (alert seen / booked in history), receipt button, day totals, monthly summary, long-press actions - Personal accounts confirm with just the dialog over the chat; business profiles keep the sheet for the OTP step - From chip shows account number and balance; MVR -> USD account is blocked, USD -> MVR warns about conversion - Long-press a chat to pin it to the top Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC --- .../sh/sar/basedbank/ui/home/ChatFragment.kt | 340 ++++++++++++++++-- .../basedbank/ui/home/ChatMessagesAdapter.kt | 187 ++++++---- .../sh/sar/basedbank/ui/home/ChatsAdapter.kt | 101 +++++- .../sh/sar/basedbank/ui/home/ChatsFragment.kt | 135 ++++++- .../sar/basedbank/ui/home/TransferFragment.kt | 40 ++- .../ui/home/TransferSheetFragment.kt | 6 +- .../ui/home/transfer/BmlTransferHandler.kt | 1 + .../java/sh/sar/basedbank/util/ChatStore.kt | 116 +++++- .../{bg_chat_date.xml => bg_chat_chip.xml} | 5 +- app/src/main/res/drawable/bg_chat_summary.xml | 6 + app/src/main/res/drawable/ic_pin.xml | 9 + app/src/main/res/drawable/ic_store.xml | 12 + app/src/main/res/layout/fragment_chat.xml | 197 +++++++--- app/src/main/res/layout/fragment_chats.xml | 17 +- app/src/main/res/layout/item_chat.xml | 14 +- app/src/main/res/layout/item_chat_bubble.xml | 109 +++--- app/src/main/res/layout/item_chat_date.xml | 3 +- app/src/main/res/values/ids.xml | 1 + app/src/main/res/values/strings.xml | 42 ++- 19 files changed, 1089 insertions(+), 252 deletions(-) rename app/src/main/res/drawable/{bg_chat_date.xml => bg_chat_chip.xml} (65%) create mode 100644 app/src/main/res/drawable/bg_chat_summary.xml create mode 100644 app/src/main/res/drawable/ic_pin.xml create mode 100644 app/src/main/res/drawable/ic_store.xml 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 -- 2.54.0