Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83ca37eade
|
||
|
|
da5cca79c6
|
@@ -0,0 +1,686 @@
|
||||
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.CredentialStore
|
||||
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, 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 {
|
||||
|
||||
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(
|
||||
onReceiptClick = { openReceipt(it.receiptKey) },
|
||||
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
|
||||
)
|
||||
private var thread: ChatThread? = null
|
||||
private var fromAccounts: List<BankAccount> = emptyList()
|
||||
private var selectedFrom: BankAccount? = null
|
||||
private var selectedTo: ChatAccount? = null
|
||||
private var merchantQr: String? = null
|
||||
private var cardAccounts: List<BankAccount> = emptyList()
|
||||
private var selectedCard: BankAccount? = null
|
||||
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
|
||||
private var payStartedAt = 0L
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||
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)
|
||||
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.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
|
||||
binding.btnSaveContact.setOnClickListener { openContact() }
|
||||
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
|
||||
binding.btnSend.setOnClickListener { send() }
|
||||
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) { _, _ ->
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
updateCurrencyWarning()
|
||||
}
|
||||
|
||||
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.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)
|
||||
if (!unavailable) row.root.setOnClickListener {
|
||||
selectTo(account, matchFromCurrency = true)
|
||||
updateToolbar()
|
||||
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() {
|
||||
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()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
delay(HISTORY_CATCH_UP_MS)
|
||||
sync()
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
|
||||
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
|
||||
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). */
|
||||
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)
|
||||
// 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)
|
||||
if (!blocked) 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
|
||||
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)
|
||||
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 = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
|
||||
fromAccountNumber = from.accountNumber,
|
||||
returnOnSuccess = true,
|
||||
autoConfirm = true
|
||||
)
|
||||
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||
payStartedAt = System.currentTimeMillis()
|
||||
hideKeyboard()
|
||||
if (isBusinessProfile(from)) {
|
||||
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
|
||||
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||
} else {
|
||||
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
|
||||
// confirm dialog appears over the chat. A new send replaces any earlier one.
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
|
||||
private fun isBusinessProfile(account: BankAccount): Boolean {
|
||||
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
|
||||
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||
}
|
||||
|
||||
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
|
||||
private fun hideKeyboard() {
|
||||
val b = _binding ?: return
|
||||
val focused = b.root.findFocus() ?: return
|
||||
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
|
||||
?.hideSoftInputFromWindow(focused.windowToken, 0)
|
||||
focused.clearFocus()
|
||||
}
|
||||
|
||||
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
|
||||
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
|
||||
// Real name (when titled with a nickname), plus the account and currency when there's only one.
|
||||
val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
|
||||
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
|
||||
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
|
||||
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
|
||||
).joinToString(" · ")
|
||||
h.tvHeaderAccount.text = subtitle
|
||||
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
|
||||
val sizePx = (40 * resources.displayMetrics.density).toInt()
|
||||
if (t.isMerchant) {
|
||||
h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
|
||||
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
|
||||
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
|
||||
} else {
|
||||
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
|
||||
}
|
||||
|
||||
requireActivity().title = t.peerName
|
||||
bar.setDisplayShowTitleEnabled(false)
|
||||
bar.setDisplayShowCustomEnabled(true)
|
||||
bar.customView = h.root
|
||||
}
|
||||
|
||||
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
|
||||
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 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 onLongPress: (ChatMessage, View) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
/** Rows of the list: a day separator or a bubble. */
|
||||
sealed class Item {
|
||||
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
|
||||
data class Bubble(val message: ChatMessage) : Item()
|
||||
}
|
||||
|
||||
private val items = mutableListOf<Item>()
|
||||
private var hideAmounts = false
|
||||
private var todayLabel = ""
|
||||
private var yesterdayLabel = ""
|
||||
|
||||
/** When the person has several accounts, sent bubbles say which one the money went to. */
|
||||
var showDestination = false
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/** [messages] must be oldest first. */
|
||||
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
|
||||
this.todayLabel = todayLabel
|
||||
this.yesterdayLabel = yesterdayLabel
|
||||
items.clear()
|
||||
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
|
||||
items.add(Item.DateChip(day, dayMessages))
|
||||
dayMessages.forEach { items.add(Item.Bubble(it)) }
|
||||
}
|
||||
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).bind(item)
|
||||
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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 "#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 density.toInt()
|
||||
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
|
||||
|
||||
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
|
||||
|
||||
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||
b.btnReceipt.setColorFilter(fg)
|
||||
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
|
||||
|
||||
b.tvAccount.text = when {
|
||||
!sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
|
||||
showDestination && m.peerAccount.isNotBlank() ->
|
||||
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
|
||||
else -> m.accountDisplayName
|
||||
}
|
||||
b.tvAccount.setTextColor(fg)
|
||||
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
|
||||
b.tvTime.setTextColor(fg)
|
||||
b.tvTicks.text = ticksFor(m)
|
||||
b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
|
||||
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
|
||||
|
||||
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
|
||||
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDetail(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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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 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 onChatLongPress: (ChatThread) -> Unit
|
||||
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
|
||||
|
||||
private var allThreads: List<ChatThread> = emptyList()
|
||||
private var displayed: List<ChatThread> = emptyList()
|
||||
private var pinned: List<String> = emptyList()
|
||||
private var searchQuery = ""
|
||||
private var hideAmounts = false
|
||||
|
||||
/** Chat key shown as selected (long-pressed), or null. */
|
||||
var selectedKey: String? = null
|
||||
set(value) { field = value; notifyDataSetChanged() }
|
||||
|
||||
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
|
||||
allThreads = threads
|
||||
pinned = pinnedKeys
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
fun setSearch(query: String) {
|
||||
searchQuery = query
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun isPinned(key: String) = key in pinned
|
||||
|
||||
val isEmpty get() = displayed.isEmpty()
|
||||
|
||||
private fun applyFilter() {
|
||||
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()
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
holder.binding.root.setOnLongClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
|
||||
pos != RecyclerView.NO_POSITION
|
||||
}
|
||||
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 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))}"
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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 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
|
||||
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 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
|
||||
|
||||
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(
|
||||
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
|
||||
|
||||
binding.etSearch.addTextChangedListener { text ->
|
||||
adapter.setSearch(text?.toString() ?: "")
|
||||
updateEmptyView()
|
||||
}
|
||||
|
||||
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)
|
||||
.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, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
|
||||
if (_binding == null) return@launch
|
||||
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
|
||||
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() {
|
||||
actionMode?.finish()
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Int> = 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<Int>) {
|
||||
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<Int> = 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()
|
||||
}
|
||||
|
||||
@@ -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,15 +245,21 @@ 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"
|
||||
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"
|
||||
|
||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +288,9 @@ class TransferFragment : Fragment() {
|
||||
displayName: String,
|
||||
amount: String?,
|
||||
remarks: String?,
|
||||
fromAccountNumber: String? = null
|
||||
fromAccountNumber: String? = null,
|
||||
returnOnSuccess: Boolean = false,
|
||||
autoConfirm: Boolean = false
|
||||
) = TransferFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT, accountNumber)
|
||||
@@ -292,10 +300,35 @@ 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)
|
||||
if (autoConfirm) putBoolean(ARG_AUTO_CONFIRM, 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?) {
|
||||
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
|
||||
@@ -378,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) {
|
||||
@@ -1205,7 +1248,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") {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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, but don't open it by itself.
|
||||
@Suppress("DEPRECATION")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -351,6 +351,7 @@ class BmlTransferHandler(
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
fragment.returnToCallerIfRequested()
|
||||
}
|
||||
} else {
|
||||
dialog.dismiss()
|
||||
|
||||
@@ -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<String, Info> = 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<String>) {
|
||||
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<String, Entry> {
|
||||
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<String, Entry>) {
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
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 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))
|
||||
|
||||
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:<normalised real name>" or "shop:<normalised business name>"
|
||||
val peerName: String, // contact nickname, else the real name
|
||||
val realName: String, // account holder's name as the bank knows it; may be blank
|
||||
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
|
||||
val messages: List<ChatMessage>, // oldest first
|
||||
val 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 }
|
||||
/** 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 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),
|
||||
* 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 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 (.+)""")
|
||||
/** "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. */
|
||||
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 const val MERCHANT_KEY_PREFIX = "shop:"
|
||||
|
||||
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
|
||||
|
||||
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<BankContact>): List<ChatThread> {
|
||||
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() }
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Every known account, grouped under its holder's real name.
|
||||
val accountsByKey = HashMap<String, LinkedHashMap<String, ChatAccount>>()
|
||||
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 }
|
||||
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() } }
|
||||
?: 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<BankAccount>, contacts: List<BankContact>) {
|
||||
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<BankAccount>,
|
||||
since: Long,
|
||||
knownIds: Set<String>
|
||||
): List<ChatMessage> {
|
||||
val fetched = mutableListOf<ChatMessage>()
|
||||
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()
|
||||
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
|
||||
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,
|
||||
merchant = merchant
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<BankAccount>,
|
||||
since: Long
|
||||
): List<ChatMessage> {
|
||||
val client = BmlNotificationsClient()
|
||||
val notifications = LinkedHashMap<String, AppNotification>()
|
||||
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<BankAccount>): ChatMessage? {
|
||||
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
|
||||
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.takeLast(4)}",
|
||||
merchant = paid != null
|
||||
)
|
||||
}
|
||||
|
||||
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
|
||||
private fun receiptMessages(context: Context, accounts: List<BankAccount>, since: Long): List<ChatMessage> =
|
||||
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<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||
val byId = LinkedHashMap<String, ChatMessage>()
|
||||
incoming.forEach { byId[it.id] = it }
|
||||
existing.forEach { byId[it.id] = it } // stored copies already carry merged details
|
||||
|
||||
val kept = mutableListOf<ChatMessage>()
|
||||
val absorbed = HashMap<String, MutableSet<Int>>() // 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 },
|
||||
merchant = k.merchant || candidate.merchant
|
||||
)
|
||||
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
|
||||
// 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
|
||||
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()
|
||||
File(context.filesDir, PINS_FILE_NAME).delete()
|
||||
File(context.filesDir, QR_FILE_NAME).delete()
|
||||
}
|
||||
|
||||
// ─── Saved merchant QRs ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The static QR (no preset amount) last paid at the business named [merchantName], or null.
|
||||
* The app already remembers such QRs among its recents, but only the last few; they are
|
||||
* copied here so a business's QR is kept for good. Call off the main thread.
|
||||
*/
|
||||
fun merchantQr(context: Context, merchantName: String): String? = synchronized(lock) {
|
||||
val file = File(context.filesDir, QR_FILE_NAME)
|
||||
val saved = try {
|
||||
if (!file.exists()) JSONObject() else JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||
} catch (_: Exception) { JSONObject() }
|
||||
var changed = false
|
||||
for (recent in RecentsCache.load(context)) {
|
||||
if (!recent.accountNumber.startsWith(RECENT_QR_PREFIX)) continue
|
||||
val key = normalise(recent.displayName)
|
||||
val url = recent.accountNumber.removePrefix(RECENT_QR_PREFIX)
|
||||
if (key.isNotBlank() && saved.optString(key) != url) { saved.put(key, url); changed = true }
|
||||
}
|
||||
if (changed) try { file.writeText(CacheEncryption.encrypt(saved.toString())) } catch (_: Exception) {}
|
||||
|
||||
// Names differ slightly between the QR, history and alerts; accept one starting the other.
|
||||
val name = normalise(merchantName)
|
||||
val keys = saved.keys().asSequence().toList()
|
||||
val match = keys.firstOrNull { it == name }
|
||||
?: keys.filter { it.startsWith(name) || name.startsWith(it) }.maxByOrNull { it.length }
|
||||
match?.let { saved.optString(it).takeIf { url -> url.isNotBlank() } }
|
||||
}
|
||||
|
||||
// ─── Pinned chats ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Pinned chat keys, most recently pinned first. */
|
||||
fun pins(context: Context): List<String> = synchronized(lock) {
|
||||
val file = File(context.filesDir, PINS_FILE_NAME)
|
||||
if (!file.exists()) return emptyList()
|
||||
try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun setPinned(context: Context, peerKey: String, pinned: Boolean) {
|
||||
val current = pins(context).filter { it != peerKey }
|
||||
val updated = if (pinned) listOf(peerKey) + current else current
|
||||
synchronized(lock) {
|
||||
try {
|
||||
File(context.filesDir, PINS_FILE_NAME)
|
||||
.writeText(CacheEncryption.encrypt(JSONArray(updated).toString()))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(context: Context): State {
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
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),
|
||||
merchant = o.optBoolean("merchant", 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)
|
||||
put("merchant", m.merchant)
|
||||
})
|
||||
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
|
||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="?attr/colorOutline" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="14dp" />
|
||||
<solid android:color="?attr/colorSurfaceContainerHigh" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2H6V9zM14,14H6v-2h8v2zM18,8H6V6h12v2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M19.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2 7.5,3.5 6,2 4.5,3.5 3,2v20l1.5,-1.5L6,22l1.5,-1.5L9,22l1.5,-1.5L12,22l1.5,-1.5L15,22l1.5,-1.5L18,22l1.5,-1.5L21,22V2l-1.5,1.5zM19,19.09H5V4.91h14v14.18zM6,15h12v2H6zM6,11h12v2H6zM6,7h12v2H6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M4,9l1.5,-5h13L20,9M4,9v11h16V9M4,9h16M9,20v-6h6v6" />
|
||||
</vector>
|
||||
@@ -0,0 +1,330 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/toAccountBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSecondaryContainer"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/chat_to"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToNickname"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToCurrency"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chat_chip"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="3dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivToSwitch"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:rotation="90"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/saveContactBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurfaceContainerHigh"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/chat_not_in_contacts"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSaveContact"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/chat_save_contact" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/summaryCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/bg_chat_summary"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:paddingVertical="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumLabel1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumValue1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sumColumn2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumLabel2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumValue2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sumColumn3"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumLabel3"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumValue3"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvMessages"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp" />
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCannotSend"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/chat_cannot_send"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/composer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrencyWarning"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnFrom"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start|center_vertical"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
app:icon="@drawable/ic_arrow_right"
|
||||
app:iconGravity="end"
|
||||
app:iconSize="16dp"
|
||||
app:iconTint="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/amountRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAmount"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.2"
|
||||
android:hint="@string/chat_amount"
|
||||
app:boxCornerRadiusBottomEnd="28dp"
|
||||
app:boxCornerRadiusBottomStart="28dp"
|
||||
app:boxCornerRadiusTopEnd="28dp"
|
||||
app:boxCornerRadiusTopStart="28dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAmount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="numberDecimal"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilNote"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/chat_note"
|
||||
app:boxCornerRadiusBottomEnd="28dp"
|
||||
app:boxCornerRadiusBottomStart="28dp"
|
||||
app:boxCornerRadiusTopEnd="28dp"
|
||||
app:boxCornerRadiusTopStart="28dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etNote"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionSend"
|
||||
android:inputType="textCapSentences"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSend"
|
||||
style="@style/Widget.Material3.Button.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/chat_send"
|
||||
app:cornerRadius="28dp"
|
||||
app:icon="@drawable/ic_send"
|
||||
app:iconGravity="textEnd" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Hosts the Transfer page off-screen when only its confirm dialog is wanted. -->
|
||||
<FrameLayout
|
||||
android:id="@+id/hiddenTransferHost"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPayAgain"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:text="@string/chat_pay_again"
|
||||
android:visibility="gone"
|
||||
app:cornerRadius="28dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
app:startIconDrawable="@android:drawable/ic_menu_search"
|
||||
app:boxCornerRadiusTopStart="24dp"
|
||||
app:boxCornerRadiusTopEnd="24dp"
|
||||
app:boxCornerRadiusBottomStart="24dp"
|
||||
app:boxCornerRadiusBottomEnd="24dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etSearch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/chats_search_hint"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:imeOptions="actionSearch" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvChats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="65dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:gravity="center"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabScanPay"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/chat_scan_to_pay"
|
||||
app:srcCompat="@drawable/ic_qr_scan" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivAvatar"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||
app:layout_constraintEnd_toStartOf="@id/tvTime"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/tvPreview"
|
||||
app:layout_constraintVertical_chainStyle="packed" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBaseline_toBaselineOf="@id/tvName" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPreview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||
app:layout_constraintEnd_toStartOf="@id/ivPin"
|
||||
app:layout_constraintTop_toBottomOf="@id/tvName"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivPin"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/chat_pinned"
|
||||
android:src="@drawable/ic_pin"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/tvPreview"
|
||||
app:layout_constraintBottom_toBottomOf="@id/tvPreview" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/bubbleRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="3dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardBubble"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="150dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAmount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="240dp"
|
||||
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnReceipt"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="-8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/chat_view_receipt"
|
||||
android:padding="8dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_receipt"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNote"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:maxWidth="240dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="150dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:alpha="0.8"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="0.8"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTicks"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/transferContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivHeaderAvatar"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHeaderName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHeaderAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -6,9 +6,9 @@
|
||||
<item android:id="@+id/nav_accounts"
|
||||
android:icon="@drawable/ic_nav_accounts"
|
||||
android:title="@string/nav_accounts" />
|
||||
<item android:id="@+id/nav_contacts"
|
||||
android:icon="@drawable/ic_contacts"
|
||||
android:title="@string/nav_contacts" />
|
||||
<item android:id="@+id/nav_chats"
|
||||
android:icon="@drawable/ic_chat"
|
||||
android:title="@string/nav_chats" />
|
||||
<item android:id="@+id/nav_transfer"
|
||||
android:icon="@drawable/ic_send"
|
||||
android:title="@string/transfer" />
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<item android:id="@+id/nav_pay_mv_qr"
|
||||
android:icon="@drawable/ic_qr_scan"
|
||||
android:title="@string/pay_mv_qr" />
|
||||
<item android:id="@+id/nav_chats"
|
||||
android:icon="@drawable/ic_chat"
|
||||
android:title="@string/nav_chats" />
|
||||
<item android:id="@+id/nav_contacts"
|
||||
android:icon="@drawable/ic_contacts"
|
||||
android:title="@string/nav_contacts" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="action_open_contacts" type="id" />
|
||||
<item name="action_pin_chat" type="id" />
|
||||
</resources>
|
||||
@@ -85,6 +85,7 @@
|
||||
<string name="nav_add_account">Add Login</string>
|
||||
<string name="nav_accounts">Accounts</string>
|
||||
<string name="nav_contacts">Contacts</string>
|
||||
<string name="nav_chats">Chats</string>
|
||||
<string name="nav_activities">Recent Transfers</string>
|
||||
<string name="nav_transfer_history">Transaction History</string>
|
||||
<string name="nav_finances">Finances</string>
|
||||
@@ -94,6 +95,7 @@
|
||||
<string name="nav_more">More</string>
|
||||
<string name="nav_desc_accounts">View all your bank accounts</string>
|
||||
<string name="nav_desc_contacts">Manage your transfer contacts</string>
|
||||
<string name="nav_desc_chats">Your transfers with each person, as chats</string>
|
||||
<string name="nav_desc_transfer">Send money to a contact</string>
|
||||
<string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string>
|
||||
<string name="nav_desc_activities">View your recent transfers</string>
|
||||
@@ -400,4 +402,60 @@
|
||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
||||
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
||||
<string name="drag_to_reorder">Drag to reorder</string>
|
||||
|
||||
<!-- Chats -->
|
||||
<string name="chats_search_hint">Search chats</string>
|
||||
<string name="chats_empty">Chats start from now. New BML transfers will show up here.</string>
|
||||
<string name="chats_empty_since">No transfers since %s. New BML transfers will show up here.</string>
|
||||
<string name="chats_no_results">No chats found</string>
|
||||
<string name="chat_preview_sent">You sent %s</string>
|
||||
<string name="chat_preview_received">Received %s</string>
|
||||
<string name="chat_preview_paid">Paid %s</string>
|
||||
<string name="chat_today">Today</string>
|
||||
<string name="chat_yesterday">Yesterday</string>
|
||||
<string name="chat_from_account">From account</string>
|
||||
<string name="chat_pick_account">Send from</string>
|
||||
<string name="chat_pick_card">Pay with</string>
|
||||
<string name="chat_from_chip">%1$s · %2$s · %3$s</string>
|
||||
<string name="chat_currency_warning">⚠ Sending USD to an MVR account. It will be converted at BML\'s rate and can\'t be reversed.</string>
|
||||
<string name="chat_currency_blocked">MVR can\'t be sent to a USD account. Pick a USD account to send from.</string>
|
||||
<string name="chat_cant_send_mvr_to_usd">Can\'t send MVR to a USD account</string>
|
||||
<string name="chat_needs_usd_source">Needs a USD account to send from</string>
|
||||
<string name="chat_transfer_sent">Transfer sent</string>
|
||||
<string name="chat_payment_sent">Payment sent</string>
|
||||
<string name="chat_view_receipt">Receipt</string>
|
||||
<string name="chat_not_in_contacts">Not in your contacts</string>
|
||||
<string name="chat_save_contact">Save contact</string>
|
||||
<string name="chat_send">Send</string>
|
||||
<string name="chat_to">To</string>
|
||||
<string name="chat_pick_recipient_account">Send to</string>
|
||||
<string name="chat_receipt_missing">Receipt no longer available</string>
|
||||
<string name="chat_amount">Amount</string>
|
||||
<string name="chat_amount_invalid">Enter an amount</string>
|
||||
<string name="chat_cannot_send">Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.</string>
|
||||
<string name="chat_into_short">into %s</string>
|
||||
<string name="chat_account_to">%1$s → %2$s</string>
|
||||
<string name="chat_tick_seen">Seen in BML alert</string>
|
||||
<string name="chat_tick_booked">Booked in BML history</string>
|
||||
<string name="chat_note">Note</string>
|
||||
<string name="chat_pay_again">Pay again with Scan to Pay</string>
|
||||
<string name="chat_pay_business">Pay %s</string>
|
||||
<string name="chat_scan_to_pay">Scan to Pay</string>
|
||||
<string name="chat_merchant_subtitle">Business · Scan to Pay & card</string>
|
||||
<string name="chat_sum_sent">%s · sent</string>
|
||||
<string name="chat_sum_received">received</string>
|
||||
<string name="chat_sum_net">net</string>
|
||||
<string name="chat_sum_spent">Spent here in %s</string>
|
||||
<string name="chat_sum_payments">payments</string>
|
||||
<string name="chat_send_again">Send again · %s</string>
|
||||
<string name="chat_view_receipt_full">View receipt</string>
|
||||
<string name="chat_copy_reference">Copy reference</string>
|
||||
<string name="chat_reference_copied">Reference copied</string>
|
||||
<string name="chat_pinned">Pinned</string>
|
||||
<string name="chat_pin">Pin</string>
|
||||
<string name="chat_unpin">Unpin</string>
|
||||
<string name="chat_selected_one">1 selected</string>
|
||||
<string name="chat_pinned_msg">%s pinned</string>
|
||||
<string name="chat_unpinned_msg">%s unpinned</string>
|
||||
<string name="chat_undo">Undo</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user