Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb7c2ce72 | ||
|
|
a37354fc82
|
||
|
|
66568a464a
|
||
|
|
5210e6207f
|
||
|
|
369c6e4a37
|
||
|
|
fbedc631c5
|
||
|
|
7d6c192a63
|
||
|
|
e7f7590654
|
||
|
|
a048be939c
|
||
|
|
102a986201
|
||
|
|
440db13a0f
|
||
|
|
0dc81fef62
|
||
|
|
7e52b510a1
|
||
|
|
0d7833af5c
|
||
|
|
9e45e36c0a
|
||
|
|
c883b6b521
|
||
|
|
5dc37aadf0
|
||
|
|
1e3cf3420b
|
||
|
|
d98dcfd407
|
||
|
|
3b55fa30a8
|
||
|
|
7f0f2707a1
|
||
|
|
117733b766
|
||
|
|
d769df6f6b
|
||
|
|
caf0db60da
|
||
|
|
b48022d249
|
@@ -27,24 +27,21 @@ jobs:
|
|||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
|
|
||||||
- name: Extract release notes
|
- name: Extract release notes
|
||||||
run: |
|
run: |
|
||||||
|
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
|
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||||
|
|
||||||
|
if [ -f "$CHANGELOG" ]; then
|
||||||
|
cp "$CHANGELOG" release_notes.md
|
||||||
|
else
|
||||||
|
echo "No changelog found at $CHANGELOG"
|
||||||
echo "No release notes" > release_notes.md
|
echo "No release notes" > release_notes.md
|
||||||
# VERSION="${{ gitea.ref_name }}"
|
fi
|
||||||
# VERSION="${VERSION#v}"
|
|
||||||
#
|
|
||||||
# awk -v ver="$VERSION" '
|
|
||||||
# BEGIN { found=0 }
|
|
||||||
# /^## \[/ {
|
|
||||||
# if (found) exit
|
|
||||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
|
||||||
# }
|
|
||||||
# found { print }
|
|
||||||
# ' CHANGELOG.md > release_notes.md
|
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
env:
|
env:
|
||||||
@@ -53,7 +50,7 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
TITLE="${APP_NAME} ${TAG}"
|
TITLE="${APP_NAME} ${TAG}"
|
||||||
NOTES_FILE="release_notes.md"
|
NOTES_FILE="release_notes.md"
|
||||||
@@ -101,12 +98,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
CAPTION="${APP_NAME} ${TAG}"
|
CAPTION="$(printf '%s\n\n%s' "${APP_NAME} ${TAG}" "$(cat release_notes.md)" | head -c 1024)"
|
||||||
|
|
||||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||||
-F "chat_id=${TG_CHAT_ID}" \
|
-F "chat_id=${TG_CHAT_ID}" \
|
||||||
-F "document=@${ASSET_PATH}" \
|
-F "document=@${ASSET_PATH}" \
|
||||||
-F "caption=${CAPTION}"
|
--form-string "caption=${CAPTION}"
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 26
|
versionCode = 28
|
||||||
versionName = "1.0.25"
|
versionName = "1.0.27"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun setupAccountSearch() {
|
private fun setupAccountSearch() {
|
||||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||||
|
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||||
|
performLookup()
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performLookup() {
|
private fun performLookup() {
|
||||||
@@ -387,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
// Auto-fill alias with existing alias or name
|
// Auto-fill alias with existing alias or name
|
||||||
if (binding.etAlias.text.isNullOrBlank()) {
|
if (binding.etAlias.text.isNullOrBlank()) {
|
||||||
binding.etAlias.setText(validation.name)
|
binding.etAlias.setText(sh.sar.basedbank.util.bmlapi.BmlDashboardParser.toTitleCase(validation.name))
|
||||||
}
|
}
|
||||||
binding.etCurrency.setText(validation.currency)
|
binding.etCurrency.setText(validation.currency)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentChatBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
import sh.sar.basedbank.util.ChatAccount
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One person's transfers as a chat. A person can have several accounts (e.g. MVR and USD); the
|
||||||
|
* "To" bar picks which one to send to. Sending opens [TransferFragment] in a sheet, prefilled with
|
||||||
|
* the recipient, source account and amount, so the usual confirm/OTP flow still applies.
|
||||||
|
*/
|
||||||
|
class ChatFragment : Fragment(), ContactSheetHost {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private var peerKey = ""
|
||||||
|
private var header: ViewChatHeaderBinding? = null
|
||||||
|
private val adapter = ChatMessagesAdapter { openReceipt(it.receiptKey) }
|
||||||
|
private var thread: ChatThread? = null
|
||||||
|
private var fromAccounts: List<BankAccount> = emptyList()
|
||||||
|
private var selectedFrom: BankAccount? = null
|
||||||
|
private var selectedTo: ChatAccount? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
|
||||||
|
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
|
||||||
|
binding.rvMessages.adapter = adapter
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
adapter.setHideAmounts(it)
|
||||||
|
selectedFrom?.let { acc -> bindAccountRow(binding.fromAccountRow, acc) }
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
|
||||||
|
bindFromAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.cardFromAccount.setOnClickListener { showAccountPicker() }
|
||||||
|
binding.btnSaveContact.setOnClickListener { openContact() }
|
||||||
|
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
|
||||||
|
binding.btnSend.setOnClickListener { send() }
|
||||||
|
binding.etAmount.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfers started from this chat come straight back here instead of opening the receipt.
|
||||||
|
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||||
|
onTransferDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putString(ARG_PEER_KEY, peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThread() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
|
||||||
|
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
|
||||||
|
val t = threads.firstOrNull { it.peerKey == peerKey }
|
||||||
|
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
thread = t
|
||||||
|
if (t == null) return@launch
|
||||||
|
peerKey = t.peerKey
|
||||||
|
adapter.showDestination = t.accounts.size > 1
|
||||||
|
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
|
||||||
|
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
|
||||||
|
updateToolbar()
|
||||||
|
|
||||||
|
bindFromAccounts()
|
||||||
|
val previous = selectedTo
|
||||||
|
val to = t.accounts.firstOrNull { it.account == previous?.account }
|
||||||
|
?: t.accounts.firstOrNull { it.account == t.peerAccount }
|
||||||
|
selectTo(to, matchFromCurrency = previous == null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
|
||||||
|
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val t = thread ?: return
|
||||||
|
selectedTo = to
|
||||||
|
|
||||||
|
b.toAccountBar.visibility = if (to == null) View.GONE else View.VISIBLE
|
||||||
|
if (to != null) {
|
||||||
|
b.tvToAccount.text = to.account
|
||||||
|
b.tvToCurrency.text = to.currency
|
||||||
|
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
|
||||||
|
b.tvToNickname.text = nickname
|
||||||
|
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
|
||||||
|
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offer to save the selected account (or the person, when no account is known yet).
|
||||||
|
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
val canSend = to != null
|
||||||
|
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
|
||||||
|
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
// Sending to a USD account: default to a USD source account, and likewise for MVR.
|
||||||
|
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
|
||||||
|
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
|
||||||
|
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecipientPicker() {
|
||||||
|
val t = thread ?: return
|
||||||
|
if (t.accounts.size < 2) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in t.accounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
|
||||||
|
?: getString(R.string.chat_not_in_contacts)
|
||||||
|
row.tvDropdownAccountNumber.text = account.account
|
||||||
|
row.tvDropdownBalance.text = account.currency
|
||||||
|
row.tvDropdownAccountType.visibility = View.GONE
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
selectTo(account, matchFromCurrency = true)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
|
||||||
|
val pad = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(pad, pad / 2, pad, pad / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFromAccounts() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
// Default to the account last used with this person.
|
||||||
|
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
|
||||||
|
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
|
||||||
|
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
|
||||||
|
|
||||||
|
selectFrom(selectedFrom!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTransferDone() {
|
||||||
|
binding.etAmount.text = null
|
||||||
|
// The receipt shows the new bubble right away; history catches up a little later and
|
||||||
|
// replaces it with the bank's own entry.
|
||||||
|
sync()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(HISTORY_CATCH_UP_MS)
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
Snackbar.make(binding.root, R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
|
||||||
|
.setAnchorView(binding.composer)
|
||||||
|
.setAction(R.string.chat_view_receipt) {
|
||||||
|
val latest = ReceiptStore.loadAll(requireContext()).firstOrNull() ?: return@setAction
|
||||||
|
openReceipt(latest.savedAt.toString())
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReceipt(receiptKey: String) {
|
||||||
|
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
|
||||||
|
if (entry == null) {
|
||||||
|
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
if (_binding != null) loadThread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectFrom(account: BankAccount) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
selectedFrom = account
|
||||||
|
bindAccountRow(b.fromAccountRow, account)
|
||||||
|
b.tilAmount.prefixText = account.currencyName
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
|
||||||
|
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
|
||||||
|
val display = AccountListParser.from(account)
|
||||||
|
row.tvDropdownAccountName.text = account.accountBriefName
|
||||||
|
row.tvDropdownAccountNumber.text = account.accountNumber
|
||||||
|
val balance = display?.balance.orEmpty()
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
|
||||||
|
val type = display?.typeLabel.orEmpty()
|
||||||
|
row.tvDropdownAccountType.text = type
|
||||||
|
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAccountPicker() {
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in fromAccounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
bindAccountRow(row, account)
|
||||||
|
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
selectFrom(account)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val from = selectedFrom ?: return
|
||||||
|
val to = selectedTo ?: return
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0.0) {
|
||||||
|
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val transfer = TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = to.account,
|
||||||
|
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
|
||||||
|
amount = "%.2f".format(amount),
|
||||||
|
remarks = null,
|
||||||
|
fromAccountNumber = from.accountNumber,
|
||||||
|
returnOnSuccess = true
|
||||||
|
)
|
||||||
|
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||||
|
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
|
||||||
|
private fun updateToolbar() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
|
||||||
|
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
|
||||||
|
header = h
|
||||||
|
h.root.setOnClickListener { openContact() }
|
||||||
|
}
|
||||||
|
h.tvHeaderName.text = t.peerName
|
||||||
|
// Show the real account-holder name when the chat is titled with a nickname.
|
||||||
|
val subtitle = t.realName.takeIf { !it.equals(t.peerName, ignoreCase = true) }.orEmpty()
|
||||||
|
h.tvHeaderAccount.text = subtitle
|
||||||
|
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val sizePx = (40 * resources.displayMetrics.density).toInt()
|
||||||
|
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||||
|
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
|
||||||
|
|
||||||
|
requireActivity().title = t.peerName
|
||||||
|
bar.setDisplayShowTitleEnabled(false)
|
||||||
|
bar.setDisplayShowCustomEnabled(true)
|
||||||
|
bar.customView = h.root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openContact() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
|
||||||
|
if (display != null) {
|
||||||
|
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||||
|
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
|
||||||
|
} else {
|
||||||
|
// Not a saved contact yet: offer to save them, prefilled with what we know.
|
||||||
|
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
|
||||||
|
AddContactSheetFragment.newInstance(
|
||||||
|
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = selectedTo?.account,
|
||||||
|
recipientName = t.realName.ifBlank { t.peerName },
|
||||||
|
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
|
||||||
|
).show(childFragmentManager, "add_contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openTransfer(contact: ContactDisplay) {
|
||||||
|
val fragment = TransferFragment.newInstance(
|
||||||
|
accountNumber = contact.accountNumber,
|
||||||
|
displayName = contact.name,
|
||||||
|
subtitle = contact.transferSubtitle,
|
||||||
|
colorHex = contact.bankColor,
|
||||||
|
imageHash = contact.imageHash
|
||||||
|
)
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
|
||||||
|
override fun confirmDelete(contact: ContactDisplay) {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
(activity as? AppCompatActivity)?.supportActionBar?.apply {
|
||||||
|
setDisplayShowCustomEnabled(false)
|
||||||
|
setDisplayShowTitleEnabled(true)
|
||||||
|
customView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
header = null
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_PEER_KEY = "peer_key"
|
||||||
|
private const val HISTORY_CATCH_UP_MS = 10_000L
|
||||||
|
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||||
|
|
||||||
|
fun newInstance(peerKey: String) = ChatFragment().apply {
|
||||||
|
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatDateBinding
|
||||||
|
import sh.sar.basedbank.util.ChatMessage
|
||||||
|
|
||||||
|
/** Transfers with one person as chat bubbles: sent on the right, received on the left. */
|
||||||
|
class ChatMessagesAdapter(
|
||||||
|
private val onReceiptClick: (ChatMessage) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class DateChip(val label: String) : Item()
|
||||||
|
data class Bubble(val message: ChatMessage) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val items = mutableListOf<Item>()
|
||||||
|
private var hideAmounts = false
|
||||||
|
|
||||||
|
/** When the person has several accounts, sent bubbles say which one the money went to. */
|
||||||
|
var showDestination = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [messages] must be oldest first. */
|
||||||
|
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
|
||||||
|
items.clear()
|
||||||
|
var lastDay = ""
|
||||||
|
for (m in messages) {
|
||||||
|
val day = m.date.take(10)
|
||||||
|
if (day != lastDay) {
|
||||||
|
val label = when {
|
||||||
|
DateUtils.isToday(m.timeMillis) -> todayLabel
|
||||||
|
DateUtils.isToday(m.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
|
||||||
|
else -> AccountHistoryAdapter.formatDateHeader(m.date)
|
||||||
|
}
|
||||||
|
items.add(Item.DateChip(label))
|
||||||
|
lastDay = day
|
||||||
|
}
|
||||||
|
items.add(Item.Bubble(m))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
|
is Item.DateChip -> TYPE_DATE
|
||||||
|
is Item.Bubble -> TYPE_BUBBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
|
||||||
|
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (val item = items[position]) {
|
||||||
|
is Item.DateChip -> (holder as DateVH).b.tvDate.text = item.label
|
||||||
|
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateVH(val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root)
|
||||||
|
|
||||||
|
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(m: ChatMessage) {
|
||||||
|
val ctx = b.root.context
|
||||||
|
val sent = m.isSent
|
||||||
|
|
||||||
|
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
(b.tvMeta.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
|
||||||
|
// Sent: tinted like the mockup. Received: neutral grey with an outline, so the two
|
||||||
|
// read apart even on a red-tinted theme.
|
||||||
|
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
|
||||||
|
else Color.parseColor(if (night) "#2A2C2E" else "#F1F2F4")
|
||||||
|
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
|
||||||
|
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
|
||||||
|
b.cardBubble.setCardBackgroundColor(bg)
|
||||||
|
b.cardBubble.strokeWidth = if (sent) 0 else (1 * ctx.resources.displayMetrics.density).toInt()
|
||||||
|
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
|
||||||
|
b.tvLabel.setTextColor(fg)
|
||||||
|
b.tvAmount.setTextColor(fg)
|
||||||
|
b.tvAccount.setTextColor(fg)
|
||||||
|
b.tvNote.setTextColor(fg)
|
||||||
|
b.btnReceipt.setColorFilter(fg)
|
||||||
|
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||||
|
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
|
||||||
|
|
||||||
|
b.tvLabel.text = ctx.getString(if (sent) R.string.chat_sent_label else R.string.chat_received_label)
|
||||||
|
// Own account on its own line so long names aren't cut off by the receipt icon and tag.
|
||||||
|
b.tvAccount.text = when {
|
||||||
|
!sent -> ctx.getString(R.string.chat_into_label, m.accountDisplayName)
|
||||||
|
showDestination && m.peerAccount.isNotBlank() ->
|
||||||
|
ctx.getString(R.string.chat_from_to_label, m.accountDisplayName, m.peerAccount)
|
||||||
|
else -> ctx.getString(R.string.chat_from_label, m.accountDisplayName)
|
||||||
|
}
|
||||||
|
b.tvAccount.visibility = if (m.accountDisplayName.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
b.tvBadge.text = ctx.getString(if (sent) R.string.chat_debit else R.string.chat_credit)
|
||||||
|
// Debit uses the bubble's own text colour; Credit a green that stays readable in both themes.
|
||||||
|
val badgeFg = if (sent) fg else Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||||
|
b.tvBadge.setTextColor(badgeFg)
|
||||||
|
b.tvBadge.background = GradientDrawable().apply {
|
||||||
|
cornerRadius = 10 * ctx.resources.displayMetrics.density
|
||||||
|
setColor(androidx.core.graphics.ColorUtils.setAlphaComponent(badgeFg, 0x2E))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.tvAmount.text = if (hideAmounts) "${m.currency} ••••••"
|
||||||
|
else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
|
||||||
|
b.tvNote.text = m.note
|
||||||
|
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
val time = AccountHistoryAdapter.formatTime(m.date)
|
||||||
|
b.tvMeta.text = if (sent) ctx.getString(R.string.chat_sent_meta, time) else time
|
||||||
|
|
||||||
|
b.cardBubble.setOnClickListener { showDetail(m) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDetail(m: ChatMessage) {
|
||||||
|
val ctx = b.root.context
|
||||||
|
val details = buildString {
|
||||||
|
val sign = if (m.isSent) "-" else "+"
|
||||||
|
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
|
||||||
|
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
|
||||||
|
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
|
||||||
|
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
|
||||||
|
append("Account\n${m.accountDisplayName}")
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(m.peerName)
|
||||||
|
.setMessage(details)
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE = 0
|
||||||
|
private const val TYPE_BUBBLE = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBinding
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
|
||||||
|
/** Telegram-style chat list: one row per person, latest transfer as the preview. */
|
||||||
|
class ChatsAdapter(
|
||||||
|
private val onChatClick: (ChatThread) -> Unit
|
||||||
|
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var allThreads: List<ChatThread> = emptyList()
|
||||||
|
private var displayed: List<ChatThread> = emptyList()
|
||||||
|
private var searchQuery = ""
|
||||||
|
private var hideAmounts = false
|
||||||
|
|
||||||
|
fun updateThreads(threads: List<ChatThread>) {
|
||||||
|
allThreads = threads
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearch(query: String) {
|
||||||
|
searchQuery = query
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isEmpty get() = displayed.isEmpty()
|
||||||
|
|
||||||
|
private fun applyFilter() {
|
||||||
|
displayed = if (searchQuery.isBlank()) allThreads else allThreads.filter {
|
||||||
|
it.peerName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.realName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.peerAccount.contains(searchQuery) ||
|
||||||
|
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
holder.binding.root.setOnClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
|
||||||
|
}
|
||||||
|
return holder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
|
||||||
|
|
||||||
|
override fun getItemCount() = displayed.size
|
||||||
|
|
||||||
|
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(thread: ChatThread) {
|
||||||
|
val ctx = binding.root.context
|
||||||
|
val last = thread.last
|
||||||
|
binding.tvName.text = thread.peerName
|
||||||
|
|
||||||
|
val amount = if (hideAmounts) "${last.currency} ••••••"
|
||||||
|
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
|
||||||
|
binding.tvPreview.text = if (last.isSent) {
|
||||||
|
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
|
||||||
|
ctx.getString(R.string.chat_preview_sent, amount) + note
|
||||||
|
} else {
|
||||||
|
ctx.getString(R.string.chat_preview_received, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
|
||||||
|
else AccountHistoryAdapter.formatDateOnly(last.date)
|
||||||
|
|
||||||
|
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
|
||||||
|
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
|
||||||
|
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
|
||||||
|
|
||||||
|
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
|
||||||
|
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentChatsBinding
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/** Transfers grouped by person, shown as a chat list. Contacts is reachable from the toolbar. */
|
||||||
|
class ChatsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private lateinit var adapter: ChatsAdapter
|
||||||
|
private var syncedThisView = false
|
||||||
|
private var syncedWithContacts = false
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ChatsAdapter { thread ->
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
|
||||||
|
}
|
||||||
|
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.rvChats.adapter = adapter
|
||||||
|
|
||||||
|
binding.etSearch.addTextChangedListener { text ->
|
||||||
|
adapter.setSearch(text?.toString() ?: "")
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener { sync() }
|
||||||
|
|
||||||
|
requireActivity().addMenuProvider(object : MenuProvider {
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
|
||||||
|
.setIcon(R.drawable.ic_contacts)
|
||||||
|
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
if (menuItem.itemId != R.id.action_open_contacts) return false
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
// Contacts give chats a saved name and an account number to send to.
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
loadThreads()
|
||||||
|
// Contacts usually load after the first sync; sync again so their real names get looked up.
|
||||||
|
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts arrive after login; sync once they are available.
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
syncedThisView = true
|
||||||
|
binding.swipeRefresh.isRefreshing = true
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
if (contacts.isNotEmpty()) syncedWithContacts = true
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
_binding?.swipeRefresh?.isRefreshing = false
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThreads() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
adapter.updateThreads(threads)
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateEmptyView() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
|
||||||
|
if (!adapter.isEmpty) return
|
||||||
|
val since = ChatStore.sinceMillis(requireContext())
|
||||||
|
b.emptyView.text = when {
|
||||||
|
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
|
||||||
|
since > 0L -> getString(R.string.chats_empty_since,
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
|
||||||
|
else -> getString(R.string.chats_empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.SheetContactDetailsBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
/** Contact details drawer shown when a contact is tapped in [ContactsFragment] or a chat header. */
|
||||||
|
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: SheetContactDetailsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private val contactsFragment get() = parentFragment as? ContactSheetHost
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val args = requireArguments()
|
||||||
|
val contactId = args.getString(ARG_CONTACT_ID)
|
||||||
|
val profileId = args.getString(ARG_PROFILE_ID)
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId }
|
||||||
|
?.let { ContactListParser.from(it) }
|
||||||
|
if (contact == null) dismissAllowingStateLoss() else bind(contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bind(contact: ContactDisplay) {
|
||||||
|
val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2
|
||||||
|
val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||||
|
binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx))
|
||||||
|
|
||||||
|
binding.tvName.text = contact.name
|
||||||
|
|
||||||
|
binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE
|
||||||
|
// QR stays visible for every contact; greyed out where PayMV QR isn't supported yet
|
||||||
|
val canQr = canShowPayMvQr(contact)
|
||||||
|
binding.btnQr.isEnabled = canQr
|
||||||
|
binding.tvQrLabel.alpha = if (canQr) 1f else 0.38f
|
||||||
|
binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE
|
||||||
|
binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.btnTransfer.setOnClickListener {
|
||||||
|
val parent = contactsFragment
|
||||||
|
dismiss()
|
||||||
|
parent?.openTransfer(contact)
|
||||||
|
}
|
||||||
|
binding.btnQr.setOnClickListener {
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
val bank = contactBank(contact) ?: return@setOnClickListener
|
||||||
|
val qrName = contact.realName.ifBlank { contact.name }
|
||||||
|
dismiss()
|
||||||
|
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
|
||||||
|
}
|
||||||
|
binding.btnEdit.setOnClickListener {
|
||||||
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
binding.btnDelete.setOnClickListener {
|
||||||
|
val parent = contactsFragment
|
||||||
|
dismiss()
|
||||||
|
parent?.confirmDelete(contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvAccount.text = contact.accountNumber
|
||||||
|
val copy = View.OnClickListener { copyAccount(contact.accountNumber) }
|
||||||
|
binding.rowAccount.setOnClickListener(copy)
|
||||||
|
binding.btnCopyAccount.setOnClickListener(copy)
|
||||||
|
binding.btnShareAccount.setOnClickListener {
|
||||||
|
shareAccount(contact.realName.ifBlank { contact.name }, contact.accountNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name
|
||||||
|
binding.tvRealName.text = contact.realName
|
||||||
|
binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.tvCurrency.text = contact.currency ?: ""
|
||||||
|
binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.tvBank.text = contact.bankName ?: ""
|
||||||
|
binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE
|
||||||
|
val bankLogo = bankLogoRes(contact)
|
||||||
|
if (bankLogo != null) {
|
||||||
|
binding.ivBankIcon.setImageResource(bankLogo)
|
||||||
|
binding.ivBankIcon.imageTintList = null
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.sourceSection.visibility = View.GONE
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val appContext = requireContext().applicationContext
|
||||||
|
val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) }
|
||||||
|
val b = _binding ?: return@launch
|
||||||
|
if (source == null) return@launch
|
||||||
|
b.ivSourceIcon.setImageResource(source.logoRes)
|
||||||
|
b.tvSource.text = source.name
|
||||||
|
b.tvSourceBank.setText(source.bankNameRes)
|
||||||
|
b.sourceSection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which bank the contact's account is at ("BML" / "MIB"); null when we can't tell. */
|
||||||
|
private fun contactBank(contact: ContactDisplay): String? = when {
|
||||||
|
contact.network == TransferNetwork.MIB -> "MIB"
|
||||||
|
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> "BML"
|
||||||
|
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> "MIB"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
|
||||||
|
private fun bankLogoRes(contact: ContactDisplay): Int? = when (contactBank(contact)) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PayMV QR is only offered for contacts whose account is at BML for now.
|
||||||
|
* TODO: enable for MIB and other banks once their PayMV QR payloads are verified —
|
||||||
|
* PayMvQrFragment.forContact already takes the bank, so this check is the only gate.
|
||||||
|
*/
|
||||||
|
private fun canShowPayMvQr(contact: ContactDisplay): Boolean =
|
||||||
|
contactBank(contact) == "BML" &&
|
||||||
|
contact.currency?.contains("USD", ignoreCase = true) != true // PayMV QR is MVR only
|
||||||
|
|
||||||
|
private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String)
|
||||||
|
|
||||||
|
/** Which of the user's own bank profiles this contact is saved under. */
|
||||||
|
private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? {
|
||||||
|
val store = CredentialStore(context)
|
||||||
|
return when (contact.network) {
|
||||||
|
TransferNetwork.BML -> {
|
||||||
|
// BML contacts carry either the loginId or a BML profileId
|
||||||
|
val loginId = store.getBmlLoginIds().firstOrNull { loginId ->
|
||||||
|
loginId == contact.profileId ||
|
||||||
|
store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId }
|
||||||
|
} ?: return null
|
||||||
|
val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||||
|
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name)
|
||||||
|
?: return null
|
||||||
|
SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name)
|
||||||
|
}
|
||||||
|
TransferNetwork.FAHIPAY -> {
|
||||||
|
val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId ->
|
||||||
|
store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
} ?: return null
|
||||||
|
SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId ->
|
||||||
|
store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||||
|
} ?: return null
|
||||||
|
SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareAccount(accountName: String, account: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, "$accountName\n$account")
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(intent, getString(R.string.contact_share_account)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAccount(account: String) {
|
||||||
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||||
|
Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_CONTACT_ID = "contact_id"
|
||||||
|
private const val ARG_PROFILE_ID = "profile_id"
|
||||||
|
|
||||||
|
fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply {
|
||||||
|
arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||||
import sh.sar.basedbank.util.ContactDisplay
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
class ContactsAdapter(
|
class ContactsAdapter(
|
||||||
private val imageCache: MutableMap<String, Bitmap>,
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onImageNeeded: (hash: String) -> Unit,
|
private val onImageNeeded: (hash: String) -> Unit,
|
||||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
private val onContactClick: (ContactDisplay) -> Unit,
|
||||||
private val onTransferClick: (ContactDisplay) -> Unit
|
private val onTransferClick: (ContactDisplay) -> Unit
|
||||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
@@ -66,12 +65,9 @@ class ContactsAdapter(
|
|||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.btnEditContact.setOnClickListener {
|
binding.root.setOnClickListener {
|
||||||
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
binding.btnDeleteContact.setOnClickListener {
|
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
@@ -107,24 +103,22 @@ class ContactsAdapter(
|
|||||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnTransferContact.visibility =
|
binding.btnTransferContact.visibility =
|
||||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnEditContact.visibility =
|
|
||||||
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
|
||||||
binding.btnDeleteContact.visibility =
|
|
||||||
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
|
||||||
|
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
binding.ivContactPhoto.setImageBitmap(photo)
|
binding.ivContactPhoto.setImageBitmap(photo)
|
||||||
} else {
|
} else {
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
|
||||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
|
||||||
val sizePx = binding.ivContactPhoto.context.resources
|
val sizePx = binding.ivContactPhoto.context.resources
|
||||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||||
.coerceAtLeast(96)
|
.coerceAtLeast(96)
|
||||||
|
binding.ivContactPhoto.setImageBitmap(
|
||||||
|
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
@@ -139,5 +133,3 @@ class ContactsAdapter(
|
|||||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import android.text.style.StyleSpan
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
@@ -33,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!!
|
||||||
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
|
|||||||
ContactsAdapter(
|
ContactsAdapter(
|
||||||
imageCache = sharedImageCache,
|
imageCache = sharedImageCache,
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
onContactClick = { contact -> showDetails(contact) },
|
||||||
onTransferClick = { contact -> openTransfer(contact) }
|
onTransferClick = { contact -> openTransfer(contact) }
|
||||||
).also { a ->
|
).also { a ->
|
||||||
a.setFilter(page.categoryId, currentSearch)
|
a.setFilter(page.categoryId, currentSearch)
|
||||||
@@ -170,7 +177,12 @@ class ContactsFragment : Fragment() {
|
|||||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openTransfer(contact: ContactDisplay) {
|
private fun showDetails(contact: ContactDisplay) {
|
||||||
|
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||||
|
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -181,10 +193,26 @@ class ContactsFragment : Fragment() {
|
|||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: ContactDisplay) {
|
override fun confirmDelete(contact: ContactDisplay) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
val ctx = requireContext()
|
||||||
|
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
||||||
|
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||||
|
|
||||||
|
val prompt = getString(R.string.contact_delete_message, contact.name)
|
||||||
|
val message = SpannableStringBuilder(prompt).apply {
|
||||||
|
val start = prompt.indexOf(contact.name)
|
||||||
|
if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
append("\n").append(contact.accountNumber)
|
||||||
|
append("\n\n")
|
||||||
|
val warnStart = length
|
||||||
|
append(getString(R.string.contact_delete_warning))
|
||||||
|
setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog)
|
||||||
|
.setIcon(icon)
|
||||||
.setTitle(R.string.contact_delete_title)
|
.setTitle(R.string.contact_delete_title)
|
||||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
.setMessage(message)
|
||||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
|
|||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||||
.map { CardItem.Bml(it) }
|
.map { CardItem.Bml(it) }
|
||||||
val all = bmlItems + mibItems
|
val rank = credStore.loginRank()
|
||||||
|
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
|
||||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
val ordered = if (defaultNum != null) {
|
val ordered = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
|||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
|
||||||
expandedPositions.clear()
|
expandedPositions.clear()
|
||||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
val mibItems = mibDeals.map { Item.Mib(it) }
|
||||||
|
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||||
|
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
|
|||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
class FinancingFragment : Fragment() {
|
class FinancingFragment : Fragment() {
|
||||||
|
|
||||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
|||||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||||
|
|
||||||
adapter.update(latestMibDeals, bmlLoans)
|
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
|
||||||
|
val order = CredentialStore(requireContext()).getLoginOrder()
|
||||||
|
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
|
||||||
|
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
|
||||||
|
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
|
||||||
|
|
||||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
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()
|
||||||
@@ -244,9 +245,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||||
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||||
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
val cachedCards = CardsCache.load(this)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
|
||||||
val cachedFinancing = FinancingCache.load(this)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||||
@@ -473,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()
|
||||||
@@ -774,47 +776,13 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||||
|
|
||||||
val bmlClient = BmlAccountClient()
|
// Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
|
||||||
|
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
|
||||||
for (profile in savedProfiles) {
|
for (profile in savedProfiles) {
|
||||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
if (profile.profileId in hiddenProfiles) continue
|
||||||
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
|
||||||
if (saved == null) {
|
|
||||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
|
||||||
.filter { it.profileId == profile.profileId }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
|
||||||
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
|
||||||
|
|
||||||
suspend fun fetchWithSession(session: BmlSession) {
|
|
||||||
bmlClient.checkProfile(session)
|
|
||||||
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
|
||||||
app.bmlSessions[profile.profileId] = session
|
|
||||||
allAccounts += accounts
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun tryRefresh() {
|
|
||||||
if (refreshToken == null) throw Exception("No refresh token")
|
|
||||||
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
|
||||||
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
|
||||||
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
|
||||||
if (newSession.refreshToken.isNotBlank())
|
|
||||||
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
|
||||||
if (newSession.expiresAt > 0)
|
|
||||||
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
|
||||||
fetchWithSession(newSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tokenKnownExpired) {
|
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
|
||||||
tryRefresh()
|
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
|
||||||
} catch (_: AuthExpiredException) {
|
|
||||||
tryRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
refreshErrors.add("NO_INTERNET")
|
refreshErrors.add("NO_INTERNET")
|
||||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
@@ -985,15 +953,17 @@ fun applyNavLabelVisibility() {
|
|||||||
refreshBmlLoanDetails()
|
refreshBmlLoanDetails()
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshMibCards(loginId, session, profiles)
|
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filters accounts whose profileId the user has hidden in settings. */
|
/** Filters accounts whose profileId or account number the user has hidden in settings. */
|
||||||
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
||||||
val store = CredentialStore(this@HomeActivity)
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
|
||||||
return filter { acc ->
|
return filter { acc ->
|
||||||
|
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
|
||||||
when (acc.bank) {
|
when (acc.bank) {
|
||||||
"MIB" -> {
|
"MIB" -> {
|
||||||
val loginId = acc.loginTag.removePrefix("mib_")
|
val loginId = acc.loginTag.removePrefix("mib_")
|
||||||
@@ -1022,10 +992,140 @@ fun applyNavLabelVisibility() {
|
|||||||
return filter { it.profileId !in hidden }
|
return filter { it.profileId !in hidden }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
/** Drops MIB cards belonging to profiles the user has hidden. */
|
||||||
fun applyProfileVisibility() {
|
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
|
||||||
val current = viewModel.accounts.value ?: return
|
val store = CredentialStore(this@HomeActivity)
|
||||||
viewModel.accounts.value = current.filterVisibleAccounts()
|
return filter { card ->
|
||||||
|
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
|
||||||
|
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
|
||||||
|
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
|
||||||
|
*/
|
||||||
|
fun applyVisibility() {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
|
||||||
|
viewModel.mibCards.value?.let { cards ->
|
||||||
|
val visible = cards.filterVisibleCards()
|
||||||
|
viewModel.mibCards.value = visible
|
||||||
|
CardsCache.save(this, visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
|
||||||
|
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
|
||||||
|
if (profileIds.isEmpty()) return
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val loginTag = "mib_$loginId"
|
||||||
|
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
|
||||||
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
|
val session = app.mibSessions[loginId]
|
||||||
|
if (session != null && enabled.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
|
||||||
|
if (accounts.isNotEmpty()) {
|
||||||
|
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
// Session expired — log this one login in again (it now includes the unhidden profiles)
|
||||||
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
|
||||||
|
try {
|
||||||
|
val flow = MibLoginFlow(store)
|
||||||
|
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||||
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
|
app.mibLoginFlows[loginId] = flow
|
||||||
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
|
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
if (fresh == null) return@launch
|
||||||
|
app.mibAccounts = fresh
|
||||||
|
AccountCache.save(this@HomeActivity, fresh)
|
||||||
|
applyVisibility()
|
||||||
|
val session = app.mibSessions[loginId] ?: return@launch
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
|
||||||
|
// Financing deals carry no profile id, so the login's visible profiles are fetched together
|
||||||
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
|
||||||
|
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
|
||||||
|
if (profileIds.isEmpty()) return
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val store = CredentialStore(this)
|
||||||
|
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
|
||||||
|
if (enabled.isEmpty()) return
|
||||||
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val fetched = withContext(Dispatchers.IO) {
|
||||||
|
enabled.mapNotNull { profile ->
|
||||||
|
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
if (fetched.isEmpty()) return@launch
|
||||||
|
val fetchedIds = fetched.map { it.first }.toSet()
|
||||||
|
val loginTag = "bml_$loginId"
|
||||||
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
|
||||||
|
fetched.flatMap { it.second }
|
||||||
|
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
|
||||||
|
applyVisibility()
|
||||||
|
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
|
||||||
|
refreshBmlLoanDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
|
||||||
|
* Returns null when the profile has no saved session; throws on network/server errors.
|
||||||
|
*/
|
||||||
|
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
|
||||||
|
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||||
|
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||||
|
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||||
|
val bmlClient = BmlAccountClient()
|
||||||
|
|
||||||
|
fun fetchWithSession(session: BmlSession): List<BankAccount> {
|
||||||
|
bmlClient.checkProfile(session)
|
||||||
|
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
|
||||||
|
app.bmlSessions[profile.profileId] = session
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryRefresh(): List<BankAccount> {
|
||||||
|
if (refreshToken == null) throw Exception("No refresh token")
|
||||||
|
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||||
|
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||||
|
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||||
|
if (newSession.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||||
|
if (newSession.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||||
|
return fetchWithSession(newSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (tokenKnownExpired) {
|
||||||
|
tryRefresh()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||||
|
} catch (_: AuthExpiredException) {
|
||||||
|
tryRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshBmlLimits(session: BmlSession) {
|
private fun refreshBmlLimits(session: BmlSession) {
|
||||||
@@ -1259,7 +1359,7 @@ fun applyNavLabelVisibility() {
|
|||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshMibCards(loginId, session, profiles)
|
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,7 +1384,8 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
if (cards.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
||||||
existing.removeAll { it.loginTag == "mib_$loginId" }
|
val fetchedIds = profiles.map { it.profileId }.toSet()
|
||||||
|
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
|
||||||
existing += cards
|
existing += cards
|
||||||
viewModel.mibCards.postValue(existing)
|
viewModel.mibCards.postValue(existing)
|
||||||
CardsCache.save(this@HomeActivity, existing)
|
CardsCache.save(this@HomeActivity, existing)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
|
|||||||
import sh.sar.basedbank.api.models.BankContactCategory
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
sealed class CardItem {
|
sealed class CardItem {
|
||||||
data class Mib(val card: MibCard) : CardItem()
|
abstract val loginTag: String
|
||||||
data class Bml(val account: BankAccount) : CardItem()
|
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
|
||||||
|
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
|
||||||
|
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
|
||||||
|
override fun setValue(value: List<BankAccount>?) {
|
||||||
|
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||||
/** BML loan details keyed by account internalId. */
|
/** BML loan details keyed by account internalId. */
|
||||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
|||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
|
||||||
|
override fun setValue(value: List<MibCard>?) {
|
||||||
|
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
|
||||||
|
|
||||||
|
/** Re-applies the login order after the user changes it. */
|
||||||
|
fun resortByLoginOrder() {
|
||||||
|
accounts.value = accounts.value
|
||||||
|
mibCards.value = mibCards.value
|
||||||
|
}
|
||||||
|
|
||||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
@@ -32,7 +34,7 @@ class OtpFragment : Fragment() {
|
|||||||
private var _binding: FragmentOtpBinding? = null
|
private var _binding: FragmentOtpBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
private data class OtpEntry(val label: String, val seed: String)
|
private data class OtpEntry(val bank: String, val name: String?, val seed: String)
|
||||||
|
|
||||||
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||||
@@ -45,18 +47,17 @@ class OtpFragment : Fragment() {
|
|||||||
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.b.tvOtpLabel.text = entries[position].label
|
val entry = entries[position]
|
||||||
update(holder.b, entries[position].seed)
|
val b = holder.b
|
||||||
holder.b.root.setOnClickListener {
|
b.tvOtpBank.text = entry.bank
|
||||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
b.tvOtpLabel.text = entry.name ?: "Authenticator"
|
||||||
if (code.isNotEmpty()) {
|
b.ivBankLogo.setImageResource(
|
||||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
)
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
update(b, entry.seed)
|
||||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||||
}
|
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||||
}
|
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tick() {
|
fun tick() {
|
||||||
@@ -71,9 +72,32 @@ class OtpFragment : Fragment() {
|
|||||||
val secondsInPeriod = (epochSeconds % 30).toInt()
|
val secondsInPeriod = (epochSeconds % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
||||||
|
val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" }
|
||||||
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
||||||
|
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
|
||||||
b.otpProgress.progress = remaining
|
b.otpProgress.progress = remaining
|
||||||
b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}"
|
b.tvOtpCountdown.text = remaining.toString()
|
||||||
|
|
||||||
|
// Turn the ring and code red in the last few seconds of the window
|
||||||
|
val expiring = remaining <= 5
|
||||||
|
val accent = MaterialColors.getColor(b.root,
|
||||||
|
if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary)
|
||||||
|
b.otpProgress.setIndicatorColor(accent)
|
||||||
|
b.tvOtpCode.setTextColor(accent)
|
||||||
|
b.tvOtpCountdown.setTextColor(
|
||||||
|
if (expiring) accent
|
||||||
|
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||||
|
val code = text.toString().replace(" ", "")
|
||||||
|
if (code.isEmpty() || code.contains('-')) return
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,21 +110,24 @@ class OtpFragment : Fragment() {
|
|||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
val entries = mutableListOf<OtpEntry>()
|
val rank = store.loginRank()
|
||||||
|
val tagged = mutableListOf<Pair<String, OtpEntry>>()
|
||||||
for (loginId in store.getMibLoginIds()) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
val name = store.loadMibFullName(loginId)
|
val name = store.loadMibFullName(loginId)
|
||||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
|
||||||
}
|
}
|
||||||
for (loginId in store.getBmlLoginIds()) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||||
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||||
}
|
}
|
||||||
|
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
|
||||||
|
|
||||||
val adapter = OtpAdapter(entries)
|
val adapter = OtpAdapter(entries)
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
// Fetch real names in background if not yet cached, then refresh labels
|
// Fetch real names in background if not yet cached, then refresh labels
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -122,7 +149,7 @@ class OtpFragment : Fragment() {
|
|||||||
))
|
))
|
||||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
val idx = entries.indexOfFirst { it.seed == seed }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +170,7 @@ class OtpFragment : Fragment() {
|
|||||||
))
|
))
|
||||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
val idx = entries.indexOfFirst { it.seed == seed }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private var selectedAccount: BankAccount? = null
|
private var selectedAccount: BankAccount? = null
|
||||||
|
/** Set when opened for a saved contact: the QR is for their account, not one of ours. */
|
||||||
|
private var contactTarget: QrTarget? = null
|
||||||
private var generatedBitmap: Bitmap? = null
|
private var generatedBitmap: Bitmap? = null
|
||||||
private var generateJob: Job? = null
|
private var generateJob: Job? = null
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
@@ -59,7 +61,17 @@ class PayMvQrFragment : Fragment() {
|
|||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who the QR pays into — one of our own accounts, or a contact's. */
|
||||||
|
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
|
||||||
|
|
||||||
|
private fun currentTarget(): QrTarget? = contactTarget
|
||||||
|
?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) }
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
contactTarget = arguments?.let { args ->
|
||||||
|
val number = args.getString(ARG_ACCOUNT_NUMBER) ?: return@let null
|
||||||
|
QrTarget(number, args.getString(ARG_ACCOUNT_NAME).orEmpty(), args.getString(ARG_BANK).orEmpty())
|
||||||
|
}
|
||||||
val basePaddingBottom = view.paddingBottom
|
val basePaddingBottom = view.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||||
@@ -68,7 +80,15 @@ class PayMvQrFragment : Fragment() {
|
|||||||
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
if (contactTarget != null) {
|
||||||
|
// Not our account, so no account picker and our phone number doesn't belong on it
|
||||||
|
binding.tilAccount.visibility = View.GONE
|
||||||
|
binding.layoutIncludePhone.visibility = View.GONE
|
||||||
|
binding.switchIncludePhone.isChecked = false
|
||||||
|
scheduleGenerate()
|
||||||
|
} else {
|
||||||
setupDropdown()
|
setupDropdown()
|
||||||
|
}
|
||||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
||||||
@@ -119,8 +139,8 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun generateQr() {
|
private suspend fun generateQr() {
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
val acquirer = when (account.bank) {
|
val acquirer = when (target.bank) {
|
||||||
"BML" -> "MALBMVMV"
|
"BML" -> "MALBMVMV"
|
||||||
"MIB" -> "MADVMVMV"
|
"MIB" -> "MADVMVMV"
|
||||||
"FAHIPAY" -> "FAHIMVMV"
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
@@ -133,10 +153,10 @@ class PayMvQrFragment : Fragment() {
|
|||||||
?.let { "%.2f".format(it) }
|
?.let { "%.2f".format(it) }
|
||||||
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val includePhone = binding.switchIncludePhone.isChecked
|
val account = selectedAccount
|
||||||
|
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
|
||||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val mobile = if (includePhone) {
|
|
||||||
when (account.bank) {
|
when (account.bank) {
|
||||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||||
@@ -154,8 +174,8 @@ class PayMvQrFragment : Fragment() {
|
|||||||
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
||||||
|
|
||||||
val bmp = withContext(Dispatchers.Default) {
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
|
||||||
renderQrCard(ctx, account, payload, amountFormatted)
|
renderQrCard(ctx, target, payload, amountFormatted)
|
||||||
}
|
}
|
||||||
if (_binding == null) return
|
if (_binding == null) return
|
||||||
generatedBitmap = bmp
|
generatedBitmap = bmp
|
||||||
@@ -224,7 +244,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun renderQrCard(
|
private fun renderQrCard(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
account: BankAccount,
|
target: QrTarget,
|
||||||
qrPayload: String,
|
qrPayload: String,
|
||||||
amountStr: String?
|
amountStr: String?
|
||||||
): Bitmap {
|
): Bitmap {
|
||||||
@@ -246,7 +266,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
canvas.drawColor(Color.WHITE)
|
canvas.drawColor(Color.WHITE)
|
||||||
|
|
||||||
// --- Bank logo top-left ---
|
// --- Bank logo top-left ---
|
||||||
val logoRes = when (account.bank) {
|
val logoRes = when (target.bank) {
|
||||||
"BML" -> R.drawable.bml_logo_vector
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
"MIB" -> R.drawable.mib_faisanet_logo
|
"MIB" -> R.drawable.mib_faisanet_logo
|
||||||
else -> R.drawable.fahipay_logo_long
|
else -> R.drawable.fahipay_logo_long
|
||||||
@@ -279,7 +299,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
paint.color = Color.WHITE
|
paint.color = Color.WHITE
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
paint.textAlign = Paint.Align.CENTER
|
paint.textAlign = Paint.Align.CENTER
|
||||||
val nameText = account.accountBriefName.uppercase()
|
val nameText = target.name.uppercase()
|
||||||
paint.textSize = 36f
|
paint.textSize = 36f
|
||||||
val maxNameW = boxR - boxL - 48f
|
val maxNameW = boxR - boxL - 48f
|
||||||
if (paint.measureText(nameText) > maxNameW) {
|
if (paint.measureText(nameText) > maxNameW) {
|
||||||
@@ -338,13 +358,13 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun shareQr() {
|
private fun shareQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val uri = withContext(Dispatchers.IO) {
|
val uri = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val dir = File(requireContext().cacheDir, "qr")
|
val dir = File(requireContext().cacheDir, "qr")
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
val file = File(dir, "${safeName}_paymv_qr.png")
|
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||||
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
@@ -366,11 +386,11 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun saveQr() {
|
private fun saveQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
val filename = "${safeName}_PayMV_QR.png"
|
val filename = "${safeName}_PayMV_QR.png"
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -412,6 +432,21 @@ class PayMvQrFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||||
|
private const val ARG_ACCOUNT_NAME = "account_name"
|
||||||
|
private const val ARG_BANK = "bank"
|
||||||
|
|
||||||
|
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
|
||||||
|
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_ACCOUNT_NUMBER, accountNumber)
|
||||||
|
putString(ARG_ACCOUNT_NAME, name)
|
||||||
|
putString(ARG_BANK, bank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account dropdown adapter ──────────────────────────────────────────────
|
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||||
|
|
||||||
private inner class QrAccountAdapter(
|
private inner class QrAccountAdapter(
|
||||||
|
|||||||
@@ -904,7 +904,9 @@ class CardsFragment : Fragment() {
|
|||||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||||
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
||||||
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
|
val rank = store.loginRank()
|
||||||
|
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
|
||||||
|
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
|
||||||
// Move default BML card to front
|
// Move default BML card to front
|
||||||
cards = if (defaultNum != null) {
|
cards = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -30,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlProfile
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
@@ -359,65 +363,86 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||||
|
|
||||||
private fun buildLoginsSection() {
|
private fun buildLoginsSection() {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val container = binding.loginsContainer
|
val container = binding.loginsContainer
|
||||||
container.removeAllViews()
|
container.removeAllViews()
|
||||||
|
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
val entries = mutableListOf<LoginEntry>()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
|
||||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
for (loginId in store.getMibLoginIds()) {
|
||||||
|
|
||||||
for (loginId in mibLoginIds) {
|
|
||||||
val profile = store.loadMibUserProfile(loginId)
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
val mibProfiles = store.loadMibProfiles(loginId)
|
val mibProfiles = store.loadMibProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
|
||||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
|
||||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in fahipayLoginIds) {
|
for (loginId in store.getFahipayLoginIds()) {
|
||||||
val profile = store.loadFahipayUserProfile(loginId)
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||||
showFahipayLoginDetails(store, loginId, profile)
|
showFahipayLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in mfaisaLoginIds) {
|
for (loginId in store.getMfaisaLoginIds()) {
|
||||||
val profile = store.loadMfaisaUserProfile(loginId)
|
val profile = store.loadMfaisaUserProfile(loginId)
|
||||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||||
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
|
||||||
showMfaisaLoginDetails(store, loginId, profile)
|
showMfaisaLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val rank = store.loginRank()
|
||||||
|
val sorted = entries.sortedBy { rank(it.key) }
|
||||||
|
val draggable = sorted.size > 1
|
||||||
|
for (entry in sorted) {
|
||||||
|
addLoginRow(
|
||||||
|
container, entry.logoRes, entry.displayName, entry.onClick,
|
||||||
|
dragTag = entry.key.takeIf { draggable },
|
||||||
|
onReordered = { order ->
|
||||||
|
store.setLoginOrder(order)
|
||||||
|
viewModel.resortByLoginOrder()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addLoginRow(
|
||||||
|
container: LinearLayout,
|
||||||
|
logoRes: Int,
|
||||||
|
displayName: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
dragTag: String? = null,
|
||||||
|
onReordered: (List<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
|
||||||
isClickable = true; isFocusable = true
|
isClickable = true; isFocusable = true
|
||||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
background = ta.getDrawable(0); ta.recycle()
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
setOnClickListener { onClick() }
|
setOnClickListener { onClick() }
|
||||||
|
tag = dragTag
|
||||||
}
|
}
|
||||||
val logo = ImageView(ctx).apply {
|
val logo = ImageView(ctx).apply {
|
||||||
setImageResource(logoRes)
|
setImageResource(logoRes)
|
||||||
@@ -430,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
row.addView(logo); row.addView(tvName)
|
row.addView(logo); row.addView(tvName)
|
||||||
|
if (dragTag != null) {
|
||||||
|
val handle = ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_reorder_lines)
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||||
|
contentDescription = getString(R.string.drag_to_reorder)
|
||||||
|
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
|
||||||
|
}
|
||||||
|
row.addView(handle)
|
||||||
|
attachDragHandle(handle, row, container, onReordered)
|
||||||
|
}
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
|
||||||
|
val ctx = handle.context
|
||||||
|
val lift = 8 * ctx.resources.displayMetrics.density
|
||||||
|
val dragBg = android.graphics.drawable.ColorDrawable(
|
||||||
|
com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
|
||||||
|
var lastY = 0f
|
||||||
|
var startIndex = 0
|
||||||
|
var savedBg: android.graphics.drawable.Drawable? = null
|
||||||
|
|
||||||
|
handle.setOnTouchListener { v, ev ->
|
||||||
|
when (ev.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
v.parent.requestDisallowInterceptTouchEvent(true)
|
||||||
|
lastY = ev.rawY
|
||||||
|
startIndex = group.indexOfChild(row)
|
||||||
|
row.animate().cancel()
|
||||||
|
savedBg = row.background
|
||||||
|
row.background = dragBg
|
||||||
|
row.translationZ = lift
|
||||||
|
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
row.translationY += ev.rawY - lastY
|
||||||
|
lastY = ev.rawY
|
||||||
|
var index = group.indexOfChild(row)
|
||||||
|
while (index < group.childCount - 1) {
|
||||||
|
val next = group.getChildAt(index + 1)
|
||||||
|
if (row.translationY <= next.height / 2f) break
|
||||||
|
group.removeView(next); group.addView(next, index)
|
||||||
|
row.translationY -= next.height
|
||||||
|
next.translationY = row.height.toFloat()
|
||||||
|
next.animate().translationY(0f).setDuration(150).start()
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
while (index > 0) {
|
||||||
|
val prev = group.getChildAt(index - 1)
|
||||||
|
if (row.translationY >= -prev.height / 2f) break
|
||||||
|
group.removeView(prev); group.addView(prev, index)
|
||||||
|
row.translationY += prev.height
|
||||||
|
prev.translationY = -row.height.toFloat()
|
||||||
|
prev.animate().translationY(0f).setDuration(150).start()
|
||||||
|
index--
|
||||||
|
}
|
||||||
|
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
|
||||||
|
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
v.parent.requestDisallowInterceptTouchEvent(false)
|
||||||
|
row.animate().translationY(0f).translationZ(0f).setDuration(150)
|
||||||
|
.withEndAction { row.background = savedBg }
|
||||||
|
.start()
|
||||||
|
if (group.indexOfChild(row) != startIndex) {
|
||||||
|
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
|
||||||
|
private fun addAccountRow(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
acc: BankAccount,
|
||||||
|
hiddenAccounts: MutableSet<String>,
|
||||||
|
indent: Boolean
|
||||||
|
): Pair<BankAccount, MaterialSwitch> {
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
if (indent) it.marginStart = (28 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
|
||||||
|
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
|
||||||
|
setTextAppearance(nameAppearance)
|
||||||
|
})
|
||||||
|
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
|
||||||
|
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||||
|
else acc.accountTypeName.trim()
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(" · ")
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
val toggle = MaterialSwitch(ctx).apply {
|
||||||
|
isChecked = acc.accountNumber !in hiddenAccounts
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
return acc to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
|
||||||
|
private fun addNestedAccountRows(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
hiddenAccounts: MutableSet<String>
|
||||||
|
): List<Pair<BankAccount, MaterialSwitch>> =
|
||||||
|
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
|
||||||
|
|
||||||
|
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
|
||||||
|
private fun addAccountsSection(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
hiddenAccounts: MutableSet<String>,
|
||||||
|
showDivider: Boolean
|
||||||
|
): List<Pair<BankAccount, MaterialSwitch>> {
|
||||||
|
if (accounts.isEmpty()) return emptyList()
|
||||||
|
if (showDivider) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.accounts)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
|
||||||
|
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
|
||||||
|
* the account listeners ignore changes made while a toggle is disabled.
|
||||||
|
*/
|
||||||
|
private fun syncAccountToggles(
|
||||||
|
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
|
||||||
|
hiddenAccounts: Set<String>,
|
||||||
|
isParentHidden: (BankAccount) -> Boolean
|
||||||
|
) {
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
if (isParentHidden(acc)) {
|
||||||
|
toggle.isEnabled = false
|
||||||
|
toggle.isChecked = false
|
||||||
|
} else {
|
||||||
|
toggle.isChecked = acc.accountNumber !in hiddenAccounts
|
||||||
|
toggle.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
|
||||||
|
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
|
||||||
|
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
|
||||||
|
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMibLoginDetails(
|
private fun showMibLoginDetails(
|
||||||
store: CredentialStore,
|
store: CredentialStore,
|
||||||
loginId: String,
|
loginId: String,
|
||||||
@@ -443,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||||
val hidden = originalHidden.toMutableSet()
|
val hidden = originalHidden.toMutableSet()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -485,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
|
// Build toggle rows — wired up after dialog.show() so we can reference the Save button.
|
||||||
|
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||||
|
// can have multiple accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = mibProfiles.map { p ->
|
val toggleRows = mibProfiles.map { p ->
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
@@ -526,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
p to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts that don't belong to any known profile still need to be reachable.
|
||||||
|
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
|
||||||
|
accountRows += addAccountsSection(
|
||||||
|
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||||
|
showDivider = mibProfiles.isNotEmpty() || profile != null
|
||||||
|
)
|
||||||
|
|
||||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (p, toggle) ->
|
toggleRows.forEach { (p, toggle) ->
|
||||||
// Disable the sole remaining visible toggle so it can't be turned off
|
// Disable the sole remaining visible toggle so it can't be turned off
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -559,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenMibProfileIds(loginId, hidden)
|
store.setHiddenMibProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||||
|
val home = activity as? HomeActivity
|
||||||
|
home?.applyVisibility()
|
||||||
|
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
}
|
}
|
||||||
val originalHidden = hidden.toSet()
|
val originalHidden = hidden.toSet()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -630,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||||
|
// can have multiple accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = bmlProfiles.map { p ->
|
val toggleRows = bmlProfiles.map { p ->
|
||||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||||
@@ -677,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
p to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts that don't belong to any known profile still need to be reachable.
|
||||||
|
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
|
||||||
|
accountRows += addAccountsSection(
|
||||||
|
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||||
|
showDivider = bmlProfiles.isNotEmpty() || profile != null
|
||||||
|
)
|
||||||
|
|
||||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (_, toggle) ->
|
toggleRows.forEach { (_, toggle) ->
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -720,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenBmlProfileIds(loginId, hidden)
|
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||||
|
val home = activity as? HomeActivity
|
||||||
|
home?.applyVisibility()
|
||||||
|
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val masked = "••••••"
|
val masked = "••••••"
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -997,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(ctx)
|
val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
.setTitle(getString(R.string.fahipay_name))
|
.setTitle(getString(R.string.fahipay_name))
|
||||||
.setView(scroll)
|
.setView(scroll)
|
||||||
.setPositiveButton(R.string.close, null)
|
.apply {
|
||||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||||
|
setNeutralButton(R.string.close, null)
|
||||||
|
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.show()
|
.show()
|
||||||
|
|
||||||
|
if (loginAccounts.isNotEmpty()) {
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.applyVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
@@ -1087,6 +1379,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||||
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||||
val originalHidden = hidden.toSet()
|
val originalHidden = hidden.toSet()
|
||||||
|
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||||
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||||
@@ -1142,6 +1436,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each group's own pockets nest directly beneath it (tree view), since a group
|
||||||
|
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = profileRows.map { row ->
|
val toggleRows = profileRows.map { row ->
|
||||||
val v = LinearLayout(ctx).apply {
|
val v = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
@@ -1164,6 +1461,7 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
v.addView(label)
|
v.addView(label)
|
||||||
v.addView(toggle)
|
v.addView(toggle)
|
||||||
container.addView(v)
|
container.addView(v)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
|
||||||
row to toggle
|
row to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1470,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
toggleRows.forEach { (_, toggle) ->
|
toggleRows.forEach { (_, toggle) ->
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -1199,11 +1498,20 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
|
// All pockets come back in one login response, so hiding/unhiding never needs a request
|
||||||
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, pockets, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
(activity as? HomeActivity)?.applyVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemPickerRowBinding
|
||||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||||
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
||||||
@@ -92,6 +94,7 @@ class TransferFragment : Fragment() {
|
|||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
||||||
|
private var contactDropdownAdapter: ContactDropdownAdapter? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
|
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
|
||||||
@@ -112,7 +115,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 { bmlHandler = it }
|
).also { bmlHandler = it }
|
||||||
|
|
||||||
@@ -152,7 +155,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 }
|
||||||
|
|
||||||
@@ -242,6 +245,10 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
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_RETURN_ON_SUCCESS = "return_on_success"
|
||||||
|
|
||||||
|
/** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */
|
||||||
|
const val RESULT_TRANSFER_DONE = "transfer_done"
|
||||||
|
|
||||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||||
@@ -279,7 +286,8 @@ class TransferFragment : Fragment() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
amount: String?,
|
amount: String?,
|
||||||
remarks: String?,
|
remarks: String?,
|
||||||
fromAccountNumber: String? = null
|
fromAccountNumber: String? = null,
|
||||||
|
returnOnSuccess: Boolean = false
|
||||||
) = TransferFragment().apply {
|
) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT, accountNumber)
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
@@ -289,10 +297,28 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the receipt, or — when opened with returnOnSuccess (e.g. from a chat) — goes back to
|
||||||
|
* the caller and tells it via [RESULT_TRANSFER_DONE] on the activity's fragment manager;
|
||||||
|
* the receipt is already in ReceiptStore.
|
||||||
|
*/
|
||||||
|
private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) {
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) == true) {
|
||||||
|
activity.supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
|
||||||
|
// Hosted in a sheet (e.g. over a chat): close the sheet; otherwise leave this page.
|
||||||
|
val sheet = parentFragment as? androidx.fragment.app.DialogFragment
|
||||||
|
if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack()
|
||||||
|
} else {
|
||||||
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
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
|
||||||
@@ -305,6 +331,9 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
setupFromDropdown()
|
setupFromDropdown()
|
||||||
setupAccountLookup()
|
setupAccountLookup()
|
||||||
|
// Contact search dropdown on the To field needs viewModel.contacts populated —
|
||||||
|
// otherwise it stays empty until the contacts tab or picker sheet is opened first.
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
accountDropdownAdapter?.notifyDataSetChanged()
|
accountDropdownAdapter?.notifyDataSetChanged()
|
||||||
@@ -327,7 +356,7 @@ class TransferFragment : Fragment() {
|
|||||||
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
||||||
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
|
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
|
||||||
if (selectedAccount?.bank == "MFAISA") {
|
if (selectedAccount?.bank == "MFAISA") {
|
||||||
binding.etTo.setText(accountNumber)
|
binding.etTo.setText(accountNumber, false)
|
||||||
mfaisaHandler().searchRecipient(accountNumber)
|
mfaisaHandler().searchRecipient(accountNumber)
|
||||||
return@setFragmentResultListener
|
return@setFragmentResultListener
|
||||||
}
|
}
|
||||||
@@ -336,18 +365,8 @@ class TransferFragment : Fragment() {
|
|||||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||||
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
||||||
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
||||||
if (selectedAccount == null) {
|
selectDefaultSourceIfNone()
|
||||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
focusAmount()
|
||||||
if (defaultNum != null) {
|
|
||||||
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
|
|
||||||
if (defaultAcc != null) {
|
|
||||||
selectedAccount = defaultAcc
|
|
||||||
updateAmountPrefix(defaultAcc)
|
|
||||||
showFromCard(defaultAcc)
|
|
||||||
updateTransferButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnPickContact.setOnClickListener {
|
binding.btnPickContact.setOnClickListener {
|
||||||
@@ -408,7 +427,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
||||||
} else if (savedToText.isNotEmpty()) {
|
} else if (savedToText.isNotEmpty()) {
|
||||||
binding.etTo.setText(savedToText)
|
binding.etTo.setText(savedToText, false)
|
||||||
}
|
}
|
||||||
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
||||||
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
||||||
@@ -537,7 +556,7 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedToOwnAccount = null
|
resolvedToOwnAccount = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.etTo.setText("")
|
binding.etTo.setText("", false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
binding.tilTo.hint = getString(R.string.transfer_to)
|
binding.tilTo.hint = getString(R.string.transfer_to)
|
||||||
@@ -551,7 +570,7 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedToOwnAccount = null
|
resolvedToOwnAccount = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.etTo.setText("")
|
binding.etTo.setText("", false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
|
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
|
||||||
@@ -720,13 +739,12 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupAccountLookup() {
|
private fun setupAccountLookup() {
|
||||||
binding.tilTo.setEndIconOnClickListener {
|
binding.tilTo.setEndIconOnClickListener { searchTo() }
|
||||||
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
binding.etTo.setOnEditorActionListener { _, actionId, _ ->
|
||||||
if (selectedAccount?.bank == "MFAISA") {
|
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||||
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
searchTo()
|
||||||
} else {
|
true
|
||||||
lookupAccount()
|
} else false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnClearToInfo.setOnClickListener {
|
binding.btnClearToInfo.setOnClickListener {
|
||||||
@@ -764,6 +782,62 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupContactDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchTo() {
|
||||||
|
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
||||||
|
if (selectedAccount?.bank == "MFAISA") {
|
||||||
|
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
||||||
|
} else {
|
||||||
|
lookupAccount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves focus to the amount field and brings up the keyboard once a recipient is resolved. */
|
||||||
|
internal fun focusAmount() {
|
||||||
|
val et = binding.etAmount
|
||||||
|
// Posted so it runs after the To-row visibility changes and any closing picker sheet
|
||||||
|
et.post {
|
||||||
|
if (_binding == null) return@post
|
||||||
|
et.requestFocus()
|
||||||
|
et.setSelection(et.text?.length ?: 0)
|
||||||
|
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||||
|
imm.showSoftInput(et, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live "search contacts as you type" dropdown on the To field (shown once 3+ chars are entered). */
|
||||||
|
private fun setupContactDropdown() {
|
||||||
|
val adapter = ContactDropdownAdapter(requireContext()) { selectedAccount?.bank == "MFAISA" }
|
||||||
|
contactDropdownAdapter = adapter
|
||||||
|
binding.etTo.setAdapter(adapter)
|
||||||
|
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
adapter.updateSource(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
||||||
|
prefillToDirectly(
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
displayName = contact.benefNickName,
|
||||||
|
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
colorHex = contact.bankColor,
|
||||||
|
imageHash = contact.customerImgHash
|
||||||
|
)
|
||||||
|
selectDefaultSourceIfNone()
|
||||||
|
focusAmount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the user's default account as the source when a recipient is chosen before any source. */
|
||||||
|
private fun selectDefaultSourceIfNone() {
|
||||||
|
if (selectedAccount != null) return
|
||||||
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() ?: return
|
||||||
|
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum } ?: return
|
||||||
|
selectSourceAccount(defaultAcc)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupAccount() {
|
private fun lookupAccount() {
|
||||||
@@ -801,6 +875,7 @@ class TransferFragment : Fragment() {
|
|||||||
colorHex = matchedContact.bankColor,
|
colorHex = matchedContact.bankColor,
|
||||||
imageHash = matchedContact.customerImgHash
|
imageHash = matchedContact.customerImgHash
|
||||||
)
|
)
|
||||||
|
focusAmount()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +943,8 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedRecipientName = info.accountName
|
resolvedRecipientName = info.accountName
|
||||||
resolvedBankName = info.bankId
|
resolvedBankName = info.bankId
|
||||||
resolvedDestCurrency = info.currency
|
resolvedDestCurrency = info.currency
|
||||||
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
|
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(" · ")
|
||||||
savedToColorHex = colorHex
|
savedToColorHex = colorHex
|
||||||
savedToImageHash = when {
|
savedToImageHash = when {
|
||||||
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
|
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
|
||||||
@@ -880,7 +956,7 @@ class TransferFragment : Fragment() {
|
|||||||
showToCard(matchedAcc)
|
showToCard(matchedAcc)
|
||||||
} else {
|
} else {
|
||||||
binding.tvToAccountName.text = displayName
|
binding.tvToAccountName.text = displayName
|
||||||
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
|
binding.tvToBankBic.text = savedToSubtitle
|
||||||
binding.tvToAccountDetails.visibility = View.GONE
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
binding.tvToBalance.visibility = View.GONE
|
binding.tvToBalance.visibility = View.GONE
|
||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
@@ -892,6 +968,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
saveToRecents(info)
|
saveToRecents(info)
|
||||||
|
focusAmount()
|
||||||
|
|
||||||
when {
|
when {
|
||||||
matchedAcc?.profileImageHash != null ->
|
matchedAcc?.profileImageHash != null ->
|
||||||
@@ -975,7 +1052,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
binding.etTo.setText(accountNumber)
|
binding.etTo.setText(accountNumber, false)
|
||||||
lookupAccount()
|
lookupAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,7 +1228,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, capturedToAvatar))
|
showReceipt(receipt, capturedToAvatar)
|
||||||
} else if (!ok) {
|
} else if (!ok) {
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
if (msg == "CONNECTIVITY") {
|
if (msg == "CONNECTIVITY") {
|
||||||
@@ -1512,7 +1589,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.etTo.setText("")
|
binding.etTo.setText("", false)
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
binding.tilAmount.error = null
|
binding.tilAmount.error = null
|
||||||
}
|
}
|
||||||
@@ -1782,4 +1859,59 @@ class TransferFragment : Fragment() {
|
|||||||
override fun convertResultToString(r: Any?) = ""
|
override fun convertResultToString(r: Any?) = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filters [source] contacts by nickname/name/account number, shown once the query is 3+ chars. */
|
||||||
|
private inner class ContactDropdownAdapter(
|
||||||
|
private val context: Context,
|
||||||
|
private val isDisabled: () -> Boolean
|
||||||
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
private var source: List<BankContact> = emptyList()
|
||||||
|
private var filtered: List<BankContact> = emptyList()
|
||||||
|
|
||||||
|
fun updateSource(contacts: List<BankContact>) {
|
||||||
|
source = contacts
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContact(position: Int): BankContact? = filtered.getOrNull(position)
|
||||||
|
|
||||||
|
override fun getCount() = filtered.size
|
||||||
|
override fun getItem(position: Int) = filtered[position]
|
||||||
|
override fun getItemId(position: Int) = position.toLong()
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val contact = filtered[position]
|
||||||
|
val b = if (convertView?.tag is ItemPickerRowBinding) {
|
||||||
|
convertView.tag as ItemPickerRowBinding
|
||||||
|
} else {
|
||||||
|
ItemPickerRowBinding.inflate(LayoutInflater.from(context), parent, false).also { it.root.tag = it }
|
||||||
|
}
|
||||||
|
b.tvPrimary.text = contact.benefNickName
|
||||||
|
b.tvSecondary.text = "${contact.benefBankName} · ${contact.benefAccount}"
|
||||||
|
b.tvBalance.visibility = View.GONE
|
||||||
|
b.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
b.ivIcon.setImageBitmap(makeInitialsBitmap(contact.benefNickName, contact.bankColor))
|
||||||
|
return b.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||||
|
val query = constraint?.toString()?.trim().orEmpty()
|
||||||
|
val matches = if (isDisabled() || query.length < 3) emptyList() else source.filter {
|
||||||
|
it.benefNickName.contains(query, ignoreCase = true) ||
|
||||||
|
it.benefName.contains(query, ignoreCase = true) ||
|
||||||
|
it.benefAccount.contains(query, ignoreCase = true)
|
||||||
|
}.take(8)
|
||||||
|
return FilterResults().apply { values = matches; count = matches.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
|
||||||
|
filtered = results?.values as? List<BankContact> ?: emptyList()
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertResultToString(r: Any?) = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import sh.sar.basedbank.databinding.SheetTransferBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible.
|
||||||
|
* The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet.
|
||||||
|
*/
|
||||||
|
class TransferSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: SheetTransferBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = SheetTransferBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
// Leave the top of the chat visible above the sheet.
|
||||||
|
binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt()
|
||||||
|
if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) {
|
||||||
|
val transferArgs = requireArguments()
|
||||||
|
childFragmentManager.beginTransaction()
|
||||||
|
.replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs })
|
||||||
|
.commitNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
(dialog as? BottomSheetDialog)?.let { d ->
|
||||||
|
// Keep the amount/remarks fields above the keyboard.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
d.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||||
|
d.behavior.skipCollapsed = true
|
||||||
|
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SHEET_HEIGHT_FRACTION = 0.8f
|
||||||
|
|
||||||
|
/** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */
|
||||||
|
fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,5 +194,6 @@ class FahipayTransferHandler(
|
|||||||
colorHex = "#FF6B00",
|
colorHex = "#FF6B00",
|
||||||
imageHash = null
|
imageHash = null
|
||||||
)
|
)
|
||||||
|
fragment.focusAmount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class MfaisaTransferHandler(
|
|||||||
} else {
|
} else {
|
||||||
recipient = result
|
recipient = result
|
||||||
showResolvedRecipient(result)
|
showResolvedRecipient(result)
|
||||||
|
fragment.focusAmount()
|
||||||
}
|
}
|
||||||
} catch (_: MfaisaRecipientNotFoundException) {
|
} catch (_: MfaisaRecipientNotFoundException) {
|
||||||
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
|
|
||||||
binding.tvOtpCode.text = otp
|
binding.tvOtpCode.text = otp
|
||||||
|
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||||
binding.otpTimer.max = 30
|
binding.otpTimer.max = 30
|
||||||
binding.otpTimer.progress = remaining
|
binding.otpTimer.progress = remaining
|
||||||
binding.cardOtp.visibility = View.VISIBLE
|
binding.cardOtp.visibility = View.VISIBLE
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted directory of account number → real account-holder name and currency, looked up through BML's
|
||||||
|
* account validation. Saved contacts often carry a nickname, while bank history only shows the
|
||||||
|
* real name; this lets a name-only transfer be matched to the right account.
|
||||||
|
*/
|
||||||
|
object AccountNameStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "account_names.json"
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
/** Names are re-checked after this long, in case an account changes hands. */
|
||||||
|
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
|
||||||
|
/** Failed lookups (closed or foreign accounts) are retried after this long. */
|
||||||
|
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
|
||||||
|
/** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */
|
||||||
|
private const val MAX_LOOKUPS_PER_RUN = 20
|
||||||
|
|
||||||
|
data class Info(val name: String, val currency: String)
|
||||||
|
|
||||||
|
private data class Entry(val name: String, val currency: String, val checkedAt: Long)
|
||||||
|
|
||||||
|
/** Account number → real name and currency, for every account resolved so far. */
|
||||||
|
fun all(context: Context): Map<String, Info> = synchronized(lock) {
|
||||||
|
load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */
|
||||||
|
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val known = synchronized(lock) { load(context) }
|
||||||
|
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
|
||||||
|
val e = known[account] ?: return@filter true
|
||||||
|
val age = now - e.checkedAt
|
||||||
|
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
|
||||||
|
}.take(MAX_LOOKUPS_PER_RUN)
|
||||||
|
if (due.isEmpty()) return
|
||||||
|
|
||||||
|
val client = BmlValidateClient()
|
||||||
|
// null = network error: leave the account due for next time. A failed lookup is stored blank.
|
||||||
|
val found = due.associateWith { account ->
|
||||||
|
try {
|
||||||
|
val v = client.validateAccount(session, account)
|
||||||
|
Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = load(context).toMutableMap()
|
||||||
|
found.forEach { (account, entry) -> if (entry != null) current[account] = entry }
|
||||||
|
save(context, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
|
||||||
|
|
||||||
|
private fun load(context: Context): Map<String, Entry> {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return emptyMap()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||||
|
root.keys().asSequence().associateWith { key ->
|
||||||
|
val o = root.getJSONObject(key)
|
||||||
|
Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyMap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(context: Context, entries: Map<String, Entry>) {
|
||||||
|
try {
|
||||||
|
val root = JSONObject()
|
||||||
|
entries.forEach { (account, e) ->
|
||||||
|
root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt))
|
||||||
|
}
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
import sh.sar.basedbank.ui.home.AccountHistoryAdapter
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One transfer shown as a chat bubble. BML history only carries the counterparty's name, so
|
||||||
|
* [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name.
|
||||||
|
*/
|
||||||
|
data class ChatMessage(
|
||||||
|
val id: String,
|
||||||
|
val peerKey: String,
|
||||||
|
val peerName: String,
|
||||||
|
val amount: Double, // negative = sent, positive = received
|
||||||
|
val currency: String,
|
||||||
|
val timeMillis: Long,
|
||||||
|
val accountNumber: String, // own account the money moved in/out of
|
||||||
|
val accountDisplayName: String,
|
||||||
|
val reference: String = "",
|
||||||
|
val note: String = "", // remarks — only known for transfers sent from this app
|
||||||
|
val peerAccount: String = "", // only known for transfers sent from this app
|
||||||
|
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
|
||||||
|
val dateOnly: Boolean = false // bank gave only a date (Favara); time of day unknown
|
||||||
|
) {
|
||||||
|
val isSent get() = amount < 0
|
||||||
|
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
|
||||||
|
val date: String get() = DATE_FMT.format(Date(timeMillis))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of a person's accounts that can be sent to from their chat. */
|
||||||
|
data class ChatAccount(
|
||||||
|
val account: String,
|
||||||
|
val currency: String, // "MVR", "USD"; blank if not known yet
|
||||||
|
val contact: BankContact? // saved contact for this account, if any
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatThread(
|
||||||
|
val peerKey: String, // "name:<normalised real 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 last get() = messages.last()
|
||||||
|
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
|
||||||
|
/** The account last sent to, else the first known one; blank when none is known. */
|
||||||
|
val peerAccount: String get() =
|
||||||
|
messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount
|
||||||
|
?.takeIf { a -> accounts.any { it.account == a } }
|
||||||
|
?: accounts.firstOrNull()?.account.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted local record of BML transfers, shown as chats.
|
||||||
|
*
|
||||||
|
* Old history is never loaded: the first call to [sync] only stamps the start time, and every
|
||||||
|
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
|
||||||
|
* pages back through the latest history of every BML current/savings account (booked later), and
|
||||||
|
* merges in receipts of transfers sent from this app, keeping one message per transfer.
|
||||||
|
*/
|
||||||
|
object ChatStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "chats.json"
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||||
|
private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
|
||||||
|
|
||||||
|
/** "You have received MVR 5.00 from NAME to 7730*****1234" */
|
||||||
|
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
|
||||||
|
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
|
||||||
|
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
|
||||||
|
/** Masked own account in notifications, e.g. "7730*****1234". */
|
||||||
|
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
|
||||||
|
|
||||||
|
/** How far apart two records of the same transfer can be timestamped. */
|
||||||
|
private const val MATCH_WINDOW_MS = 15 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Upper bound on history pages fetched per account in one sync. */
|
||||||
|
private const val MAX_PAGES_PER_SYNC = 5
|
||||||
|
|
||||||
|
private const val NAME_KEY_PREFIX = "name:"
|
||||||
|
|
||||||
|
private data class State(val sinceMillis: Long, val messages: List<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() }
|
||||||
|
|
||||||
|
fun keyOf(m: ChatMessage): String {
|
||||||
|
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
|
||||||
|
return NAME_KEY_PREFIX + normalise(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every known account, grouped under its holder's real name.
|
||||||
|
val accountsByKey = HashMap<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 }
|
||||||
|
val accounts = accountsByKey[key]?.values?.toList().orEmpty()
|
||||||
|
// Bank history and the account lookup carry the real name; a receipt only has our label.
|
||||||
|
val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName
|
||||||
|
?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
?: ""
|
||||||
|
val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
ChatThread(
|
||||||
|
peerKey = key,
|
||||||
|
peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName,
|
||||||
|
realName = realName,
|
||||||
|
accounts = accounts,
|
||||||
|
messages = sorted
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.last.timeMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */
|
||||||
|
suspend fun sync(context: Context, app: BasedBankApp, accounts: List<BankAccount>, contacts: List<BankContact>) {
|
||||||
|
val state = synchronized(lock) { load(context) }
|
||||||
|
if (state.sinceMillis == 0L) {
|
||||||
|
synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val since = state.sinceMillis
|
||||||
|
|
||||||
|
val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id })
|
||||||
|
val notifications = fetchNotifications(context, app, accounts, since)
|
||||||
|
val receipts = receiptMessages(context, accounts, since)
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = load(context)
|
||||||
|
save(context, current.copy(messages = merge(current.messages, history + notifications + receipts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
|
||||||
|
// SWIFT (foreign) beneficiaries can't be looked up through BML.
|
||||||
|
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
|
||||||
|
AccountNameStore.resolve(context, session, toResolve)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers from account history. BML only adds a transaction here once it is booked (often
|
||||||
|
* the next day), so recent ones come from [fetchNotifications] first.
|
||||||
|
*/
|
||||||
|
private suspend fun fetchHistory(
|
||||||
|
app: BasedBankApp,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
since: Long,
|
||||||
|
knownIds: Set<String>
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val fetched = mutableListOf<ChatMessage>()
|
||||||
|
for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) {
|
||||||
|
val fetcher = HistoryFetcher(account)
|
||||||
|
var pages = 0
|
||||||
|
// Page back until we reach transfers we already have or ones older than the start time.
|
||||||
|
while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) {
|
||||||
|
val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break }
|
||||||
|
pages++
|
||||||
|
if (page.isEmpty()) break
|
||||||
|
var reachedKnown = false
|
||||||
|
for (trx in page) {
|
||||||
|
val time = AccountHistoryAdapter.parseDateMillis(trx.date)
|
||||||
|
if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true
|
||||||
|
trx.toChatMessage(time, since)?.let { fetched += it }
|
||||||
|
}
|
||||||
|
if (reachedKnown) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
|
||||||
|
val name = counterpartyName?.trim().orEmpty()
|
||||||
|
if (description !in TRANSFER_TYPES || name.isBlank()) return null
|
||||||
|
// Favara (other-bank) entries carry only a date; keep those from the start day onwards.
|
||||||
|
val dateOnly = date.contains("T00:00:00")
|
||||||
|
if (if (dateOnly) time < startOfDay(since) else time < since) return null
|
||||||
|
return ChatMessage(
|
||||||
|
id = "bml_$id",
|
||||||
|
peerKey = normalise(name),
|
||||||
|
peerName = name,
|
||||||
|
amount = amount,
|
||||||
|
currency = currency,
|
||||||
|
timeMillis = time,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
reference = reference.orEmpty(),
|
||||||
|
dateOnly = dateOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive
|
||||||
|
* instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page
|
||||||
|
* is not written back to the cache, which the notification poller uses to spot new alerts.
|
||||||
|
*/
|
||||||
|
private fun fetchNotifications(
|
||||||
|
context: Context,
|
||||||
|
app: BasedBankApp,
|
||||||
|
accounts: List<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 (currency, amountText, name, ownMasked) = when {
|
||||||
|
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) }
|
||||||
|
sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
|
||||||
|
val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null
|
||||||
|
val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m ->
|
||||||
|
val (head, tail) = m.destructured
|
||||||
|
accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) }
|
||||||
|
}
|
||||||
|
return ChatMessage(
|
||||||
|
id = "ntf_${n.id}",
|
||||||
|
peerKey = normalise(name),
|
||||||
|
peerName = name.trim(),
|
||||||
|
amount = if (received != null) amount else -amount,
|
||||||
|
currency = currency,
|
||||||
|
timeMillis = n.timestampMs,
|
||||||
|
accountNumber = own?.accountNumber.orEmpty(),
|
||||||
|
accountDisplayName = own?.accountBriefName ?: ownMasked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
|
||||||
|
private fun receiptMessages(context: Context, accounts: List<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 }
|
||||||
|
)
|
||||||
|
absorbed.getOrPut(k.id) { mutableSetOf() } += r
|
||||||
|
} else {
|
||||||
|
kept += candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept.sortedBy { it.timeMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rank(m: ChatMessage) = when {
|
||||||
|
m.id.startsWith("bml_") -> 0
|
||||||
|
m.id.startsWith("ntf_") -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
|
||||||
|
if (a.reference.isNotBlank() && a.reference == b.reference) return true
|
||||||
|
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
|
||||||
|
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false
|
||||||
|
if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
|
||||||
|
if (a.dateOnly || b.dateOnly) {
|
||||||
|
// A date-only entry could be any transfer that day, so the names must agree too.
|
||||||
|
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
|
||||||
|
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
|
||||||
|
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
|
||||||
|
}
|
||||||
|
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply {
|
||||||
|
timeInMillis = millis
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
|
||||||
|
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
|
||||||
|
|
||||||
|
private fun load(context: Context): State {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return State(0L, emptyList())
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||||
|
val arr = root.optJSONArray("messages") ?: JSONArray()
|
||||||
|
State(
|
||||||
|
sinceMillis = root.optLong("since", 0L),
|
||||||
|
messages = (0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
ChatMessage(
|
||||||
|
id = o.optString("id"),
|
||||||
|
peerKey = o.optString("peerKey"),
|
||||||
|
peerName = o.optString("peerName"),
|
||||||
|
amount = o.optDouble("amount", 0.0),
|
||||||
|
currency = o.optString("currency"),
|
||||||
|
timeMillis = o.optLong("time", 0L),
|
||||||
|
accountNumber = o.optString("account"),
|
||||||
|
accountDisplayName = o.optString("accountName"),
|
||||||
|
reference = o.optString("reference"),
|
||||||
|
note = o.optString("note"),
|
||||||
|
peerAccount = o.optString("peerAccount"),
|
||||||
|
receiptKey = o.optString("receiptKey"),
|
||||||
|
dateOnly = o.optBoolean("dateOnly", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { State(0L, emptyList()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(context: Context, state: State) {
|
||||||
|
try {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (m in state.messages) arr.put(JSONObject().apply {
|
||||||
|
put("id", m.id)
|
||||||
|
put("peerKey", m.peerKey)
|
||||||
|
put("peerName", m.peerName)
|
||||||
|
put("amount", m.amount)
|
||||||
|
put("currency", m.currency)
|
||||||
|
put("time", m.timeMillis)
|
||||||
|
put("account", m.accountNumber)
|
||||||
|
put("accountName", m.accountDisplayName)
|
||||||
|
put("reference", m.reference)
|
||||||
|
put("note", m.note)
|
||||||
|
put("peerAccount", m.peerAccount)
|
||||||
|
put("receiptKey", m.receiptKey)
|
||||||
|
put("dateOnly", m.dateOnly)
|
||||||
|
})
|
||||||
|
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ data class ContactDisplay(
|
|||||||
val network: TransferNetwork,
|
val network: TransferNetwork,
|
||||||
val bankColor: String,
|
val bankColor: String,
|
||||||
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
||||||
|
val bankName: String?, // shown in the contact details sheet; null = hide row
|
||||||
|
val currency: String?, // shown in the contact details sheet; null = hide row
|
||||||
val imageHash: String?,
|
val imageHash: String?,
|
||||||
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
||||||
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
|
|||||||
|
|
||||||
class CredentialStore(context: Context) {
|
class CredentialStore(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
|
||||||
|
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
|
||||||
|
}
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||||
private val keyAlias = "basedbank_credential_key"
|
private val keyAlias = "basedbank_credential_key"
|
||||||
private val transformation = "AES/GCM/NoPadding"
|
private val transformation = "AES/GCM/NoPadding"
|
||||||
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
|
|||||||
data class FahipayCredentials(val idCard: String, val password: String)
|
data class FahipayCredentials(val idCard: String, val password: String)
|
||||||
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
||||||
|
|
||||||
|
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
|
||||||
|
|
||||||
|
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
|
||||||
|
fun getLoginOrder(): List<String> {
|
||||||
|
val all = getMibLoginIds().map { loginKey("mib", it) } +
|
||||||
|
getBmlLoginIds().map { loginKey("bml", it) } +
|
||||||
|
getFahipayLoginIds().map { loginKey("fahipay", it) } +
|
||||||
|
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
|
||||||
|
val saved = try {
|
||||||
|
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
return saved.filter { it in all } + all.filter { it !in saved }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
|
||||||
|
fun loginRank(): (String) -> Int {
|
||||||
|
val order = getLoginOrder()
|
||||||
|
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
|
||||||
|
fun setLoginOrder(keys: List<String>) {
|
||||||
|
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
|
||||||
|
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
|
||||||
|
setMibLoginIds(idsFor("mib"))
|
||||||
|
setBmlLoginIds(idsFor("bml"))
|
||||||
|
setFahipayLoginIds(idsFor("fahipay"))
|
||||||
|
setMfaisaLoginIds(idsFor("mfaisa"))
|
||||||
|
}
|
||||||
|
|
||||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
|
|
||||||
fun getMibLoginIds(): List<String> {
|
fun getMibLoginIds(): List<String> {
|
||||||
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setMibLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getMibLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addMibLoginId(loginId: String) {
|
private fun addMibLoginId(loginId: String) {
|
||||||
val ids = getMibLoginIds().toMutableList()
|
val ids = getMibLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -160,6 +201,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setBmlLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getBmlLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addBmlLoginId(loginId: String) {
|
private fun addBmlLoginId(loginId: String) {
|
||||||
val ids = getBmlLoginIds().toMutableList()
|
val ids = getBmlLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -316,6 +362,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setFahipayLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getFahipayLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addFahipayLoginId(loginId: String) {
|
private fun addFahipayLoginId(loginId: String) {
|
||||||
val ids = getFahipayLoginIds().toMutableList()
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -473,6 +524,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setMfaisaLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getMfaisaLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addMfaisaLoginId(loginId: String) {
|
private fun addMfaisaLoginId(loginId: String) {
|
||||||
val ids = getMfaisaLoginIds().toMutableList()
|
val ids = getMfaisaLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -764,6 +820,15 @@ class CredentialStore(context: Context) {
|
|||||||
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
||||||
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
||||||
|
|
||||||
|
// ── Per-account visibility (account numbers are globally unique) ─────────
|
||||||
|
|
||||||
|
/** Returns the set of account numbers the user has chosen to hide, across all logins. */
|
||||||
|
fun getHiddenAccountNumbers(): Set<String> =
|
||||||
|
prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenAccountNumbers(accountNumbers: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply()
|
||||||
|
|
||||||
// ── Crypto primitives ─────────────────────────────────────────────────────
|
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun getOrCreateKey(): SecretKey {
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ object Totp {
|
|||||||
/**
|
/**
|
||||||
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
||||||
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
|
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
|
||||||
|
* [periodOffset] shifts the time window, e.g. 1 yields the next code.
|
||||||
*/
|
*/
|
||||||
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String {
|
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30, periodOffset: Long = 0): String {
|
||||||
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
|
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
|
||||||
val counter = System.currentTimeMillis() / 1000L / periodSeconds
|
val counter = System.currentTimeMillis() / 1000L / periodSeconds + periodOffset
|
||||||
val otp = hotp(key, counter, digits)
|
val otp = hotp(key, counter, digits)
|
||||||
return otp.toString().padStart(digits, '0')
|
return otp.toString().padStart(digits, '0')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ object BmlContactParser {
|
|||||||
network = TransferNetwork.BML,
|
network = TransferNetwork.BML,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ object FahipayContactParser {
|
|||||||
network = TransferNetwork.FAHIPAY,
|
network = TransferNetwork.FAHIPAY,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = null, // Fahipay contacts show no detail line
|
detail = null, // Fahipay contacts show no detail line
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = contact.benefAccount,
|
transferSubtitle = contact.benefAccount,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object MibContactParser {
|
|||||||
network = network,
|
network = network,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
<solid android:color="?attr/colorSurfaceContainerHigh" />
|
||||||
|
<stroke android:width="1dp" android:color="?attr/colorOutlineVariant" />
|
||||||
|
</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,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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/black"
|
||||||
|
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.61 -4.7,-4.2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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/black"
|
||||||
|
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5zM3,21h8v-8H3V21zM5,15h4v4H5V15zM13,3v8h8V3H13zM19,9h-4V5h4V9zM19,19h2v2h-2V19zM13,13h2v2h-2V13zM15,15h2v2h-2V15zM13,17h2v2h-2V17zM15,19h2v2h-2V19zM17,17h2v2h-2V17zM17,13h2v2h-2V13zM19,15h2v2h-2V15z" />
|
||||||
|
</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,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?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/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_date"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:text="@string/chat_from_account"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardFromAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:strokeColor="?attr/colorOutline">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingEnd="12dp">
|
||||||
|
|
||||||
|
<include
|
||||||
|
android:id="@+id/fromAccountRow"
|
||||||
|
layout="@layout/item_account_dropdown"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:rotation="90"
|
||||||
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilAmount"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/chat_amount"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
app:boxCornerRadiusTopStart="28dp"
|
||||||
|
app:boxCornerRadiusTopEnd="28dp"
|
||||||
|
app:boxCornerRadiusBottomStart="28dp"
|
||||||
|
app:boxCornerRadiusBottomEnd="28dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etAmount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:imeOptions="actionSend" />
|
||||||
|
|
||||||
|
</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>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -175,6 +175,32 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="end"
|
||||||
|
android:alpha="0.7">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Next"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNextOtpCode"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
android:id="@+id/otpTimer"
|
android:id="@+id/otpTimer"
|
||||||
android:layout_width="32dp"
|
android:layout_width="32dp"
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<FrameLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerView"
|
android:id="@+id/recyclerView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:paddingTop="16dp"
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="16dp"
|
||||||
android:clipToPadding="false" />
|
android:clipToPadding="false" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/emptyState"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="32dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:alpha="0.6"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_nav_otp"
|
||||||
|
android:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="No OTP codes yet.\nAdd an MIB or BML login to see its codes here."
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
|
|
||||||
<!-- Include phone number toggle -->
|
<!-- Include phone number toggle -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutIncludePhone"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp"
|
||||||
|
android:clipToPadding="false">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvLoginsTitle"
|
android:id="@+id/tvLoginsTitle"
|
||||||
@@ -20,10 +21,12 @@
|
|||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/loginsContainer"
|
android:id="@+id/loginsContainer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="-12dp"
|
||||||
android:orientation="vertical" />
|
android:orientation="vertical" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
|
|||||||
@@ -148,12 +148,16 @@
|
|||||||
app:endIconDrawable="@android:drawable/ic_menu_search"
|
app:endIconDrawable="@android:drawable/ic_menu_search"
|
||||||
app:endIconContentDescription="@string/transfer_lookup_account">
|
app:endIconContentDescription="@string/transfer_lookup_account">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||||
android:id="@+id/etTo"
|
android:id="@+id/etTo"
|
||||||
|
style="@style/Widget.Material3.AutoCompleteTextView.OutlinedBox"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textNoSuggestions"
|
android:inputType="textNoSuggestions"
|
||||||
android:maxLines="1" />
|
android:maxLines="1"
|
||||||
|
android:completionThreshold="3"
|
||||||
|
android:dropDownHeight="wrap_content"
|
||||||
|
android:imeOptions="actionSearch" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?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_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvName"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?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="4dp">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardBubble"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="200dp"
|
||||||
|
app:cardCornerRadius="18dp"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="0dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="200dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLabel"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnReceipt"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:padding="6dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/chat_view_receipt"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@drawable/ic_receipt"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBadge"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:paddingVertical="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAmount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAccount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:alpha="0.8"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNote"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvMeta"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_marginHorizontal="6dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?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:background="@drawable/bg_chat_date"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
android:paddingHorizontal="16dp"
|
android:paddingHorizontal="16dp"
|
||||||
android:paddingVertical="10dp">
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
@@ -26,25 +27,6 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent">
|
app:layout_constraintBottom_toBottomOf="parent">
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnEditContact"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_edit"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:contentDescription="@string/contact_edit" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnDeleteContact"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_delete"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:tint="?attr/colorError"
|
|
||||||
android:contentDescription="@string/contact_delete" />
|
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btnTransferContact"
|
android:id="@+id/btnTransferContact"
|
||||||
android:layout_width="36dp"
|
android:layout_width="36dp"
|
||||||
|
|||||||
@@ -2,56 +2,164 @@
|
|||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
style="@style/Widget.Material3.CardView.Filled"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
app:cardCornerRadius="16dp"
|
app:cardCornerRadius="24dp">
|
||||||
app:cardElevation="2dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="20dp">
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
|
<!-- Header: logo, bank + holder name, countdown ring -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivBankLogo"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpBank"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvOtpLabel"
|
android:id="@+id/tvOtpLabel"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
android:ellipsize="end"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant"
|
android:maxLines="1"
|
||||||
android:layout_marginBottom="8dp" />
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
<TextView
|
</LinearLayout>
|
||||||
android:id="@+id/tvOtpCode"
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/otpProgress"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
android:layout_gravity="center"
|
||||||
android:textColor="?attr/colorPrimary"
|
android:indeterminate="false"
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:letterSpacing="0.15"
|
|
||||||
android:layout_marginBottom="16dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
|
||||||
android:id="@+id/otpProgress"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:max="30"
|
android:max="30"
|
||||||
app:trackCornerRadius="4dp"
|
app:indicatorSize="40dp"
|
||||||
|
app:trackThickness="3dp"
|
||||||
|
app:trackCornerRadius="2dp"
|
||||||
app:indicatorColor="?attr/colorPrimary"
|
app:indicatorColor="?attr/colorPrimary"
|
||||||
app:trackColor="?attr/colorSurfaceVariant" />
|
app:trackColor="?attr/colorOutlineVariant" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvOtpCountdown"
|
android:id="@+id/tvOtpCountdown"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_gravity="center"
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Current code + copy -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCode"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:letterSpacing="0.12"
|
||||||
|
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
||||||
|
android:textColor="?attr/colorPrimary"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCopyOtp"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="Copy OTP"
|
||||||
|
android:tooltipText="Copy OTP"
|
||||||
|
app:icon="@drawable/ic_copy" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<!-- Next code + copy -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="Next"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNextOtpCode"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCopyNextOtp"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="Copy next OTP"
|
||||||
|
android:tooltipText="Copy next OTP"
|
||||||
|
app:icon="@drawable/ic_copy"
|
||||||
|
app:iconTint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
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">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivPhoto"
|
||||||
|
android:layout_width="112dp"
|
||||||
|
android:layout_height="112dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionTransfer"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnTransfer"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/transfer"
|
||||||
|
app:icon="@drawable/ic_send"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/transfer"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionQr"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnQr"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_qr"
|
||||||
|
app:icon="@drawable/ic_qr_code"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvQrLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_qr"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionEdit"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnEdit"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_edit"
|
||||||
|
app:icon="@drawable/ic_edit"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_edit"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionDelete"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnDelete"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_delete"
|
||||||
|
app:backgroundTint="?attr/colorErrorContainer"
|
||||||
|
app:icon="@drawable/ic_delete"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp"
|
||||||
|
app:iconTint="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_delete"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorError" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_manage_card"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_account_number"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnShareAccount"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/contact_share_account"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:src="@drawable/ic_share"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnCopyAccount"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/contact_copy_account"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:src="@drawable/ic_copy"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowRealName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_contacts"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRealName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_account_name"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowCurrency"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_currency"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCurrency"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_currency"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivBankIcon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_nav_finances"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_bank"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sourceSection"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginVertical="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="56dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivSourceIcon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSource"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSourceBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -145,4 +145,5 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
||||||
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
||||||
|
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<item name="action_open_contacts" 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>
|
||||||
@@ -332,6 +334,14 @@
|
|||||||
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
||||||
<string name="contact_deleted">Contact deleted</string>
|
<string name="contact_deleted">Contact deleted</string>
|
||||||
<string name="contact_delete_failed">Could not delete contact</string>
|
<string name="contact_delete_failed">Could not delete contact</string>
|
||||||
|
<string name="contact_account_number">Account number</string>
|
||||||
|
<string name="contact_account_name">Account name</string>
|
||||||
|
<string name="contact_bank">Bank</string>
|
||||||
|
<string name="contact_qr">QR</string>
|
||||||
|
<string name="contact_delete_warning">This can\'t be undone.</string>
|
||||||
|
<string name="contact_copy_account">Copy account number</string>
|
||||||
|
<string name="contact_share_account">Share account details</string>
|
||||||
|
<string name="contact_account_copied">Account number copied</string>
|
||||||
|
|
||||||
<!-- Financing -->
|
<!-- Financing -->
|
||||||
<string name="financing_empty">No financing deals found</string>
|
<string name="financing_empty">No financing deals found</string>
|
||||||
@@ -391,4 +401,37 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<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>
|
||||||
|
|
||||||
|
<!-- 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_from_label">From %s</string>
|
||||||
|
<string name="chat_received_label">Received</string>
|
||||||
|
<string name="chat_sent_label">Sent</string>
|
||||||
|
<string name="chat_into_label">To %s</string>
|
||||||
|
<string name="chat_debit">Debit</string>
|
||||||
|
<string name="chat_credit">Credit</string>
|
||||||
|
<string name="chat_sent_meta">✓ Sent · %s</string>
|
||||||
|
<string name="chat_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_transfer_sent">Transfer sent</string>
|
||||||
|
<string name="chat_view_receipt">Receipt</string>
|
||||||
|
<string name="chat_not_in_contacts">Not in your contacts</string>
|
||||||
|
<string name="chat_save_contact">Save contact</string>
|
||||||
|
<string name="chat_send">Send</string>
|
||||||
|
<string name="chat_to">To</string>
|
||||||
|
<string name="chat_pick_recipient_account">Send to</string>
|
||||||
|
<string name="chat_from_to_label">From %1$s → %2$s</string>
|
||||||
|
<string name="chat_receipt_missing">Receipt no longer available</string>
|
||||||
|
<string name="chat_amount">Amount</string>
|
||||||
|
<string name="chat_amount_invalid">Enter an amount</string>
|
||||||
|
<string name="chat_account_subtitle">Account %s</string>
|
||||||
|
<string name="chat_cannot_send">Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
|
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
|
||||||
|
|
||||||
|
<!-- Destructive confirmation dialog: centered icon/title, filled red confirm button -->
|
||||||
|
<style name="ThemeOverlay.BasedBank.DestructiveDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
|
||||||
|
<item name="buttonBarPositiveButtonStyle">@style/Widget.BasedBank.DestructiveDialogButton</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.BasedBank.DestructiveDialogButton" parent="Widget.Material3.Button">
|
||||||
|
<item name="backgroundTint">?attr/colorError</item>
|
||||||
|
<item name="android:textColor">?attr/colorOnError</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<item name="colorPrimary">@color/seed_primary</item>
|
<item name="colorPrimary">@color/seed_primary</item>
|
||||||
<item name="colorSecondary">@color/seed_secondary</item>
|
<item name="colorSecondary">@color/seed_secondary</item>
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
|
|||||||
|
|
||||||
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
|
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
|
||||||
|
|
||||||
|
### Login Order
|
||||||
|
|
||||||
|
The user sets the order of their logins in [Settings → Logins](14-settings.md#login-order). It applies everywhere accounts are listed:
|
||||||
|
|
||||||
|
- `HomeViewModel.accounts` and `HomeViewModel.mibCards` sort themselves by `CredentialStore.loginRank()` on every `setValue` / `postValue`, so every observer (accounts list, transfer dropdown, contact picker "My accounts", PayMV QR, BML QR pay, transfer history, BML loans in Financing) gets the same order with no per-screen sorting. The sort is stable, so accounts from the same login keep the order the bank returned them in.
|
||||||
|
- Combined MIB + BML card lists (dashboard stack, [Cards](22-cards.md)) are sorted by `CardItem.loginTag`.
|
||||||
|
- [Financing](13-financing.md) places the MIB and BML sections by whichever bank's first login ranks higher.
|
||||||
|
- The [OTP Screen](10-otp-screen.md) sorts its entries by login.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MIB Session KeepAlive
|
## MIB Session KeepAlive
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed a
|
|||||||
## TOTP Display
|
## TOTP Display
|
||||||
|
|
||||||
Each card shows:
|
Each card shows:
|
||||||
- Bank logo and name
|
- Bank logo, bank name and account holder name
|
||||||
- The current 6-digit TOTP code (large text)
|
- A circular countdown ring with the seconds left in the current 30-second window; the ring and code turn red in the last 5 seconds
|
||||||
- A circular countdown ring showing time remaining in the current 30-second window
|
- The current 6-digit TOTP code (large text) with a copy button
|
||||||
- The code refreshes automatically when the window expires — no user interaction needed
|
- The next window's code (smaller, below a divider) with its own copy button — handy when the current code is about to expire
|
||||||
|
- The codes refresh automatically when the window expires — no user interaction needed
|
||||||
|
|
||||||
|
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
|
||||||
|
|
||||||
### Algorithm
|
### Algorithm
|
||||||
|
|
||||||
@@ -30,14 +33,14 @@ Standard RFC 6238 TOTP:
|
|||||||
|
|
||||||
## Supported Banks
|
## Supported Banks
|
||||||
|
|
||||||
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt:93-98`). Seeds are per-`loginId` in `CredentialStore`.
|
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
|
||||||
|
|
||||||
| Bank | Seed source | Card label |
|
| Bank | Seed source | Card title / subtitle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `"MIB · {fullName}"` |
|
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{fullName}` |
|
||||||
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {fullName}"` |
|
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `BML` / `{fullName}` |
|
||||||
|
|
||||||
If no full name has been cached the label falls back to plain `"MIB"` / `"BML"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
|
If no full name has been cached the subtitle falls back to `"Authenticator"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Aggregates financing products across banks — MIB promotional deals and BML loa
|
|||||||
|
|
||||||
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
|
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
|
||||||
|
|
||||||
|
MIB deals carry no login tag, so the two sections are ordered as blocks: MIB first if the first MIB login ranks above the first BML login in the user's [login order](00-app-overview.md#login-order), otherwise BML first. BML loans within their section follow `HomeViewModel.accounts`, which is already in login order.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MIB Deals Section
|
## MIB Deals Section
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ Each profile entry shows:
|
|||||||
- Profile image (circular avatar)
|
- Profile image (circular avatar)
|
||||||
- Visibility toggle switch
|
- Visibility toggle switch
|
||||||
|
|
||||||
|
### Login Order
|
||||||
|
|
||||||
|
All logins are shown as one list, across banks. When there is more than one login, each row gets a three-line handle (`ic_reorder_lines`) on the right; press and drag it to move the login up or down (`SettingsLoginsFragment.attachDragHandle()`). Tapping the rest of the row still opens the login details.
|
||||||
|
|
||||||
|
On drop the order is saved with `CredentialStore.setLoginOrder()`, which:
|
||||||
|
- writes the combined order to the `login_order` pref as a JSON array of login tags (`mib_<id>`, `bml_<id>`, `fahipay_<id>`, `mfaisa_<id>` — the same format as `BankAccount.loginTag`)
|
||||||
|
- rewrites each bank's own list (`mib_login_ids`, `bml_login_ids`, …) in the same relative order, so code that walks one bank's logins stays consistent
|
||||||
|
|
||||||
|
It then calls `HomeViewModel.resortByLoginOrder()` so already-open screens re-sort immediately.
|
||||||
|
|
||||||
|
`CredentialStore.getLoginOrder()` returns the effective order: saved tags that still exist, followed by any logins not yet ordered (e.g. newly added) in the default MIB → BML → Fahipay → M-Faisa order. `loginRank()` turns that into a sort key; unknown tags sort last.
|
||||||
|
|
||||||
|
See [Login Order](00-app-overview.md#login-order) for where the order is applied.
|
||||||
|
|
||||||
### Visibility Toggle
|
### Visibility Toggle
|
||||||
|
|
||||||
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
|
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ A horizontal `RecyclerView` with `LinearSnapHelper` that paginates BML cards + M
|
|||||||
|
|
||||||
- BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"`
|
- BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"`
|
||||||
- MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`)
|
- MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`)
|
||||||
|
- Cards are sorted by the user's [login order](00-app-overview.md#login-order)
|
||||||
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
|
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
|
||||||
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
|
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ A horizontal `RecyclerView` driven by `CardStackAdapter` with a `PagerSnapHelper
|
|||||||
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
|
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
|
||||||
- MIB: `viewModel.mibCards`
|
- MIB: `viewModel.mibCards`
|
||||||
|
|
||||||
The combined order is `bmlActive + mibActive + bmlInactive + mibInactive`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
|
Active cards come first, then inactive ones; within each group cards are sorted by the user's [login order](00-app-overview.md#login-order) via `CardItem.loginTag`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
|
||||||
|
|
||||||
### Card Art
|
### Card Art
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
|||||||
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
|
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
|
||||||
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
|
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
|
||||||
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
|
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
|
||||||
| [14 — Settings](14-settings.md) | Settings hub: Logins, Appearance, Privacy & Security, Notifications, Storage, About |
|
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
|
||||||
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
|
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
|
||||||
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
|
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
|
||||||
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |
|
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
- Support for BML payment gateway using PayMV QR
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- NFC tap-to-pay with wallet integration
|
||||||
|
- Set a default card for NFC payments
|
||||||
|
- Launcher shortcut renamed to "Scan to Pay"
|
||||||
|
- New NFC icon
|
||||||
|
- Cards hidden when not relevant
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- Default transfer accounts
|
||||||
|
- Share-to-scan-to-pay with merchant details
|
||||||
|
- Static BML QR scans saved to recents
|
||||||
|
- Unified QR scan flow across banks
|
||||||
|
- Friendlier transfer flow UI
|
||||||
|
- Removed *** masking on PayMV QR amounts
|
||||||
|
- New NFC tap-to-pay animation
|
||||||
|
- Fixed NFC-related crash
|
||||||
|
- Scanning a PayMV QR after selecting a card falls back to the transfer flow
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Fixed transfer form resetting on switching tabs
|
||||||
|
- Fixed default account not selected from contacts page or contact picker
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- New navigation mode: Nostalgic
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
- View details of blocked balance
|
||||||
|
- Notifications
|
||||||
|
- Wheel optimizations
|
||||||
|
- Fixed back navigation from Finances page
|
||||||
|
- Fixed "An error occurred" instead of "No available balance"
|
||||||
|
- Fixed Islamic visa card image
|
||||||
|
- Fixed PIN button glyph scaling and theme bugs
|
||||||
|
- Fixed receipt buttons on some phones
|
||||||
|
- Fixed balance not updating after BML QR payment
|
||||||
|
- Fixed onboarding warning bypass
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- NFC prompts for off, default and not supported states
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- About page
|
||||||
|
- Donate to support development
|
||||||
|
- Import TOTP QR and Google Authenticator export QR
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- BML and MIB card freeze/unfreeze
|
||||||
|
- Background service and push notifications (very unstable)
|
||||||
|
- Cards ordered: active BML, MIB, inactive BML, inactive MIB
|
||||||
|
- Faster notification loading
|
||||||
|
- Fixed notification icon in light theme
|
||||||
|
- Fixed contact picker rendering
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
- Edge-to-edge support for Android 15+
|
||||||
|
- Redesigned onboarding pages and added disclaimer
|
||||||
|
- otpauth://totp/ URI import for OTP seed
|
||||||
|
- Hint shown when biometrics are disabled
|
||||||
|
- App resumes last screen after unlocking
|
||||||
|
- Loading indicators for account info and transfers
|
||||||
|
- Fixed bottom bar staying above the keyboard
|
||||||
|
- Fixed adding a contact with multiple BML logins
|
||||||
|
- Added Visa, Master and Amex card icons
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- Contact picker recents show most recent first
|
||||||
|
- Outstanding and unbilled credit card values
|
||||||
|
- Fixed MIB USD history amounts
|
||||||
|
- Fixed currency detection in MIB to MIB transfers
|
||||||
|
- Option to add contact during BML to MIB USD transfer
|
||||||
|
- Settings item renamed to "Confirm Transaction"
|
||||||
|
- Fixed "Cards" title in tap to pay from shortcut
|
||||||
|
- Removed gap under bottom bar on cards screens
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Initial support for Ooredoo M-Faisa
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Updated BML card mapping (fixed Mastercard Platinum)
|
||||||
|
- Disabled PayMV QR for M-Faisa accounts
|
||||||
|
- Fixed share and save buttons showing on shared receipt image
|
||||||
|
- Fixed bottom bar flashing before receipt share sheet
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Fixed QR scanner failing to launch on revisit
|
||||||
|
- Fixed PayMV scan keeping unusable M-Faisa source
|
||||||
|
- Fixed BML merchant staying loaded after payment
|
||||||
|
- Transfer logic split into per-bank handlers
|
||||||
|
- Note: Fahipay is still broken
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- No changes from previous version
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Hide accounts you don't want to see
|
||||||
|
- Reorder logins
|
||||||
|
- Account number field on the transfer page suggests saved contacts
|
||||||
|
- Transfer page shows the other account's currency
|
||||||
|
- Search button on the transfer "To" field keyboard, focus moves to amount afterwards
|
||||||
|
- Search key in the add contact drawer now works
|
||||||
|
- Saving a profile disable no longer sends unnecessary refresh requests
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Redesigned OTP page: codes turn red in the last 5 seconds and there are copy buttons, and you can tap a card to copy its code
|
||||||
|
- OTP page and login setup now also show the next code
|
||||||
|
- Redesigned contacts list: tap a contact to open a details sheet, which now holds the edit and delete buttons
|
||||||
|
- Contact details show the account number, account name, bank and currency
|
||||||
|
- Copy or share a contact's account details, or show a PayMV QR code for them
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Customizable bottom bar and quick actions
|
||||||
|
- Multiple Fahipay and MIB account support
|
||||||
|
- Updated BML logo
|
||||||
|
- Transfer button stays disabled until all required fields are filled
|
||||||
|
- Fixed display issues on phones without edge-to-edge screens
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- BML business profile support
|
||||||
|
- Privacy mode toggle to hide balances
|
||||||
|
- View previous transfer receipts, full screen view, long-press to copy, save and share
|
||||||
|
- Initial PayMV QR generation
|
||||||
|
- Renamed Activities to Recent Transfers
|
||||||
|
- Fixed card transaction history not loading
|
||||||
|
- Fixed BML contact list loading
|
||||||
|
- Fixed single-profile multi-login issue
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- BML loans shown on the Financing page, included in pending finances
|
||||||
|
- Animated lock and show/hide amount icons
|
||||||
|
- Credit cards and spending limits in a separate dashboard section, limits shown as progress bars
|
||||||
|
- Optimized MIB session keepalive
|
||||||
|
- Long-press to copy receipt fields in full screen
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- MIB debit/credit cards shown in a dashboard carousel
|
||||||
|
- Transfers from BML business profile accounts
|
||||||
|
- New setting: auto unlock on correct PIN
|
||||||
|
- Fewer network requests on BML session refresh
|
||||||
|
- Refresh indicator moved to the action bar
|
||||||
|
- Reworked back button navigation
|
||||||
|
- Fixed PayMV QR page empty space
|
||||||
|
- BML loan accounts excluded from balance total and transfer sources
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Compressed MIB card images to reduce app size
|
||||||
|
- Connectivity banners for network issues
|
||||||
|
- Transfer button disabled when source bank is unavailable
|
||||||
|
- Fixed cache read issue when refreshing offline
|
||||||
|
- Privacy mode hides amounts in transfer dropdown
|
||||||
|
- User-agent reports real device info
|
||||||
|
- Fixed error on failed PIN entry
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- QR payments from BML gateway and static QR payments from BML cards
|
||||||
|
- Zoom and flashlight on QR scanner
|
||||||
|
- Bank/profile images in accounts list and transfer dropdown
|
||||||
|
- Local per-profile images for BML and Fahipay
|
||||||
|
- Better network error handling and timeout handling
|
||||||
|
- Fixed contacts infinite loading offline
|
||||||
|
- Fixed crash with no network on transaction history
|
||||||
|
- Fixed nav bar buttons disappearing
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
- Manage cards mode and default payment card
|
||||||
|
- Theme customizations
|
||||||
|
- Launcher shortcuts
|
||||||
|
- Tap to copy OTP
|
||||||
|
- Unified card settings and Pay with Card page
|
||||||
|
- Dashboard shows MVR and USD blocked funds separately
|
||||||
|
- Accounts list uses available balance
|
||||||
|
- Fixed lockscreen bypass on rooted devices
|
||||||
|
- Fixed empty dashboard without accounts
|
||||||
|
- PayMV QR generation fix
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Thijooree is an unofficial, unified banking app for the Maldives, built by reverse engineering the official bank apps.
|
||||||
|
|
||||||
|
Supported services:
|
||||||
|
- Bank of Maldives (BML)
|
||||||
|
- Maldives Islamic Bank (MIB)
|
||||||
|
- Fahipay (initial support)
|
||||||
|
- Ooredoo M-Faisa (initial support)
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Multiple logins per bank in one app, with a unified dashboard of all your accounts
|
||||||
|
- Transfers between accounts and banks, with saved contacts and Favara IDs
|
||||||
|
- Transaction history for accounts
|
||||||
|
- Cards: view BML and MIB cards, freeze/unfreeze
|
||||||
|
- NFC tap-to-pay
|
||||||
|
- QR payments: scan PayMV and BML QR codes, generate your own PayMV QR, with zoom and flashlight on the scanner
|
||||||
|
- Loans, credit cards, spending limits and blocked funds on the Financing page
|
||||||
|
- Built-in TOTP: import from otpauth:// URIs, QR codes or Google Authenticator exports
|
||||||
|
- Notifications
|
||||||
|
- Privacy mode to hide balances
|
||||||
|
- PIN and biometric app lock
|
||||||
|
- Customizable bottom bar, quick actions and themes
|
||||||
|
- Hide specific accounts per profile
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Your banking credentials for each bank
|
||||||
|
|
||||||
|
Privacy:
|
||||||
|
No data ever leaves your device except the API calls to the banking services themselves.
|
||||||
|
|
||||||
|
Disclaimer:
|
||||||
|
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by BML, MIB, Fahipay or Ooredoo. Use at your own risk. Review the source code before entering your banking credentials.
|
||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 89 KiB |