Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e7cf2a24
|
||
|
|
97fe63025d
|
||
|
|
83ca37eade
|
||
|
|
da5cca79c6
|
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 33
|
versionCode = 32
|
||||||
versionName = "1.0.32"
|
versionName = "1.0.31"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name">
|
android:label="@string/app_name">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -46,24 +45,20 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LockActivity"
|
android:name=".LockActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.onboarding.OnboardingActivity"
|
android:name=".ui.onboarding.OnboardingActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.login.LoginActivity"
|
android:name=".ui.login.LoginActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustPan" />
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
@@ -74,7 +69,6 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".nfc.BmlTapToPayActivity"
|
android:name=".nfc.BmlTapToPayActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/Theme.BasedBank" />
|
android:theme="@style/Theme.BasedBank" />
|
||||||
|
|||||||
@@ -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.CredentialStore
|
||||||
import sh.sar.basedbank.util.TransferNetwork
|
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() {
|
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
private var _binding: SheetContactDetailsBinding? = null
|
private var _binding: SheetContactDetailsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
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 {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
_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.ContactsCache
|
||||||
import sh.sar.basedbank.util.TransferNetwork
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
class ContactsFragment : Fragment() {
|
class ContactsFragment : Fragment(), ContactSheetHost {
|
||||||
|
|
||||||
private var _binding: FragmentContactsBinding? = null
|
private var _binding: FragmentContactsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
@@ -182,7 +182,7 @@ class ContactsFragment : Fragment() {
|
|||||||
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun openTransfer(contact: ContactDisplay) {
|
override fun openTransfer(contact: ContactDisplay) {
|
||||||
val fragment = TransferFragment.newInstance(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.accountNumber,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.name,
|
displayName = contact.name,
|
||||||
@@ -193,7 +193,7 @@ class ContactsFragment : Fragment() {
|
|||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun confirmDelete(contact: ContactDisplay) {
|
override fun confirmDelete(contact: ContactDisplay) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
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) }
|
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -106,57 +104,6 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
if (securitySet) lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Payment guard ─────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// The manifest has this activity handle theme, language, font-size and display-size changes
|
|
||||||
// itself, because recreating it mid-payment tears down the screen waiting on the bank's
|
|
||||||
// answer — the money can move with nothing left to say so. Those changes still need a
|
|
||||||
// recreate to re-inflate with the new resources, so it runs straight away when nothing is in
|
|
||||||
// flight and otherwise waits until the last payment finishes.
|
|
||||||
|
|
||||||
private var paymentsInFlight = 0
|
|
||||||
private var recreatePending = false
|
|
||||||
private var lastConfig: Configuration? = null
|
|
||||||
|
|
||||||
/** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */
|
|
||||||
inner class PaymentGuard internal constructor() {
|
|
||||||
private var ended = false
|
|
||||||
fun end() {
|
|
||||||
if (ended) return
|
|
||||||
ended = true
|
|
||||||
paymentsInFlight--
|
|
||||||
if (paymentsInFlight == 0 && recreatePending) {
|
|
||||||
recreatePending = false
|
|
||||||
// Posted so a receipt screen committed in the same pass is saved with the state
|
|
||||||
binding.root.post { recreate() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */
|
|
||||||
fun beginPayment(owner: LifecycleOwner): PaymentGuard {
|
|
||||||
paymentsInFlight++
|
|
||||||
val guard = PaymentGuard()
|
|
||||||
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
|
||||||
override fun onDestroy(owner: LifecycleOwner) = guard.end()
|
|
||||||
})
|
|
||||||
return guard
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
||||||
super.onConfigurationChanged(newConfig)
|
|
||||||
val previous = lastConfig
|
|
||||||
lastConfig = Configuration(newConfig)
|
|
||||||
// Size and orientation changes are handled in place; these need fresh resources.
|
|
||||||
val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or
|
|
||||||
android.content.pm.ActivityInfo.CONFIG_LOCALE or
|
|
||||||
android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or
|
|
||||||
android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or
|
|
||||||
android.content.pm.ActivityInfo.CONFIG_DENSITY
|
|
||||||
if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return
|
|
||||||
if (paymentsInFlight > 0) recreatePending = true else recreate()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun lockApp() = lock()
|
fun lockApp() = lock()
|
||||||
|
|
||||||
fun notifyWheelLockTap() {
|
fun notifyWheelLockTap() {
|
||||||
@@ -189,7 +136,6 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
}
|
}
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
lastConfig = Configuration(resources.configuration)
|
|
||||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
@@ -225,21 +171,12 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
// The app bar only pads for the status bar. In landscape the navigation bar (and any
|
|
||||||
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
|
|
||||||
// draw underneath it, out of reach.
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
|
|
||||||
val sides = insets.getInsets(
|
|
||||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
|
||||||
v.updatePadding(left = sides.left, right = sides.right)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||||
val frag = when (item.itemId) {
|
val frag = when (item.itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
|
R.id.nav_chats -> ChatsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
@@ -537,6 +474,7 @@ fun applyNavLabelVisibility() {
|
|||||||
val dest = fragment ?: when (itemId) {
|
val dest = fragment ?: when (itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
|
R.id.nav_chats -> ChatsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
|
|||||||
@@ -56,7 +56,4 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* for HTTP 5xx server errors from specific banks.
|
* for HTTP 5xx server errors from specific banks.
|
||||||
*/
|
*/
|
||||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
||||||
|
|
||||||
/** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */
|
|
||||||
var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ object NavCustomization {
|
|||||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
val ALL_SWAPPABLE = listOf(
|
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_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_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_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),
|
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(
|
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_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),
|
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
.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")
|
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ object NavCustomization {
|
|||||||
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
|
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_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),
|
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ object NavCustomization {
|
|||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
|
.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_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")
|
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,11 +123,15 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
stackAdapter = CardStackAdapter()
|
val screenW = resources.displayMetrics.widthPixels
|
||||||
|
val peekPx = screenW / 8
|
||||||
|
cardWidth = screenW - 2 * peekPx
|
||||||
|
|
||||||
|
stackAdapter = CardStackAdapter(cardWidth)
|
||||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
binding.rvCards.adapter = stackAdapter
|
binding.rvCards.adapter = stackAdapter
|
||||||
|
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
||||||
binding.rvCards.clipToPadding = false
|
binding.rvCards.clipToPadding = false
|
||||||
applyCarouselWidth()
|
|
||||||
|
|
||||||
val snapHelper = PagerSnapHelper()
|
val snapHelper = PagerSnapHelper()
|
||||||
snapHelper.attachToRecyclerView(binding.rvCards)
|
snapHelper.attachToRecyclerView(binding.rvCards)
|
||||||
@@ -633,13 +637,8 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Held while tap mode is up: recreating the activity would clear the NFC payment token. */
|
|
||||||
private var tapGuard: HomeActivity.PaymentGuard? = null
|
|
||||||
|
|
||||||
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||||
isTapMode = enabled
|
isTapMode = enabled
|
||||||
tapGuard?.end()
|
|
||||||
tapGuard = if (enabled) (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) else null
|
|
||||||
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||||
if (enabled) enterTapMode(item!!) else exitTapMode()
|
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||||
}
|
}
|
||||||
@@ -960,25 +959,6 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sizes the carousel from the window width: each card leaves a 1/8 peek on either side. */
|
|
||||||
private fun applyCarouselWidth() {
|
|
||||||
val screenW = resources.displayMetrics.widthPixels
|
|
||||||
val peekPx = screenW / 8
|
|
||||||
cardWidth = screenW - 2 * peekPx
|
|
||||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HomeActivity handles size changes itself (rotation, split screen) rather than being
|
|
||||||
// recreated, so the carousel has to re-measure for the new width on its own.
|
|
||||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
|
||||||
super.onConfigurationChanged(newConfig)
|
|
||||||
if (_binding == null) return
|
|
||||||
applyCarouselWidth()
|
|
||||||
stackAdapter.notifyDataSetChanged()
|
|
||||||
binding.rvCards.scrollToPosition(currentCardPosition)
|
|
||||||
binding.rvCards.post { if (_binding != null) applyCardScales() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyCardScales() {
|
private fun applyCardScales() {
|
||||||
val rv = binding.rvCards
|
val rv = binding.rvCards
|
||||||
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
||||||
@@ -1075,7 +1055,7 @@ class CardsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class CardStackAdapter : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
||||||
private var items: List<CardItem> = emptyList()
|
private var items: List<CardItem> = emptyList()
|
||||||
|
|
||||||
fun update(newItems: List<CardItem>) {
|
fun update(newItems: List<CardItem>) {
|
||||||
@@ -1090,8 +1070,6 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.bind(items[position])
|
holder.bind(items[position])
|
||||||
// Re-applied on every bind so a width change reaches recycled holders too
|
|
||||||
holder.itemView.layoutParams.width = cardWidth
|
|
||||||
// Pre-scale based on data position so initial render and off-screen cards are correct
|
// Pre-scale based on data position so initial render and off-screen cards are correct
|
||||||
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
||||||
val scale = 1f - 0.18f * fraction
|
val scale = 1f - 0.18f * fraction
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
|||||||
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.TransferDraft
|
|
||||||
import sh.sar.basedbank.util.AccountListParser
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
@@ -68,16 +67,7 @@ class TransferFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
/**
|
private var selectedAccount: BankAccount? = null
|
||||||
* The form lives on the activity's ViewModel (see [TransferDraft]) so a tab switch or a
|
|
||||||
* theme/language recreation repaints it rather than starting over. The properties below are
|
|
||||||
* the fragment's view of it.
|
|
||||||
*/
|
|
||||||
private val draft: TransferDraft get() = viewModel.transferDraft
|
|
||||||
|
|
||||||
private var selectedAccount: BankAccount?
|
|
||||||
get() = draft.selectedAccount
|
|
||||||
set(value) { draft.selectedAccount = value }
|
|
||||||
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
|
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,36 +78,21 @@ class TransferFragment : Fragment() {
|
|||||||
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
|
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
|
||||||
|
|
||||||
// Resolved recipient info — set after successful lookup or prefill
|
// Resolved recipient info — set after successful lookup or prefill
|
||||||
private var resolvedAccountNumber: String
|
private var resolvedAccountNumber = ""
|
||||||
get() = draft.resolvedAccountNumber
|
private var resolvedRecipientName = ""
|
||||||
set(value) { draft.resolvedAccountNumber = value }
|
private var resolvedBankName = ""
|
||||||
private var resolvedRecipientName: String
|
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
||||||
get() = draft.resolvedRecipientName
|
private var loadedToPhoto: Bitmap? = null
|
||||||
set(value) { draft.resolvedRecipientName = value }
|
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||||
private var resolvedBankName: String
|
private var resolvedToOwnAccount: BankAccount? = null
|
||||||
get() = draft.resolvedBankName
|
|
||||||
set(value) { draft.resolvedBankName = value }
|
|
||||||
private var loadedToPhoto: Bitmap?
|
|
||||||
get() = draft.loadedToPhoto
|
|
||||||
set(value) { draft.loadedToPhoto = value }
|
|
||||||
private var resolvedDestCurrency: String
|
|
||||||
get() = draft.resolvedDestCurrency
|
|
||||||
set(value) { draft.resolvedDestCurrency = value }
|
|
||||||
private var resolvedToOwnAccount: BankAccount?
|
|
||||||
get() = draft.resolvedToOwnAccount
|
|
||||||
set(value) { draft.resolvedToOwnAccount = value }
|
|
||||||
private var savedToSubtitle: String
|
|
||||||
get() = draft.toSubtitle
|
|
||||||
set(value) { draft.toSubtitle = value }
|
|
||||||
private var savedToColorHex: String
|
|
||||||
get() = draft.toColorHex
|
|
||||||
set(value) { draft.toColorHex = value }
|
|
||||||
private var savedToImageHash: String?
|
|
||||||
get() = draft.toImageHash
|
|
||||||
set(value) { draft.toImageHash = value }
|
|
||||||
|
|
||||||
/** Set when this view applied the fragment's arguments, so the accounts observer may too. */
|
// Form state preserved across view destroy/create when the fragment instance is cached
|
||||||
private var argsAppliedThisView = false
|
private var savedAmount = ""
|
||||||
|
private var savedRemarks = ""
|
||||||
|
private var savedToText = ""
|
||||||
|
private var savedToSubtitle = ""
|
||||||
|
private var savedToColorHex = "#607D8B"
|
||||||
|
private var savedToImageHash: String? = null
|
||||||
|
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
@@ -137,14 +112,13 @@ class TransferFragment : Fragment() {
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
currentSource = { selectedAccount },
|
currentSource = { selectedAccount },
|
||||||
selectSource = ::selectSourceAccount,
|
selectSource = ::selectSourceAccount,
|
||||||
clearSource = ::clearSourceAccount,
|
|
||||||
onStateChanged = { updateTransferButton() },
|
onStateChanged = { updateTransferButton() },
|
||||||
onTransferSuccess = { receipt, avatar ->
|
onTransferSuccess = { receipt, avatar ->
|
||||||
ReceiptStore.save(requireContext(), receipt)
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
clearForm()
|
clearForm()
|
||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
showReceipt(receipt, avatar)
|
||||||
}
|
}
|
||||||
).also { bmlHandler = it }
|
).also { bmlHandler = it }
|
||||||
|
|
||||||
@@ -184,7 +158,7 @@ class TransferFragment : Fragment() {
|
|||||||
clearForm()
|
clearForm()
|
||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
showReceipt(receipt, avatar)
|
||||||
}
|
}
|
||||||
).also { mfaisaHandler = it }
|
).also { mfaisaHandler = it }
|
||||||
|
|
||||||
@@ -279,17 +253,21 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_BML_QR_URL = "bml_qr_url"
|
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||||
private const val ARG_AUTO_SCAN = "auto_scan"
|
private const val ARG_AUTO_SCAN = "auto_scan"
|
||||||
private const val ARG_BML_TXN_ID = "bml_txn_id"
|
private const val ARG_BML_TXN_ID = "bml_txn_id"
|
||||||
/** Set once the arguments have been applied, so later views restore the draft instead. */
|
private const val ARG_RETURN_ON_SUCCESS = "return_on_success"
|
||||||
private const val ARG_APPLIED = "args_applied"
|
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 {
|
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
|
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null, returnOnSuccess: Boolean = false) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_BML_QR_URL, qrUrl)
|
putString(ARG_BML_QR_URL, qrUrl)
|
||||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
|
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +301,9 @@ class TransferFragment : Fragment() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
amount: String?,
|
amount: String?,
|
||||||
remarks: String?,
|
remarks: String?,
|
||||||
fromAccountNumber: String? = null
|
fromAccountNumber: String? = null,
|
||||||
|
returnOnSuccess: Boolean = false,
|
||||||
|
autoConfirm: Boolean = false
|
||||||
) = TransferFragment().apply {
|
) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT, accountNumber)
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
@@ -333,26 +313,41 @@ class TransferFragment : Fragment() {
|
|||||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||||
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||||
|
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||||
|
if (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 {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentTransferBinding.inflate(inflater, container, false)
|
_binding = FragmentTransferBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
// A screen opened with its own arguments (scanned QR, picked contact…) starts a fresh
|
|
||||||
// draft, once. Every later view — tab switch back, theme recreation — restores instead.
|
|
||||||
// The flag lives in the arguments so it survives the fragment being recreated too.
|
|
||||||
val args = arguments
|
|
||||||
argsAppliedThisView = args != null && !args.getBoolean(ARG_APPLIED, false)
|
|
||||||
if (argsAppliedThisView) {
|
|
||||||
viewModel.transferDraft = TransferDraft()
|
|
||||||
args!!.putBoolean(ARG_APPLIED, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
qrLauncher = requireActivity().activityResultRegistry.register(
|
qrLauncher = requireActivity().activityResultRegistry.register(
|
||||||
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
|
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
|
||||||
) { onQrScanned(it) }
|
) { onQrScanned(it) }
|
||||||
@@ -416,19 +411,6 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
||||||
|
|
||||||
if (argsAppliedThisView) applyArguments()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
|
||||||
super.onViewStateRestored(savedInstanceState)
|
|
||||||
// Repaint here, not in onViewCreated: after a recreation the framework restores the
|
|
||||||
// EditTexts' old text in between, and that setText on the To field fires its "user
|
|
||||||
// edited the recipient" listener — which would hide a To card painted earlier.
|
|
||||||
if (!argsAppliedThisView) restoreFromDraft()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** First view of a screen opened with arguments: prefill from them. */
|
|
||||||
private fun applyArguments() {
|
|
||||||
// Pre-select contact if navigated from contacts page or QR scan
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
@@ -442,7 +424,17 @@ class TransferFragment : Fragment() {
|
|||||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
|
||||||
arguments?.getString(ARG_BML_QR_URL)?.let { openBmlQr(it) }
|
// Opened fully filled in (e.g. from a chat): go straight to the confirm dialog, once. Posted
|
||||||
|
// so the source account, picked when the accounts arrive, is selected first.
|
||||||
|
if (arguments?.getBoolean(ARG_AUTO_CONFIRM) == true) {
|
||||||
|
view.post {
|
||||||
|
if (_binding == null || arguments?.getBoolean(ARG_AUTO_CONFIRM) != true) return@post
|
||||||
|
arguments?.remove(ARG_AUTO_CONFIRM)
|
||||||
|
if (selectedAccount != null && resolvedAccountNumber.isNotBlank()) initiateTransfer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
|
||||||
arguments?.getString(ARG_BML_TXN_ID)?.let {
|
arguments?.getString(ARG_BML_TXN_ID)?.let {
|
||||||
// Shown in the To field so a failed lookup leaves the ID there to retry or correct.
|
// Shown in the To field so a failed lookup leaves the ID there to retry or correct.
|
||||||
binding.etTo.setText(it)
|
binding.etTo.setText(it)
|
||||||
@@ -452,24 +444,10 @@ class TransferFragment : Fragment() {
|
|||||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||||
launchQrScanner()
|
launchQrScanner()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Restore form state when view is recreated on the cached no-args instance
|
||||||
* Repaints a recreated view from [draft] — nothing is looked up again. The From card is
|
if (arguments == null) {
|
||||||
* repainted by the accounts observer; a BML QR lookup that never finished is retried there.
|
if (resolvedAccountNumber.isNotEmpty()) {
|
||||||
*/
|
|
||||||
private fun restoreFromDraft() {
|
|
||||||
// Amount first: a dynamic merchant QR overwrites and locks it below
|
|
||||||
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
|
|
||||||
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
|
|
||||||
val bmlQr = draft.bmlQrInfo
|
|
||||||
val mfaisaQr = draft.mfaisaQrInfo
|
|
||||||
val mfaisaRecipient = draft.mfaisaRecipient
|
|
||||||
when {
|
|
||||||
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
|
|
||||||
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
|
|
||||||
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
|
|
||||||
resolvedAccountNumber.isNotEmpty() -> {
|
|
||||||
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||||
if (ownAccount != null) {
|
if (ownAccount != null) {
|
||||||
showToCard(ownAccount)
|
showToCard(ownAccount)
|
||||||
@@ -485,40 +463,22 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
val photo = loadedToPhoto
|
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
||||||
if (photo != null) {
|
} else if (savedToText.isNotEmpty()) {
|
||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
binding.etTo.setText(savedToText, false)
|
||||||
binding.ivToPhoto.setImageBitmap(photo)
|
|
||||||
} else {
|
|
||||||
savedToImageHash?.let { loadToPhoto(it, isProfile = resolvedToOwnAccount != null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
|
|
||||||
}
|
}
|
||||||
|
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
||||||
|
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Restores the To-input row to its default state when a QR lookup fails. */
|
||||||
* Freezes the amount at a merchant's preset value. Unlike disabling the field this keeps it
|
internal fun resetToFieldVisibility() {
|
||||||
* at full colour — it's the figure being paid, so it should read clearly — and a lock icon
|
binding.cardToInfo.visibility = View.GONE
|
||||||
* says why it can't be typed into.
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
*/
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
internal fun setAmountLocked(locked: Boolean) {
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.etAmount.apply {
|
|
||||||
isFocusable = !locked
|
|
||||||
isFocusableInTouchMode = !locked
|
|
||||||
isCursorVisible = !locked
|
|
||||||
isLongClickable = !locked
|
|
||||||
if (locked) clearFocus()
|
|
||||||
}
|
|
||||||
binding.tilAmount.apply {
|
|
||||||
if (locked) {
|
|
||||||
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM
|
|
||||||
endIconDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_lock)
|
|
||||||
} else {
|
|
||||||
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_NONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun startLookupLoading() {
|
internal fun startLookupLoading() {
|
||||||
@@ -545,7 +505,14 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupFromDropdown() {
|
private fun setupFromDropdown() {
|
||||||
binding.btnClearFromInfo.setOnClickListener { clearSourceAccount() }
|
binding.btnClearFromInfo.setOnClickListener {
|
||||||
|
selectedAccount = null
|
||||||
|
binding.tilAmount.prefixText = null
|
||||||
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
|
binding.actvFrom.setText("", false)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
||||||
@@ -572,7 +539,7 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)?.takeIf { argsAppliedThisView }
|
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||||
if (fromNumber != null && selectedAccount == null) {
|
if (fromNumber != null && selectedAccount == null) {
|
||||||
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
@@ -584,7 +551,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
||||||
if (selectedAccount == null && argsAppliedThisView && arguments?.getString(ARG_ACCOUNT) != null) {
|
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
|
||||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
if (defaultNum != null) {
|
if (defaultNum != null) {
|
||||||
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
||||||
@@ -598,9 +565,12 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
||||||
// onViewCreated runs; a lookup can also have been cut off by leaving the tab.
|
// onViewCreated runs. Retry the lookup once sessions are available.
|
||||||
// Retry it once sessions are available.
|
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
|
||||||
draft.pendingBmlQrTarget?.let { bmlHandler().lookupQrMerchant(it) }
|
if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-render the from card when the view is recreated on a cached instance
|
// Re-render the from card when the view is recreated on a cached instance
|
||||||
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
||||||
@@ -617,7 +587,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
||||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||||
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
||||||
if (resolvedAccountNumber.isNotBlank() && draft.mfaisaRecipient == null) {
|
if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) {
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
@@ -630,8 +600,8 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.hint = getString(R.string.transfer_to)
|
binding.tilTo.hint = getString(R.string.transfer_to)
|
||||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
// Drop any M-Faisa-resolved recipient when switching banks
|
// Drop any M-Faisa-resolved recipient when switching banks
|
||||||
if (draft.mfaisaRecipient != null) {
|
if (mfaisaHandler?.recipient != null) {
|
||||||
mfaisaHandler().clearState()
|
mfaisaHandler?.clearState()
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
@@ -802,15 +772,6 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearSourceAccount() {
|
|
||||||
selectedAccount = null
|
|
||||||
binding.tilAmount.prefixText = null
|
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
|
||||||
binding.tilFrom.visibility = View.VISIBLE
|
|
||||||
binding.actvFrom.setText("", false)
|
|
||||||
updateTransferButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAmountPrefix(account: BankAccount) {
|
private fun updateAmountPrefix(account: BankAccount) {
|
||||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||||
}
|
}
|
||||||
@@ -863,13 +824,12 @@ class TransferFragment : Fragment() {
|
|||||||
setupContactDropdown()
|
setupContactDropdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Reopens the Transfer screen in BML merchant-QR mode, keeping a selected BML card as the source. */
|
||||||
* Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source.
|
|
||||||
* Done in place: reopening the screen for it rebuilt the whole form and made the To row
|
|
||||||
* vanish and reappear.
|
|
||||||
*/
|
|
||||||
private fun openBmlQr(bmlTarget: String) {
|
private fun openBmlQr(bmlTarget: String) {
|
||||||
bmlHandler().lookupQrMerchant(bmlTarget)
|
val fromCard = selectedAccount?.takeIf {
|
||||||
|
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
|
||||||
|
}
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1341,7 +1301,7 @@ class TransferFragment : Fragment() {
|
|||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToPhoto))
|
showReceipt(receipt, capturedToPhoto)
|
||||||
} else if (!ok) {
|
} else if (!ok) {
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
if (msg == "CONNECTIVITY") {
|
if (msg == "CONNECTIVITY") {
|
||||||
@@ -1528,10 +1488,6 @@ class TransferFragment : Fragment() {
|
|||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||||
dialog.setCancelable(false)
|
dialog.setCancelable(false)
|
||||||
// From here until the outcome is dismissed the payment is in flight: a theme/language
|
|
||||||
// change waits rather than recreating the screen out from under it.
|
|
||||||
val guard = (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner)
|
|
||||||
dialog.setOnDismissListener { guard?.end() }
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val spinner = CircularProgressDrawable(ctx).apply {
|
val spinner = CircularProgressDrawable(ctx).apply {
|
||||||
@@ -1692,7 +1648,6 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilFrom.visibility = View.VISIBLE
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
binding.tilAmount.prefixText = null
|
binding.tilAmount.prefixText = null
|
||||||
binding.tilAmount.isEnabled = true
|
binding.tilAmount.isEnabled = true
|
||||||
setAmountLocked(false)
|
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -1794,14 +1749,15 @@ class TransferFragment : Fragment() {
|
|||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
// Persist form state so it can be restored when the view is recreated
|
// Persist form state so it can be restored when the view is recreated
|
||||||
draft.amount = binding.etAmount.text?.toString() ?: ""
|
savedAmount = binding.etAmount.text?.toString() ?: ""
|
||||||
draft.remarks = binding.etRemarks.text?.toString() ?: ""
|
savedRemarks = binding.etRemarks.text?.toString() ?: ""
|
||||||
draft.toText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
||||||
// The bank handlers hold binding refs; drop them so the next view gets fresh ones. What
|
// The bank handlers hold binding refs; drop them so the next view gets fresh ones.
|
||||||
// they resolved lives in the draft; an in-progress BML OTP flow cannot sensibly resume.
|
// Clearing also resets any in-progress OTP flow, which cannot sensibly resume.
|
||||||
bmlHandler?.clearState()
|
bmlHandler?.clearState()
|
||||||
bmlHandler = null
|
bmlHandler = null
|
||||||
fahipayHandler = null
|
fahipayHandler = null
|
||||||
|
mfaisaHandler?.clearState()
|
||||||
mfaisaHandler = null
|
mfaisaHandler = null
|
||||||
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
|
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
|
||||||
qrLauncher = null
|
qrLauncher = null
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,8 +59,6 @@ class BmlTransferHandler(
|
|||||||
private val currentSource: () -> BankAccount?,
|
private val currentSource: () -> BankAccount?,
|
||||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
private val selectSource: (BankAccount) -> Unit,
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
/** Asks the fragment to drop the selected source and show the empty From picker. */
|
|
||||||
private val clearSource: () -> Unit,
|
|
||||||
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||||
private val onStateChanged: () -> Unit,
|
private val onStateChanged: () -> Unit,
|
||||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||||
@@ -76,18 +74,6 @@ class BmlTransferHandler(
|
|||||||
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||||
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
private var otpState = OtpState.NONE
|
private var otpState = OtpState.NONE
|
||||||
set(value) {
|
|
||||||
// The whole OTP flow counts as a payment in flight: a theme change mid-way would
|
|
||||||
// otherwise recreate the screen between initiate and confirm.
|
|
||||||
if (field == OtpState.NONE && value != OtpState.NONE) {
|
|
||||||
otpGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
|
||||||
} else if (value == OtpState.NONE) {
|
|
||||||
otpGuard?.end()
|
|
||||||
otpGuard = null
|
|
||||||
}
|
|
||||||
field = value
|
|
||||||
}
|
|
||||||
private var otpGuard: HomeActivity.PaymentGuard? = null
|
|
||||||
private var otpChannel: String? = null
|
private var otpChannel: String? = null
|
||||||
|
|
||||||
private data class PendingTransfer(
|
private data class PendingTransfer(
|
||||||
@@ -107,15 +93,14 @@ class BmlTransferHandler(
|
|||||||
)
|
)
|
||||||
private var pendingTransfer: PendingTransfer? = null
|
private var pendingTransfer: PendingTransfer? = null
|
||||||
|
|
||||||
// Merchant QR state lives in the draft so it outlives this handler (dropped with the view).
|
|
||||||
private val draft get() = viewModel.transferDraft
|
|
||||||
|
|
||||||
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||||
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
|
var qrInfo: BmlQrPayInfo? = null
|
||||||
|
private set
|
||||||
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||||
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
|
private var gatewayQr = false
|
||||||
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
|
/** Prevents re-running the lookup after the user clears the merchant. */
|
||||||
private var qrLookupInFlight = false
|
var qrLookupAttempted = false
|
||||||
|
private set
|
||||||
|
|
||||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -170,11 +155,10 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
fun clearQrMerchant() {
|
fun clearQrMerchant() {
|
||||||
draft.pendingBmlQrTarget = null
|
|
||||||
if (qrInfo == null) return
|
if (qrInfo == null) return
|
||||||
draft.bmlQrInfo = null
|
qrInfo = null
|
||||||
draft.bmlGatewayQr = false
|
gatewayQr = false
|
||||||
fragment.setAmountLocked(false)
|
binding.tilAmount.isEnabled = true
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -189,25 +173,18 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode.
|
|
||||||
* Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment
|
|
||||||
* retries once sessions load (cold start) or when the view comes back (tab switched away
|
|
||||||
* mid-lookup).
|
|
||||||
*/
|
|
||||||
fun lookupQrMerchant(qrUrl: String) {
|
fun lookupQrMerchant(qrUrl: String) {
|
||||||
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
|
qrLookupAttempted = true
|
||||||
val draft = this.draft
|
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
||||||
draft.pendingBmlQrTarget = qrUrl
|
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||||
if (qrLookupInFlight) return
|
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||||
val session = app.anyBmlSession() ?: return
|
|
||||||
qrLookupInFlight = true
|
|
||||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
|
||||||
// The To row stays on screen with a spinner while loading and is only swapped for the
|
// Lock the "To" input row while loading
|
||||||
// merchant card once there is a merchant to show — hiding it up front left a gap that
|
binding.tilTo.visibility = View.GONE
|
||||||
// made the form jump twice.
|
binding.btnPickContact.visibility = View.GONE
|
||||||
fragment.startLookupLoading()
|
binding.btnScanQr.visibility = View.GONE
|
||||||
host?.setRefreshing(true)
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -215,32 +192,19 @@ class BmlTransferHandler(
|
|||||||
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||||
}
|
}
|
||||||
host?.setRefreshing(false)
|
host?.setRefreshing(false)
|
||||||
qrLookupInFlight = false
|
|
||||||
if (fragment.view == null) return@launch
|
|
||||||
if (draft !== viewModel.transferDraft) return@launch
|
|
||||||
fragment.stopLookupLoading()
|
|
||||||
// Superseded: cleared meanwhile, or another QR was opened while this one ran
|
|
||||||
val latest = draft.pendingBmlQrTarget
|
|
||||||
if (latest != qrUrl) {
|
|
||||||
latest?.let { lookupQrMerchant(it) }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
draft.pendingBmlQrTarget = null
|
|
||||||
val info = result.getOrNull()
|
val info = result.getOrNull()
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
// An expired or rejected QR is BML telling us something specific — show its own
|
// An expired or rejected QR is BML telling us something specific — show its own
|
||||||
// wording and stay put with the To row as it was, rather than bouncing the user
|
// wording and stay put with the To row restored, rather than bouncing the user out
|
||||||
// out of the screen they just scanned from.
|
// of the screen they just scanned from.
|
||||||
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||||
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||||
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||||
|
fragment.resetToFieldVisibility()
|
||||||
onStateChanged()
|
onStateChanged()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
draft.bmlQrInfo = info
|
qrInfo = info
|
||||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount
|
|
||||||
// and need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
|
||||||
draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
|
||||||
if (info.amount == 0.0) {
|
if (info.amount == 0.0) {
|
||||||
RecentsCache.save(ctx, RecentPick(
|
RecentsCache.save(ctx, RecentPick(
|
||||||
accountNumber = "bmlqr:$qrUrl",
|
accountNumber = "bmlqr:$qrUrl",
|
||||||
@@ -252,13 +216,7 @@ class BmlTransferHandler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the To row before touching the source: repainting the From card re-syncs the
|
// Auto-select the user's default BML card if no card was pre-selected
|
||||||
// picker/scan buttons to the To row's visibility.
|
|
||||||
hideToRow()
|
|
||||||
|
|
||||||
// Only a BML card can pay a merchant QR — drop any other source, then auto-select
|
|
||||||
// the user's default card if no card was pre-selected
|
|
||||||
if (currentSource()?.let { isCard(it) } == false) clearSource()
|
|
||||||
if (currentSource() == null) {
|
if (currentSource() == null) {
|
||||||
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||||
if (defaultNum != null) {
|
if (defaultNum != null) {
|
||||||
@@ -271,17 +229,7 @@ class BmlTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showQrMerchant(info)
|
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a
|
|
||||||
* recreated view restores it — no network involved.
|
|
||||||
*/
|
|
||||||
fun showQrMerchant(info: BmlQrPayInfo) {
|
|
||||||
hideToRow()
|
|
||||||
// Clear button hidden (can't change recipient for QR)
|
|
||||||
binding.tvToAccountName.text = info.merchantName
|
binding.tvToAccountName.text = info.merchantName
|
||||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
binding.tvToAccountDetails.visibility = View.GONE
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
@@ -293,7 +241,7 @@ class BmlTransferHandler(
|
|||||||
// Pre-fill amount if dynamic QR
|
// Pre-fill amount if dynamic QR
|
||||||
if (info.amount > 0.0) {
|
if (info.amount > 0.0) {
|
||||||
binding.etAmount.setText("%.2f".format(info.amount))
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
fragment.setAmountLocked(true)
|
binding.tilAmount.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remarks not applicable for merchant QR payments
|
// Remarks not applicable for merchant QR payments
|
||||||
@@ -302,11 +250,6 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
onStateChanged()
|
onStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideToRow() {
|
|
||||||
binding.tilTo.visibility = View.GONE
|
|
||||||
binding.btnPickContact.visibility = View.GONE
|
|
||||||
binding.btnScanQr.visibility = View.GONE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -408,6 +351,7 @@ class BmlTransferHandler(
|
|||||||
) {
|
) {
|
||||||
fragment.clearForm()
|
fragment.clearForm()
|
||||||
host?.triggerRefresh()
|
host?.triggerRefresh()
|
||||||
|
fragment.returnToCallerIfRequested()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
|||||||
@@ -54,9 +54,8 @@ class FahipayTransferHandler(
|
|||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
/** The service picked for the current recipient; null until a lookup resolves one. */
|
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||||
var service: FahipayService?
|
var service: FahipayService? = null
|
||||||
get() = viewModel.transferDraft.fahipayService
|
private set
|
||||||
private set(value) { viewModel.transferDraft.fahipayService = value }
|
|
||||||
|
|
||||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||||
|
|||||||
@@ -68,29 +68,16 @@ class MfaisaTransferHandler(
|
|||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
private val host get() = fragment.activity as? HomeActivity
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
// Resolved state lives in the draft so it outlives this handler (dropped with the view).
|
|
||||||
private val draft get() = viewModel.transferDraft
|
|
||||||
|
|
||||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||||
var recipient: MfaisaTransferClient.Recipient?
|
var recipient: MfaisaTransferClient.Recipient? = null
|
||||||
get() = draft.mfaisaRecipient
|
private set
|
||||||
private set(value) { draft.mfaisaRecipient = value }
|
|
||||||
|
|
||||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||||
var qrInfo: MfaisaQrPayClient.QrMerchant?
|
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||||
get() = draft.mfaisaQrInfo
|
private set
|
||||||
private set(value) { draft.mfaisaQrInfo = value }
|
|
||||||
|
|
||||||
private var lookupInFlight = false
|
private var lookupInFlight = false
|
||||||
|
|
||||||
/** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */
|
|
||||||
private var transferGuard: HomeActivity.PaymentGuard? = null
|
|
||||||
|
|
||||||
private fun endTransferFlow() {
|
|
||||||
transferGuard?.end()
|
|
||||||
transferGuard = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||||
@@ -167,8 +154,6 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
binding.btnTransfer.isEnabled = false
|
binding.btnTransfer.isEnabled = false
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
endTransferFlow()
|
|
||||||
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val refId = try {
|
val refId = try {
|
||||||
@@ -176,7 +161,6 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
endTransferFlow()
|
|
||||||
showError(e)
|
showError(e)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -199,7 +183,7 @@ class MfaisaTransferHandler(
|
|||||||
fun clearQrMerchant() {
|
fun clearQrMerchant() {
|
||||||
if (qrInfo == null) return
|
if (qrInfo == null) return
|
||||||
qrInfo = null
|
qrInfo = null
|
||||||
fragment.setAmountLocked(false)
|
binding.tilAmount.isEnabled = true
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -225,8 +209,10 @@ class MfaisaTransferHandler(
|
|||||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||||
|
|
||||||
// The To row stays up with a spinner until there is a merchant to swap in
|
// Lock the "To" input row while loading
|
||||||
fragment.startLookupLoading()
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
host?.setRefreshing(true)
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -241,10 +227,9 @@ class MfaisaTransferHandler(
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
host?.setRefreshing(false)
|
host?.setRefreshing(false)
|
||||||
if (fragment.view == null) return@launch
|
|
||||||
fragment.stopLookupLoading()
|
|
||||||
if (merchant == null) {
|
if (merchant == null) {
|
||||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||||
|
fragment.resetToFieldVisibility()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
qrInfo = merchant
|
qrInfo = merchant
|
||||||
@@ -263,19 +248,7 @@ class MfaisaTransferHandler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
showQrMerchant(merchant)
|
// Show merchant in the "To" card — clear button is the only way to back out
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a
|
|
||||||
* recreated view restores it — no network involved.
|
|
||||||
*/
|
|
||||||
fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) {
|
|
||||||
// Clear button is the only way to back out
|
|
||||||
binding.tilTo.visibility = View.GONE
|
|
||||||
binding.btnPickContact.visibility = View.GONE
|
|
||||||
binding.btnScanQr.visibility = View.GONE
|
|
||||||
binding.tvToAccountName.text = merchant.merchantName
|
binding.tvToAccountName.text = merchant.merchantName
|
||||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||||
binding.tvToAccountDetails.visibility = View.GONE
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
@@ -288,11 +261,12 @@ class MfaisaTransferHandler(
|
|||||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||||
fragment.setAmountLocked(true)
|
binding.tilAmount.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onRecipientChanged()
|
onRecipientChanged()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||||
@@ -423,8 +397,7 @@ class MfaisaTransferHandler(
|
|||||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||||
|
|
||||||
/** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */
|
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||||
fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) {
|
|
||||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||||
// card view, so we just populate its text fields and toggle visibility.
|
// card view, so we just populate its text fields and toggle visibility.
|
||||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||||
@@ -440,7 +413,7 @@ class MfaisaTransferHandler(
|
|||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
if (saveRecent) RecentsCache.save(ctx, RecentPick(
|
RecentsCache.save(ctx, RecentPick(
|
||||||
accountNumber = r.msisdn,
|
accountNumber = r.msisdn,
|
||||||
displayName = r.name.ifBlank { r.msisdn },
|
displayName = r.name.ifBlank { r.msisdn },
|
||||||
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||||
@@ -497,7 +470,7 @@ class MfaisaTransferHandler(
|
|||||||
refId: String,
|
refId: String,
|
||||||
errorMsg: String?
|
errorMsg: String?
|
||||||
) {
|
) {
|
||||||
val view = fragment.view ?: run { endTransferFlow(); return }
|
val view = fragment.view ?: return
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val colorMuted = MaterialColors.getColor(
|
val colorMuted = MaterialColors.getColor(
|
||||||
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||||
@@ -586,7 +559,6 @@ class MfaisaTransferHandler(
|
|||||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||||
d.dismiss()
|
d.dismiss()
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
endTransferFlow()
|
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
.show()
|
.show()
|
||||||
@@ -611,7 +583,6 @@ class MfaisaTransferHandler(
|
|||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
endTransferFlow()
|
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
bank = "MFAISA",
|
bank = "MFAISA",
|
||||||
amount = amountValue,
|
amount = amountValue,
|
||||||
@@ -636,7 +607,6 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
endTransferFlow()
|
|
||||||
showError(e)
|
showError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package sh.sar.basedbank.ui.home.transfer
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
|
||||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything the Transfer screen has filled in or resolved so far: source, recipient, form
|
|
||||||
* text and any loaded merchant QR.
|
|
||||||
*
|
|
||||||
* Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives
|
|
||||||
* both the view (switching tabs) and the fragment instance (a theme or language change
|
|
||||||
* recreates the activity) — the screen is repainted from here instead of re-running lookups.
|
|
||||||
* A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh
|
|
||||||
* draft.
|
|
||||||
*/
|
|
||||||
class TransferDraft {
|
|
||||||
var selectedAccount: BankAccount? = null
|
|
||||||
|
|
||||||
// Resolved recipient — set after a successful lookup or prefill
|
|
||||||
var resolvedAccountNumber = ""
|
|
||||||
var resolvedRecipientName = ""
|
|
||||||
var resolvedBankName = ""
|
|
||||||
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
|
||||||
var loadedToPhoto: Bitmap? = null
|
|
||||||
var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
|
||||||
var resolvedToOwnAccount: BankAccount? = null
|
|
||||||
var toSubtitle = ""
|
|
||||||
var toColorHex = "#607D8B"
|
|
||||||
var toImageHash: String? = null
|
|
||||||
|
|
||||||
// Form text, captured when the view goes away
|
|
||||||
var amount = ""
|
|
||||||
var remarks = ""
|
|
||||||
var toText = ""
|
|
||||||
|
|
||||||
// BML merchant QR
|
|
||||||
var bmlQrInfo: BmlQrPayInfo? = null
|
|
||||||
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
|
|
||||||
var bmlGatewayQr = false
|
|
||||||
/** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */
|
|
||||||
var pendingBmlQrTarget: String? = null
|
|
||||||
|
|
||||||
// M-Faisa
|
|
||||||
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
|
|
||||||
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
|
|
||||||
|
|
||||||
// Fahipay
|
|
||||||
var fahipayService: FahipayService? = null
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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
|
||||||
|
/*
|
||||||
|
* BML's account validation is the same search the Transfer page uses, and BML rate-limits it
|
||||||
|
* per user ("searched too many times"). Lookups are a background nicety, so they get a small
|
||||||
|
* budget and back off at the first refusal, leaving the user's own searches alone.
|
||||||
|
*/
|
||||||
|
private const val MAX_LOOKUPS_PER_RUN = 3
|
||||||
|
private const val MIN_RUN_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||||
|
private const val MAX_LOOKUPS_PER_DAY = 15
|
||||||
|
private const val BACKOFF_MS = 24L * 60 * 60 * 1000
|
||||||
|
private const val BUDGET_PREFS = "chat_name_lookup_budget"
|
||||||
|
|
||||||
|
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] (most important first) that are unknown or stale, within the
|
||||||
|
* budget above. Network I/O — call on Dispatchers.IO.
|
||||||
|
*/
|
||||||
|
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val budget = context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE)
|
||||||
|
if (now < budget.getLong("blockedUntil", 0L)) return
|
||||||
|
if (now - budget.getLong("lastRunAt", 0L) < MIN_RUN_INTERVAL_MS) return
|
||||||
|
val today = now / (24L * 60 * 60 * 1000)
|
||||||
|
val usedToday = if (budget.getLong("day", -1L) == today) budget.getInt("count", 0) else 0
|
||||||
|
val allowed = minOf(MAX_LOOKUPS_PER_RUN, MAX_LOOKUPS_PER_DAY - usedToday)
|
||||||
|
if (allowed <= 0) return
|
||||||
|
|
||||||
|
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(allowed)
|
||||||
|
if (due.isEmpty()) return
|
||||||
|
|
||||||
|
val client = BmlValidateClient()
|
||||||
|
val found = mutableMapOf<String, Entry>()
|
||||||
|
var used = 0
|
||||||
|
for (account in due) {
|
||||||
|
used++
|
||||||
|
val v = try { client.validateAccount(session, account) } catch (_: Exception) { null }
|
||||||
|
if (v == null || v.name.isBlank()) {
|
||||||
|
// Refused, limited, or unknown: stop for the day rather than keep asking. The
|
||||||
|
// account is marked failed so it's retried later, not first in line tomorrow.
|
||||||
|
found[account] = Entry("", "", now)
|
||||||
|
budget.edit().putLong("blockedUntil", now + BACKOFF_MS).apply()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
found[account] = Entry(v.name.trim(), v.currency, now)
|
||||||
|
}
|
||||||
|
budget.edit()
|
||||||
|
.putLong("lastRunAt", now)
|
||||||
|
.putLong("day", today)
|
||||||
|
.putInt("count", usedToday + used)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = load(context).toMutableMap()
|
||||||
|
current.putAll(found)
|
||||||
|
save(context, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) = synchronized(lock) {
|
||||||
|
File(context.filesDir, FILE_NAME).delete()
|
||||||
|
context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
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,532 @@
|
|||||||
|
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
|
||||||
|
// Only what the saved data can't answer, most useful first: accounts sent to that aren't
|
||||||
|
// contacts, then contacts saved without a separate real name. SWIFT (foreign) beneficiaries
|
||||||
|
// can't be looked up through BML.
|
||||||
|
val contactAccounts = contacts.mapTo(HashSet()) { it.benefAccount }
|
||||||
|
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
|
||||||
|
.filter { it.isNotBlank() && it !in contactAccounts }
|
||||||
|
val nameless = contacts.filter {
|
||||||
|
it.benefType != "S" && it.benefAccount.isNotBlank() &&
|
||||||
|
(it.benefName.isBlank() || normalise(it.benefName) == normalise(it.benefNickName))
|
||||||
|
}.map { it.benefAccount }
|
||||||
|
val toResolve = sentTo + nameless
|
||||||
|
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"
|
<item android:id="@+id/nav_accounts"
|
||||||
android:icon="@drawable/ic_nav_accounts"
|
android:icon="@drawable/ic_nav_accounts"
|
||||||
android:title="@string/nav_accounts" />
|
android:title="@string/nav_accounts" />
|
||||||
<item android:id="@+id/nav_contacts"
|
<item android:id="@+id/nav_chats"
|
||||||
android:icon="@drawable/ic_contacts"
|
android:icon="@drawable/ic_chat"
|
||||||
android:title="@string/nav_contacts" />
|
android:title="@string/nav_chats" />
|
||||||
<item android:id="@+id/nav_transfer"
|
<item android:id="@+id/nav_transfer"
|
||||||
android:icon="@drawable/ic_send"
|
android:icon="@drawable/ic_send"
|
||||||
android:title="@string/transfer" />
|
android:title="@string/transfer" />
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
<item android:id="@+id/nav_pay_mv_qr"
|
<item android:id="@+id/nav_pay_mv_qr"
|
||||||
android:icon="@drawable/ic_qr_scan"
|
android:icon="@drawable/ic_qr_scan"
|
||||||
android:title="@string/pay_mv_qr" />
|
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"
|
<item android:id="@+id/nav_contacts"
|
||||||
android:icon="@drawable/ic_contacts"
|
android:icon="@drawable/ic_contacts"
|
||||||
android:title="@string/nav_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_add_account">Add Login</string>
|
||||||
<string name="nav_accounts">Accounts</string>
|
<string name="nav_accounts">Accounts</string>
|
||||||
<string name="nav_contacts">Contacts</string>
|
<string name="nav_contacts">Contacts</string>
|
||||||
|
<string name="nav_chats">Chats</string>
|
||||||
<string name="nav_activities">Recent Transfers</string>
|
<string name="nav_activities">Recent Transfers</string>
|
||||||
<string name="nav_transfer_history">Transaction History</string>
|
<string name="nav_transfer_history">Transaction History</string>
|
||||||
<string name="nav_finances">Finances</string>
|
<string name="nav_finances">Finances</string>
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
<string name="nav_more">More</string>
|
<string name="nav_more">More</string>
|
||||||
<string name="nav_desc_accounts">View all your bank accounts</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_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_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_pay_mv_qr">Scan or generate a PayMV QR code</string>
|
||||||
<string name="nav_desc_activities">View your recent transfers</string>
|
<string name="nav_desc_activities">View your recent transfers</string>
|
||||||
@@ -402,4 +404,60 @@
|
|||||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
<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="connectivity_server_error">Connectivity issue with %s</string>
|
||||||
<string name="drag_to_reorder">Drag to reorder</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>
|
</resources>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.5 KiB |
@@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" baseProfile="basic" id="Layer_1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
|
||||||
xml:space="preserve">
|
|
||||||
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
|
|
||||||
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
|
|
||||||
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"/>
|
|
||||||
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
|
|
||||||
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
|
|
||||||
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
|
|
||||||
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"/>
|
|
||||||
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
|
|
||||||
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
|
|
||||||
L256,193.98999z"/>
|
|
||||||
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
|
|
||||||
C72,238.87917,85.87916,225,102.99997,225H248z"/>
|
|
||||||
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 "/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -17,12 +17,11 @@ You get your seed in one of two ways:
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
| [<img src="../../../logos/bml_logo.png" alt="BML" height="64">](01-setup-bml.md) | [<img src="../../../logos/mib_logo.png" alt="MIB" height="64">](02-setup-mib.md) |
|
1. [Set up BML](01-setup-bml.md)
|
||||||
|:---:|:---:|
|
2. [Set up MIB](02-setup-mib.md)
|
||||||
| [1. Set up BML](01-setup-bml.md) | [2. Set up MIB](02-setup-mib.md) |
|
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|
||||||
| [<img src="../../../logos/google_authenticator_logo.svg" alt="Google Authenticator" height="64">](03-export-googleauthenticator.md) | [<img src="../../../logos/microsoft_authenticator_logo.png" alt="Microsoft Authenticator" height="64">](04-export-microsoft.md) | [<img src="../../../logos/bitwarden_logo.png" alt="Bitwarden" height="64">](05-export-bitwarden.md) |
|
3. [Export from Google Authenticator](03-export-googleauthenticator.md)
|
||||||
|:---:|:---:|:---:|
|
4. [Export from Microsoft Authenticator](04-export-microsoft.md)
|
||||||
| [3. Export from Google Authenticator](03-export-googleauthenticator.md) | [4. Export from Microsoft Authenticator](04-export-microsoft.md) | [5. Export from Bitwarden](05-export-bitwarden.md) |
|
5. [Export from Bitwarden](05-export-bitwarden.md)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
- Fixed payments restarting or showing as failed when rotating or resizing the app, and the To box flickering when loading a merchant
|
|
||||||
- Transfer screen now keeps your form and merchant when switching tabs or changing theme/language, and waits for payments to finish before applying them
|
|
||||||
- Fixed-amount merchant QR or Gateway now show the amount clearly with a lock icon instead of greying it out
|
|
||||||
- App lock icon does not go behind navigation bar in landscape mode
|
|
||||||
Reference in New Issue
Block a user