Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83ca37eade
|
||
|
|
da5cca79c6
|
||
|
|
a37354fc82
|
||
|
|
66568a464a
|
||
|
|
5210e6207f
|
||
|
|
369c6e4a37
|
||
|
|
fbedc631c5
|
||
|
|
7d6c192a63
|
||
|
|
e7f7590654
|
||
|
|
a048be939c
|
||
|
|
102a986201
|
||
|
|
440db13a0f
|
||
|
|
0dc81fef62
|
||
|
|
7e52b510a1
|
||
|
|
0d7833af5c
|
||
|
|
9e45e36c0a
|
||
|
|
c883b6b521
|
||
|
|
5dc37aadf0
|
||
|
|
1e3cf3420b
|
||
|
|
d98dcfd407
|
||
|
|
3b55fa30a8
|
||
|
|
7f0f2707a1
|
||
|
|
117733b766
|
||
|
|
d769df6f6b
|
||
|
|
caf0db60da
|
||
|
|
b48022d249
|
||
|
|
7574a5e8b0
|
||
|
|
992c8d4364
|
||
|
|
80fd238195
|
@@ -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: |
|
||||||
echo "No release notes" > release_notes.md
|
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
# VERSION="${{ gitea.ref_name }}"
|
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||||
# VERSION="${VERSION#v}"
|
|
||||||
#
|
if [ -f "$CHANGELOG" ]; then
|
||||||
# awk -v ver="$VERSION" '
|
cp "$CHANGELOG" release_notes.md
|
||||||
# BEGIN { found=0 }
|
else
|
||||||
# /^## \[/ {
|
echo "No changelog found at $CHANGELOG"
|
||||||
# if (found) exit
|
echo "No release notes" > release_notes.md
|
||||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
fi
|
||||||
# }
|
|
||||||
# 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 = 24
|
versionCode = 28
|
||||||
versionName = "1.0.23"
|
versionName = "1.0.27"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
|
||||||
|
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
|
||||||
|
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
|
||||||
|
*/
|
||||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||||
Request.Builder().url(url)
|
Request.Builder().url(url)
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
.header("accept", "application/json")
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("user-agent", BML_USER_AGENT)
|
||||||
|
.header("authorization", "Bearer ${session.accessToken}")
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
|||||||
val currency: String
|
val currency: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
|
||||||
|
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
|
||||||
|
* parse failures stay plain exceptions so the generic message is used for those instead.
|
||||||
|
*/
|
||||||
|
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
|
||||||
|
|
||||||
data class BmlQrPayResult(
|
data class BmlQrPayResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val merchant: String = "",
|
val merchant: String = "",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -10,17 +11,27 @@ class BmlQrPayClient {
|
|||||||
private val client = newBmlApiClient()
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a BML QR URL to merchant details.
|
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
* `35` → `20` → `01` reference.
|
||||||
|
*
|
||||||
|
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
|
||||||
|
* resolves too, so this is parity rather than a requirement.
|
||||||
*/
|
*/
|
||||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
|
||||||
|
val key = Base64.encodeToString(
|
||||||
|
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||||
val request = bmlApiRequest(session,
|
val request = bmlApiRequest(session,
|
||||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
val body = response.body?.string() ?: throw Exception("No response")
|
val body = response.body?.string() ?: throw Exception("No response")
|
||||||
|
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
|
||||||
|
if (!body.trimStart().startsWith("{"))
|
||||||
|
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
|
||||||
val json = JSONObject(body)
|
val json = JSONObject(body)
|
||||||
if (!json.optBoolean("success"))
|
if (!json.optBoolean("success"))
|
||||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
throw BmlQrPayLookupException(
|
||||||
|
json.optInt("code"),
|
||||||
|
json.optString("message").ifBlank { "Lookup failed" })
|
||||||
val payload = json.getJSONObject("payload")
|
val payload = json.getJSONObject("payload")
|
||||||
val addr2 = payload.optString("narrative2").trim()
|
val addr2 = payload.optString("narrative2").trim()
|
||||||
val addr3 = payload.optString("narrative3").trim()
|
val addr3 = payload.optString("narrative3").trim()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupMerchant(qrUrl: String) {
|
private fun lookupMerchant(qrUrl: String) {
|
||||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val session = app.anyBmlSession() ?: run {
|
val session = app.anyBmlSession() ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||||
catch (_: Exception) { null }
|
catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (_binding == null) return@launch
|
if (_binding == null) return@launch
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentChatBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
import sh.sar.basedbank.util.ChatAccount
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One person's transfers as a chat, or one business's card / Scan to Pay payments.
|
||||||
|
*
|
||||||
|
* A person can have several accounts (e.g. MVR and USD); the "To" bar picks which one to send to.
|
||||||
|
* Sending opens [TransferFragment] in a sheet, prefilled with the recipient, source account,
|
||||||
|
* amount and note, so the usual confirm/OTP flow still applies. A monthly summary sits on top,
|
||||||
|
* and long-pressing a bubble offers Send again / receipt / copy.
|
||||||
|
*/
|
||||||
|
class ChatFragment : Fragment(), ContactSheetHost {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private var peerKey = ""
|
||||||
|
private var header: ViewChatHeaderBinding? = null
|
||||||
|
private val adapter = ChatMessagesAdapter(
|
||||||
|
onReceiptClick = { openReceipt(it.receiptKey) },
|
||||||
|
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
|
||||||
|
)
|
||||||
|
private var thread: ChatThread? = null
|
||||||
|
private var fromAccounts: List<BankAccount> = emptyList()
|
||||||
|
private var selectedFrom: BankAccount? = null
|
||||||
|
private var selectedTo: ChatAccount? = null
|
||||||
|
private var merchantQr: String? = null
|
||||||
|
private var cardAccounts: List<BankAccount> = emptyList()
|
||||||
|
private var selectedCard: BankAccount? = null
|
||||||
|
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
|
||||||
|
private var payStartedAt = 0L
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
|
||||||
|
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
|
||||||
|
binding.rvMessages.adapter = adapter
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
adapter.setHideAmounts(it)
|
||||||
|
thread?.let { t -> bindSummary(t) }
|
||||||
|
bindFromChip()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
|
||||||
|
cardAccounts = accounts.filter {
|
||||||
|
it.bank == "BML" && it.profileType in CARD_TYPES && it.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
}
|
||||||
|
bindFromAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
|
||||||
|
binding.btnSaveContact.setOnClickListener { openContact() }
|
||||||
|
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
|
||||||
|
binding.btnSend.setOnClickListener { send() }
|
||||||
|
binding.etNote.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
|
||||||
|
}
|
||||||
|
binding.btnPayAgain.setOnClickListener { payBusiness() }
|
||||||
|
|
||||||
|
// Transfers started from this chat come straight back here instead of opening the receipt.
|
||||||
|
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||||
|
onTransferDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putString(ARG_PEER_KEY, peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThread() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
|
||||||
|
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
|
||||||
|
val t = threads.firstOrNull { it.peerKey == peerKey }
|
||||||
|
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
thread = t
|
||||||
|
if (t == null) return@launch
|
||||||
|
peerKey = t.peerKey
|
||||||
|
adapter.showDestination = t.accounts.size > 1
|
||||||
|
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
|
||||||
|
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
|
||||||
|
bindSummary(t)
|
||||||
|
|
||||||
|
bindFromAccounts()
|
||||||
|
val previous = selectedTo
|
||||||
|
val to = t.accounts.firstOrNull { it.account == previous?.account }
|
||||||
|
?: t.accounts.firstOrNull { it.account == t.peerAccount }
|
||||||
|
selectTo(to, matchFromCurrency = previous == null)
|
||||||
|
updateToolbar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
|
||||||
|
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val t = thread ?: return
|
||||||
|
selectedTo = to
|
||||||
|
|
||||||
|
if (t.isMerchant) {
|
||||||
|
// Businesses paid by card / Scan to Pay: history only, nothing to send to or save.
|
||||||
|
b.toAccountBar.visibility = View.GONE
|
||||||
|
b.saveContactBar.visibility = View.GONE
|
||||||
|
b.composer.visibility = View.GONE
|
||||||
|
b.tvCannotSend.visibility = View.GONE
|
||||||
|
b.btnPayAgain.visibility = View.VISIBLE
|
||||||
|
bindMerchantQr(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.btnPayAgain.visibility = View.GONE
|
||||||
|
// One account: it's shown in the header. Several: this bar picks between them.
|
||||||
|
b.toAccountBar.visibility = if (to == null || t.accounts.size < 2) View.GONE else View.VISIBLE
|
||||||
|
if (to != null) {
|
||||||
|
b.tvToAccount.text = to.account
|
||||||
|
b.tvToCurrency.text = to.currency
|
||||||
|
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
|
||||||
|
b.tvToNickname.text = nickname
|
||||||
|
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
|
||||||
|
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offer to save the selected account (or the person, when no account is known yet).
|
||||||
|
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
val canSend = to != null
|
||||||
|
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
|
||||||
|
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
// Sending to a USD account: default to a USD source account, and likewise for MVR.
|
||||||
|
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
|
||||||
|
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
|
||||||
|
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
|
||||||
|
}
|
||||||
|
updateCurrencyWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecipientPicker() {
|
||||||
|
val t = thread ?: return
|
||||||
|
if (t.accounts.size < 2) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in t.accounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
|
||||||
|
?: getString(R.string.chat_not_in_contacts)
|
||||||
|
row.tvDropdownAccountNumber.text = account.account
|
||||||
|
row.tvDropdownBalance.text = account.currency
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
// A USD account can only be paid from a USD account.
|
||||||
|
val unavailable = account.currency.equals("USD", ignoreCase = true) && !hasUsdSource()
|
||||||
|
row.tvDropdownAccountType.text = getString(R.string.chat_needs_usd_source)
|
||||||
|
row.tvDropdownAccountType.visibility = if (unavailable) View.VISIBLE else View.GONE
|
||||||
|
row.root.alpha = if (unavailable) 0.4f else 1f
|
||||||
|
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
if (!unavailable) row.root.setOnClickListener {
|
||||||
|
selectTo(account, matchFromCurrency = true)
|
||||||
|
updateToolbar()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
|
||||||
|
val pad = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(pad, pad / 2, pad, pad / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFromAccounts() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
// Default to the account last used with this person.
|
||||||
|
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
|
||||||
|
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
|
||||||
|
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
|
||||||
|
|
||||||
|
selectFrom(selectedFrom!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTransferDone() {
|
||||||
|
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
|
||||||
|
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
|
||||||
|
}
|
||||||
|
binding.etAmount.text = null
|
||||||
|
binding.etNote.text = null
|
||||||
|
// The receipt shows the new bubble right away; history catches up a little later and
|
||||||
|
// replaces it with the bank's own entry.
|
||||||
|
sync()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(HISTORY_CATCH_UP_MS)
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
// Only offer the receipt this payment made; QR payments don't save one.
|
||||||
|
val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
|
||||||
|
val merchant = thread?.isMerchant == true
|
||||||
|
Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
|
||||||
|
.setAnchorView(if (binding.composer.visibility == View.VISIBLE) binding.composer else binding.btnPayAgain)
|
||||||
|
.apply { if (receipt != null) setAction(R.string.chat_view_receipt) { openReceipt(receipt.savedAt.toString()) } }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReceipt(receiptKey: String) {
|
||||||
|
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
|
||||||
|
if (entry == null) {
|
||||||
|
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
if (_binding != null) loadThread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectFrom(account: BankAccount) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
selectedFrom = account
|
||||||
|
bindFromChip()
|
||||||
|
b.tilAmount.prefixText = account.currencyName
|
||||||
|
updateCurrencyWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chip shows the account (or, in a business chat, the card) paid from, with its balance. */
|
||||||
|
private fun bindFromChip() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val account = (if (thread?.isMerchant == true) selectedCard else selectedFrom) ?: return
|
||||||
|
val balance = AccountListParser.from(account)?.balance.orEmpty()
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val money = when {
|
||||||
|
balance.isBlank() -> account.currencyName
|
||||||
|
hide -> AccountHistoryAdapter.maskAmount(balance)
|
||||||
|
else -> balance
|
||||||
|
}
|
||||||
|
b.btnFrom.text = getString(R.string.chat_from_chip, account.accountBriefName, account.accountNumber, money)
|
||||||
|
b.btnFrom.contentDescription = getString(R.string.chat_from_account) + ": " + b.btnFrom.text
|
||||||
|
|
||||||
|
// A card shows its network logo up front, in full colour; an account keeps the dropdown arrow.
|
||||||
|
val logo = if (thread?.isMerchant == true) sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(account) else null
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
if (logo != null) {
|
||||||
|
b.btnFrom.setIconResource(logo)
|
||||||
|
b.btnFrom.iconTint = null
|
||||||
|
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_START
|
||||||
|
b.btnFrom.iconSize = (28 * density).toInt()
|
||||||
|
} else {
|
||||||
|
b.btnFrom.setIconResource(R.drawable.ic_arrow_right)
|
||||||
|
b.btnFrom.iconTint = android.content.res.ColorStateList.valueOf(
|
||||||
|
MaterialColors.getColor(b.btnFrom, com.google.android.material.R.attr.colorOnSecondaryContainer))
|
||||||
|
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_END
|
||||||
|
b.btnFrom.iconSize = (16 * density).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCardPicker() {
|
||||||
|
if (cardAccounts.isEmpty()) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_card)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (card in cardAccounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
bindAccountRow(row, card)
|
||||||
|
sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(card)?.let {
|
||||||
|
row.ivDropdownCardLogo.setImageResource(it)
|
||||||
|
row.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
if (card.accountNumber == selectedCard?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
selectedCard = card
|
||||||
|
bindFromChip()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MVR can't be sent into a USD account: say so and disable Send. USD into an MVR account is
|
||||||
|
* converted by BML at its rate: allowed, with a warning.
|
||||||
|
*/
|
||||||
|
private fun updateCurrencyWarning() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val from = selectedFrom?.currencyName.orEmpty()
|
||||||
|
val to = selectedTo?.currency.orEmpty()
|
||||||
|
val blocked = isBlockedPair(from, to)
|
||||||
|
val converts = from.equals("USD", ignoreCase = true) && to.equals("MVR", ignoreCase = true)
|
||||||
|
b.tvCurrencyWarning.text = when {
|
||||||
|
blocked -> getString(R.string.chat_currency_blocked)
|
||||||
|
converts -> getString(R.string.chat_currency_warning)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
b.tvCurrencyWarning.visibility = if (blocked || converts) View.VISIBLE else View.GONE
|
||||||
|
b.btnSend.isEnabled = !blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MVR → USD account: BML doesn't allow it. */
|
||||||
|
private fun isBlockedPair(fromCurrency: String, toCurrency: String) =
|
||||||
|
fromCurrency.equals("MVR", ignoreCase = true) && toCurrency.equals("USD", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun hasUsdSource() = fromAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
|
|
||||||
|
/** Looks up a saved static QR for this business: with one, the button pays it directly. */
|
||||||
|
private fun bindMerchantQr(t: ChatThread) {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val qr = withContext(Dispatchers.IO) { ChatStore.merchantQr(ctx, t.peerName) }
|
||||||
|
val b = _binding ?: return@launch
|
||||||
|
merchantQr = qr
|
||||||
|
b.btnPayAgain.text = if (qr != null) getString(R.string.chat_pay_business, t.peerName)
|
||||||
|
else getString(R.string.chat_pay_again)
|
||||||
|
// A saved QR is paid from a card picked here: show just the card chip above the button.
|
||||||
|
b.composer.visibility = if (qr != null && cardAccounts.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
b.amountRow.visibility = View.GONE
|
||||||
|
b.tvCurrencyWarning.visibility = View.GONE
|
||||||
|
if (qr != null && selectedCard == null) {
|
||||||
|
val lastCard = t.messages.lastOrNull { m -> cardAccounts.any { it.accountNumber == m.accountNumber } }?.accountNumber
|
||||||
|
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
|
||||||
|
selectedCard = cardAccounts.firstOrNull { it.accountNumber == lastCard }
|
||||||
|
?: cardAccounts.firstOrNull { it.accountNumber == defaultCard }
|
||||||
|
?: cardAccounts.firstOrNull()
|
||||||
|
}
|
||||||
|
bindFromChip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved static QR: pay it in the sheet over the chat. Otherwise: open the scanner. */
|
||||||
|
private fun payBusiness() {
|
||||||
|
val qr = merchantQr
|
||||||
|
if (qr == null) {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment.newInstanceWithAutoScan())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||||
|
val transfer = TransferFragment.newInstanceFromBmlQr(qr, selectedCard?.accountNumber, returnOnSuccess = true)
|
||||||
|
payStartedAt = System.currentTimeMillis()
|
||||||
|
hideKeyboard()
|
||||||
|
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This month with this person (sent / received / net) or at this business (spent / payments). */
|
||||||
|
private fun bindSummary(t: ChatThread) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val monthStart = java.util.Calendar.getInstance().apply {
|
||||||
|
set(java.util.Calendar.DAY_OF_MONTH, 1)
|
||||||
|
set(java.util.Calendar.HOUR_OF_DAY, 0); set(java.util.Calendar.MINUTE, 0)
|
||||||
|
set(java.util.Calendar.SECOND, 0); set(java.util.Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
val thisMonth = t.messages.filter { it.timeMillis >= monthStart }
|
||||||
|
if (thisMonth.isEmpty()) { b.summaryCard.visibility = View.GONE; return }
|
||||||
|
// One currency per card: the selected account's, else the one used most this month.
|
||||||
|
val currency = selectedTo?.currency?.takeIf { c -> thisMonth.any { it.currency == c } }
|
||||||
|
?: thisMonth.groupingBy { it.currency }.eachCount().maxByOrNull { it.value }!!.key
|
||||||
|
val ms = thisMonth.filter { it.currency == currency }
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
fun money(v: Double) = if (hide) "$currency ••••" else "$currency ${"%,.2f".format(v)}"
|
||||||
|
val month = java.text.SimpleDateFormat("MMMM", java.util.Locale.getDefault()).format(java.util.Date())
|
||||||
|
|
||||||
|
b.summaryCard.visibility = View.VISIBLE
|
||||||
|
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
|
||||||
|
if (t.isMerchant) {
|
||||||
|
b.tvSumLabel1.text = getString(R.string.chat_sum_spent, month)
|
||||||
|
b.tvSumValue1.text = money(sent)
|
||||||
|
b.sumColumn2.visibility = View.GONE
|
||||||
|
b.tvSumLabel3.text = getString(R.string.chat_sum_payments)
|
||||||
|
b.tvSumValue3.text = ms.size.toString()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val received = ms.filter { !it.isSent }.sumOf { it.amount }
|
||||||
|
val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
b.sumColumn2.visibility = View.VISIBLE
|
||||||
|
b.tvSumLabel1.text = getString(R.string.chat_sum_sent, month)
|
||||||
|
b.tvSumValue1.text = money(sent)
|
||||||
|
b.tvSumValue1.setTextColor(MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorError))
|
||||||
|
b.tvSumLabel2.text = getString(R.string.chat_sum_received)
|
||||||
|
b.tvSumValue2.text = money(received)
|
||||||
|
b.tvSumValue2.setTextColor(android.graphics.Color.parseColor(if (night) "#81C784" else "#2E7D32"))
|
||||||
|
b.tvSumLabel3.text = getString(R.string.chat_sum_net)
|
||||||
|
val net = received - sent
|
||||||
|
b.tvSumValue3.text = if (hide) "••••" else (if (net < 0) "−" else "+") + "%,.2f".format(kotlin.math.abs(net))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-press on a bubble: send the same again, open its receipt, or copy its reference. */
|
||||||
|
private fun showMessageMenu(m: sh.sar.basedbank.util.ChatMessage, anchor: View) {
|
||||||
|
val t = thread ?: return
|
||||||
|
val menu = android.widget.PopupMenu(requireContext(), anchor, android.view.Gravity.END)
|
||||||
|
val amount = "%.2f".format(kotlin.math.abs(m.amount))
|
||||||
|
val canSendAgain = m.isSent && !t.isMerchant && selectedTo != null
|
||||||
|
if (canSendAgain) menu.menu.add(0, 1, 0, getString(R.string.chat_send_again, "${m.currency} $amount"))
|
||||||
|
if (m.receiptKey.isNotBlank()) menu.menu.add(0, 2, 1, R.string.chat_view_receipt_full)
|
||||||
|
if (m.reference.isNotBlank()) menu.menu.add(0, 3, 2, R.string.chat_copy_reference)
|
||||||
|
if (menu.menu.size() == 0) return
|
||||||
|
menu.setOnMenuItemClickListener { item ->
|
||||||
|
when (item.itemId) {
|
||||||
|
1 -> {
|
||||||
|
// Same account it went to, when that account is still one of theirs.
|
||||||
|
t.accounts.firstOrNull { it.account == m.peerAccount }?.let { selectTo(it, matchFromCurrency = true) }
|
||||||
|
binding.etAmount.setText(amount)
|
||||||
|
binding.etNote.setText(m.note)
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
2 -> openReceipt(m.receiptKey)
|
||||||
|
3 -> {
|
||||||
|
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
|
||||||
|
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("reference", m.reference))
|
||||||
|
Snackbar.make(binding.root, R.string.chat_reference_copied, Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
menu.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
|
||||||
|
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
|
||||||
|
val display = AccountListParser.from(account)
|
||||||
|
row.tvDropdownAccountName.text = account.accountBriefName
|
||||||
|
row.tvDropdownAccountNumber.text = account.accountNumber
|
||||||
|
val balance = display?.balance.orEmpty()
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
|
||||||
|
val type = display?.typeLabel.orEmpty()
|
||||||
|
row.tvDropdownAccountType.text = type
|
||||||
|
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAccountPicker() {
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in fromAccounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
bindAccountRow(row, account)
|
||||||
|
// MVR can't go into a USD account: show those accounts, but not selectable.
|
||||||
|
val blocked = isBlockedPair(account.currencyName, selectedTo?.currency.orEmpty())
|
||||||
|
if (blocked) {
|
||||||
|
row.tvDropdownAccountType.text = getString(R.string.chat_cant_send_mvr_to_usd)
|
||||||
|
row.tvDropdownAccountType.visibility = View.VISIBLE
|
||||||
|
row.root.alpha = 0.4f
|
||||||
|
}
|
||||||
|
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
if (!blocked) row.root.setOnClickListener {
|
||||||
|
selectFrom(account)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val from = selectedFrom ?: return
|
||||||
|
val to = selectedTo ?: return
|
||||||
|
if (isBlockedPair(from.currencyName, to.currency)) return
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0.0) {
|
||||||
|
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val transfer = TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = to.account,
|
||||||
|
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
|
||||||
|
amount = "%.2f".format(amount),
|
||||||
|
remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
fromAccountNumber = from.accountNumber,
|
||||||
|
returnOnSuccess = true,
|
||||||
|
autoConfirm = true
|
||||||
|
)
|
||||||
|
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||||
|
payStartedAt = System.currentTimeMillis()
|
||||||
|
hideKeyboard()
|
||||||
|
if (isBusinessProfile(from)) {
|
||||||
|
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
|
||||||
|
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||||
|
} else {
|
||||||
|
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
|
||||||
|
// confirm dialog appears over the chat. A new send replaces any earlier one.
|
||||||
|
childFragmentManager.beginTransaction()
|
||||||
|
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
|
||||||
|
private fun isBusinessProfile(account: BankAccount): Boolean {
|
||||||
|
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
|
||||||
|
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
|
||||||
|
private fun hideKeyboard() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val focused = b.root.findFocus() ?: return
|
||||||
|
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
|
||||||
|
?.hideSoftInputFromWindow(focused.windowToken, 0)
|
||||||
|
focused.clearFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
|
||||||
|
private fun updateToolbar() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
|
||||||
|
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
|
||||||
|
header = h
|
||||||
|
h.root.setOnClickListener { openContact() }
|
||||||
|
}
|
||||||
|
h.tvHeaderName.text = t.peerName
|
||||||
|
// Real name (when titled with a nickname), plus the account and currency when there's only one.
|
||||||
|
val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
|
||||||
|
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
|
||||||
|
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
|
||||||
|
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
|
||||||
|
).joinToString(" · ")
|
||||||
|
h.tvHeaderAccount.text = subtitle
|
||||||
|
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val sizePx = (40 * resources.displayMetrics.density).toInt()
|
||||||
|
if (t.isMerchant) {
|
||||||
|
h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
|
||||||
|
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
|
||||||
|
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
|
||||||
|
} else {
|
||||||
|
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||||
|
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
|
||||||
|
}
|
||||||
|
|
||||||
|
requireActivity().title = t.peerName
|
||||||
|
bar.setDisplayShowTitleEnabled(false)
|
||||||
|
bar.setDisplayShowCustomEnabled(true)
|
||||||
|
bar.customView = h.root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openContact() {
|
||||||
|
val t = thread ?: return
|
||||||
|
if (t.isMerchant) return
|
||||||
|
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
|
||||||
|
if (display != null) {
|
||||||
|
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||||
|
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
|
||||||
|
} else {
|
||||||
|
// Not a saved contact yet: offer to save them, prefilled with what we know.
|
||||||
|
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
|
||||||
|
AddContactSheetFragment.newInstance(
|
||||||
|
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = selectedTo?.account,
|
||||||
|
recipientName = t.realName.ifBlank { t.peerName },
|
||||||
|
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
|
||||||
|
).show(childFragmentManager, "add_contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openTransfer(contact: ContactDisplay) {
|
||||||
|
val fragment = TransferFragment.newInstance(
|
||||||
|
accountNumber = contact.accountNumber,
|
||||||
|
displayName = contact.name,
|
||||||
|
subtitle = contact.transferSubtitle,
|
||||||
|
colorHex = contact.bankColor,
|
||||||
|
imageHash = contact.imageHash
|
||||||
|
)
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
|
||||||
|
override fun confirmDelete(contact: ContactDisplay) {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
(activity as? AppCompatActivity)?.supportActionBar?.apply {
|
||||||
|
setDisplayShowCustomEnabled(false)
|
||||||
|
setDisplayShowTitleEnabled(true)
|
||||||
|
customView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
header = null
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_PEER_KEY = "peer_key"
|
||||||
|
private const val HISTORY_CATCH_UP_MS = 10_000L
|
||||||
|
private const val TAG_HIDDEN_TRANSFER = "transfer_hidden"
|
||||||
|
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||||
|
private val CARD_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
|
||||||
|
|
||||||
|
fun newInstance(peerKey: String) = ChatFragment().apply {
|
||||||
|
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.text.style.StyleSpan
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatDateBinding
|
||||||
|
import sh.sar.basedbank.util.ChatMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One chat's transfers or payments as compact bubbles — sent / paid on the right, received on the
|
||||||
|
* left — with ✓ (seen in a BML alert) or ✓✓ (booked in history). Day separators carry the day's
|
||||||
|
* totals.
|
||||||
|
*/
|
||||||
|
class ChatMessagesAdapter(
|
||||||
|
private val onReceiptClick: (ChatMessage) -> Unit,
|
||||||
|
private val onLongPress: (ChatMessage, View) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
/** Rows of the list: a day separator or a bubble. */
|
||||||
|
sealed class Item {
|
||||||
|
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
|
||||||
|
data class Bubble(val message: ChatMessage) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val items = mutableListOf<Item>()
|
||||||
|
private var hideAmounts = false
|
||||||
|
private var todayLabel = ""
|
||||||
|
private var yesterdayLabel = ""
|
||||||
|
|
||||||
|
/** When the person has several accounts, sent bubbles say which one the money went to. */
|
||||||
|
var showDestination = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [messages] must be oldest first. */
|
||||||
|
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
|
||||||
|
this.todayLabel = todayLabel
|
||||||
|
this.yesterdayLabel = yesterdayLabel
|
||||||
|
items.clear()
|
||||||
|
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
|
||||||
|
items.add(Item.DateChip(day, dayMessages))
|
||||||
|
dayMessages.forEach { items.add(Item.Bubble(it)) }
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
|
is Item.DateChip -> TYPE_DATE
|
||||||
|
is Item.Bubble -> TYPE_BUBBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
|
||||||
|
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (val item = items[position]) {
|
||||||
|
is Item.DateChip -> (holder as DateVH).bind(item)
|
||||||
|
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAmount(m: ChatMessage) =
|
||||||
|
if (hideAmounts) "${m.currency} ••••••" else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
|
||||||
|
|
||||||
|
private fun isNight(view: View) =
|
||||||
|
(view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
|
||||||
|
private fun ticksFor(m: ChatMessage) = if (m.isBooked) "✓✓" else "✓"
|
||||||
|
|
||||||
|
private fun tickColor(view: View, m: ChatMessage, fallback: Int) =
|
||||||
|
if (m.isBooked) Color.parseColor(if (isNight(view)) "#90CAF9" else "#1565C0") else fallback
|
||||||
|
|
||||||
|
inner class DateVH(private val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(item: Item.DateChip) {
|
||||||
|
val first = item.messages.first()
|
||||||
|
val label = when {
|
||||||
|
DateUtils.isToday(first.timeMillis) -> todayLabel
|
||||||
|
DateUtils.isToday(first.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
|
||||||
|
else -> AccountHistoryAdapter.formatDateHeader(first.date)
|
||||||
|
}
|
||||||
|
val text = SpannableStringBuilder(label)
|
||||||
|
text.setSpan(StyleSpan(Typeface.BOLD), 0, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
if (!hideAmounts) {
|
||||||
|
// Day totals per currency: ↑ sent, ↓ received.
|
||||||
|
val parts = item.messages.groupBy { it.currency }.map { (ccy, ms) ->
|
||||||
|
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
|
||||||
|
val received = ms.filter { !it.isSent }.sumOf { it.amount }
|
||||||
|
listOfNotNull(
|
||||||
|
"↑ %.2f".format(sent).takeIf { sent > 0 },
|
||||||
|
"↓ %.2f".format(received).takeIf { received > 0 }
|
||||||
|
).joinToString(" · ").let { if (item.messages.map { m -> m.currency }.toSet().size > 1) "$ccy $it" else it }
|
||||||
|
}.filter { it.isNotBlank() }
|
||||||
|
if (parts.isNotEmpty()) text.append(" ").append(parts.joinToString(" "))
|
||||||
|
}
|
||||||
|
b.tvDate.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(m: ChatMessage) {
|
||||||
|
val ctx = b.root.context
|
||||||
|
val sent = m.isSent
|
||||||
|
val night = isNight(b.root)
|
||||||
|
val density = ctx.resources.displayMetrics.density
|
||||||
|
|
||||||
|
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
|
||||||
|
// Rounded, with a small "tail" corner on the sender's side at the bottom.
|
||||||
|
val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||||
|
val big = 18 * density
|
||||||
|
val tail = 4 * density
|
||||||
|
val tailOnRight = sent != rtl
|
||||||
|
b.cardBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||||
|
.setTopLeftCornerSize(big).setTopRightCornerSize(big)
|
||||||
|
.setBottomLeftCornerSize(if (tailOnRight) big else tail)
|
||||||
|
.setBottomRightCornerSize(if (tailOnRight) tail else big)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Sent: tinted. Received: neutral with an outline, so the two read apart on any theme.
|
||||||
|
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
|
||||||
|
else Color.parseColor(if (night) "#2A2C2E" else "#FFFFFF")
|
||||||
|
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
|
||||||
|
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
|
||||||
|
val green = Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||||
|
b.cardBubble.setCardBackgroundColor(bg)
|
||||||
|
b.cardBubble.strokeWidth = if (sent) 0 else density.toInt()
|
||||||
|
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
|
||||||
|
|
||||||
|
b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
|
||||||
|
b.tvAmount.setTextColor(if (sent) fg else green)
|
||||||
|
b.tvNote.text = m.note
|
||||||
|
b.tvNote.setTextColor(fg)
|
||||||
|
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||||
|
b.btnReceipt.setColorFilter(fg)
|
||||||
|
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
|
||||||
|
|
||||||
|
b.tvAccount.text = when {
|
||||||
|
!sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
|
||||||
|
showDestination && m.peerAccount.isNotBlank() ->
|
||||||
|
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
|
||||||
|
else -> m.accountDisplayName
|
||||||
|
}
|
||||||
|
b.tvAccount.setTextColor(fg)
|
||||||
|
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
|
||||||
|
b.tvTime.setTextColor(fg)
|
||||||
|
b.tvTicks.text = ticksFor(m)
|
||||||
|
b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
|
||||||
|
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
|
||||||
|
|
||||||
|
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
|
||||||
|
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDetail(anchor: View, m: ChatMessage) {
|
||||||
|
val ctx = anchor.context
|
||||||
|
val details = buildString {
|
||||||
|
val sign = if (m.isSent) "-" else "+"
|
||||||
|
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
|
||||||
|
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
|
||||||
|
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
|
||||||
|
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
|
||||||
|
append("Account\n${m.accountDisplayName}\n\n")
|
||||||
|
append("Status\n${ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)}")
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(m.peerName)
|
||||||
|
.setMessage(details)
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE = 0
|
||||||
|
private const val TYPE_BUBBLE = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBinding
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram-style chat list: one row per person or business, latest transfer as the preview.
|
||||||
|
* Pinned chats come first; long-press selects a chat (for pinning).
|
||||||
|
*/
|
||||||
|
class ChatsAdapter(
|
||||||
|
private val onChatClick: (ChatThread) -> Unit,
|
||||||
|
private val onChatLongPress: (ChatThread) -> Unit
|
||||||
|
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var allThreads: List<ChatThread> = emptyList()
|
||||||
|
private var displayed: List<ChatThread> = emptyList()
|
||||||
|
private var pinned: List<String> = emptyList()
|
||||||
|
private var searchQuery = ""
|
||||||
|
private var hideAmounts = false
|
||||||
|
|
||||||
|
/** Chat key shown as selected (long-pressed), or null. */
|
||||||
|
var selectedKey: String? = null
|
||||||
|
set(value) { field = value; notifyDataSetChanged() }
|
||||||
|
|
||||||
|
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
|
||||||
|
allThreads = threads
|
||||||
|
pinned = pinnedKeys
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearch(query: String) {
|
||||||
|
searchQuery = query
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPinned(key: String) = key in pinned
|
||||||
|
|
||||||
|
val isEmpty get() = displayed.isEmpty()
|
||||||
|
|
||||||
|
private fun applyFilter() {
|
||||||
|
val matching = if (searchQuery.isBlank()) allThreads else allThreads.filter {
|
||||||
|
it.peerName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.realName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.peerAccount.contains(searchQuery) ||
|
||||||
|
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
// Pinned first, most recently pinned on top; the rest stay newest first.
|
||||||
|
val (pinnedThreads, others) = matching.partition { it.peerKey in pinned }
|
||||||
|
displayed = pinnedThreads.sortedBy { pinned.indexOf(it.peerKey) } + others
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
holder.binding.root.setOnClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
|
||||||
|
}
|
||||||
|
holder.binding.root.setOnLongClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
|
||||||
|
pos != RecyclerView.NO_POSITION
|
||||||
|
}
|
||||||
|
return holder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
|
||||||
|
|
||||||
|
override fun getItemCount() = displayed.size
|
||||||
|
|
||||||
|
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(thread: ChatThread) {
|
||||||
|
val ctx = binding.root.context
|
||||||
|
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
val last = thread.last
|
||||||
|
binding.tvName.text = thread.peerName
|
||||||
|
|
||||||
|
val amount = if (hideAmounts) "${last.currency} ••••••"
|
||||||
|
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
|
||||||
|
val ticks = if (last.isBooked) " ✓✓" else " ✓"
|
||||||
|
binding.tvPreview.text = when {
|
||||||
|
last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
|
||||||
|
last.isSent -> {
|
||||||
|
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
|
||||||
|
ctx.getString(R.string.chat_preview_sent, amount) + note + ticks
|
||||||
|
}
|
||||||
|
else -> ctx.getString(R.string.chat_preview_received, amount)
|
||||||
|
}
|
||||||
|
binding.tvPreview.setTextColor(
|
||||||
|
if (!last.isSent) Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||||
|
else MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
|
||||||
|
else AccountHistoryAdapter.formatDateOnly(last.date)
|
||||||
|
|
||||||
|
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
|
||||||
|
if (thread.isMerchant) {
|
||||||
|
// Businesses get a rounded-square shop icon; people keep round initials or a photo.
|
||||||
|
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||||
|
.setAllCornerSizes(14 * ctx.resources.displayMetrics.density).build()
|
||||||
|
binding.ivAvatar.setImageBitmap(shopBitmap(binding.root, merchantColor(thread.peerKey), sizePx))
|
||||||
|
} else {
|
||||||
|
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(sizePx / 2f).build()
|
||||||
|
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
|
||||||
|
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
|
||||||
|
}
|
||||||
|
|
||||||
|
val isPinned = thread.peerKey in pinned
|
||||||
|
binding.ivPin.visibility = if (isPinned) View.VISIBLE else View.GONE
|
||||||
|
// Selected (long-pressed): tinted; pinned: a soft surface; otherwise the normal ripple.
|
||||||
|
when {
|
||||||
|
thread.peerKey == selectedKey -> binding.root.setBackgroundColor(
|
||||||
|
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorErrorContainer))
|
||||||
|
isPinned -> binding.root.setBackgroundColor(
|
||||||
|
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorSurfaceContainerHigh))
|
||||||
|
else -> binding.root.setBackgroundResource(selectableBackground(binding.root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectableBackground(view: View): Int {
|
||||||
|
val out = android.util.TypedValue()
|
||||||
|
view.context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true)
|
||||||
|
return out.resourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
|
||||||
|
private val MERCHANT_COLORS = listOf("#6D4C41", "#546E7A", "#5D4037", "#455A64", "#8D6E63", "#37474F")
|
||||||
|
|
||||||
|
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
|
||||||
|
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
|
||||||
|
|
||||||
|
fun merchantColor(peerKey: String) = MERCHANT_COLORS[(peerKey.hashCode() and 0x7fffffff) % MERCHANT_COLORS.size]
|
||||||
|
|
||||||
|
/** Shop icon on a coloured square; the image view's shape rounds the corners. */
|
||||||
|
fun shopBitmap(view: View, colorHex: String, sizePx: Int): Bitmap {
|
||||||
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
canvas.drawRect(RectF(0f, 0f, sizePx.toFloat(), sizePx.toFloat()),
|
||||||
|
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(colorHex) })
|
||||||
|
val icon = ContextCompat.getDrawable(view.context, R.drawable.ic_store)?.mutate() ?: return bm
|
||||||
|
val inset = sizePx / 4
|
||||||
|
icon.setBounds(inset, inset, sizePx - inset, sizePx - inset)
|
||||||
|
icon.draw(canvas)
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentChatsBinding
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers grouped by person or business, shown as a chat list. Contacts is reachable from the
|
||||||
|
* toolbar; long-press a chat to pin it to the top.
|
||||||
|
*/
|
||||||
|
class ChatsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private lateinit var adapter: ChatsAdapter
|
||||||
|
|
||||||
|
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
onQrScanned(raw)
|
||||||
|
}
|
||||||
|
private var actionMode: ActionMode? = null
|
||||||
|
private var syncedThisView = false
|
||||||
|
private var syncedWithContacts = false
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ChatsAdapter(
|
||||||
|
onChatClick = { thread ->
|
||||||
|
if (actionMode != null) actionMode?.finish()
|
||||||
|
else (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
|
||||||
|
},
|
||||||
|
onChatLongPress = { thread -> startSelection(thread) }
|
||||||
|
)
|
||||||
|
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.rvChats.adapter = adapter
|
||||||
|
|
||||||
|
binding.etSearch.addTextChangedListener { text ->
|
||||||
|
adapter.setSearch(text?.toString() ?: "")
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener { sync() }
|
||||||
|
|
||||||
|
// Scan to Pay: opens the QR scanner; after paying it comes back here and refreshes.
|
||||||
|
binding.fabScanPay.setOnClickListener {
|
||||||
|
scanLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||||
|
sync()
|
||||||
|
// BML's alert often lands a few seconds after the payment: check again shortly.
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(RECHECK_AFTER_PAYMENT_MS)
|
||||||
|
if (_binding != null) sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep the button above the system navigation bar when there's no bottom bar.
|
||||||
|
val fabMargin = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.fabScanPay) { v, insets ->
|
||||||
|
val bottomNav = NavCustomization.getNavMode(
|
||||||
|
requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)
|
||||||
|
) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||||
|
(v.layoutParams as android.widget.FrameLayout.LayoutParams).bottomMargin =
|
||||||
|
fabMargin + if (bottomNav) 0 else navBar
|
||||||
|
v.requestLayout()
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
requireActivity().addMenuProvider(object : MenuProvider {
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
|
||||||
|
.setIcon(R.drawable.ic_contacts)
|
||||||
|
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
if (menuItem.itemId != R.id.action_open_contacts) return false
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
// Contacts give chats a saved name and an account number to send to.
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
loadThreads()
|
||||||
|
// Contacts usually load after the first sync; sync again so their real names get looked up.
|
||||||
|
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts arrive after login; sync once they are available.
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
syncedThisView = true
|
||||||
|
binding.swipeRefresh.isRefreshing = true
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
if (contacts.isNotEmpty()) syncedWithContacts = true
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
_binding?.swipeRefresh?.isRefreshing = false
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThreads() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (threads, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
adapter.updateThreads(threads, pins)
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scanned QR: BML Scan to Pay (static or card-machine) opens the payment, a PayMV QR opens a
|
||||||
|
* transfer. Either way, success comes back to this list, which then refreshes.
|
||||||
|
*/
|
||||||
|
private fun onQrScanned(raw: String) {
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
|
if (bmlTarget != null) {
|
||||||
|
activity.showWithBackStack(TransferFragment.newInstanceFromBmlQr(bmlTarget, null, returnOnSuccess = true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
val account = qr?.accountNumber
|
||||||
|
if (account == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activity.showWithBackStack(TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = account,
|
||||||
|
displayName = qr.merchantName ?: account,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose,
|
||||||
|
returnOnSuccess = true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-press: select the chat and offer Pin / Unpin in the top bar. */
|
||||||
|
private fun startSelection(thread: ChatThread) {
|
||||||
|
adapter.selectedKey = thread.peerKey
|
||||||
|
val pinned = adapter.isPinned(thread.peerKey)
|
||||||
|
val callback = object : ActionMode.Callback {
|
||||||
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.title = getString(R.string.chat_selected_one)
|
||||||
|
menu.add(Menu.NONE, R.id.action_pin_chat, 0, if (pinned) R.string.chat_unpin else R.string.chat_pin)
|
||||||
|
.setIcon(R.drawable.ic_pin)
|
||||||
|
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
|
||||||
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
if (item.itemId != R.id.action_pin_chat) return false
|
||||||
|
setPinned(thread, !pinned, offerUndo = true)
|
||||||
|
mode.finish()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun onDestroyActionMode(mode: ActionMode) {
|
||||||
|
actionMode = null
|
||||||
|
adapter.selectedKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actionMode?.finish()
|
||||||
|
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPinned(thread: ChatThread, pin: Boolean, offerUndo: Boolean) {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.setPinned(ctx, thread.peerKey, pin) }
|
||||||
|
loadThreads()
|
||||||
|
val b = _binding ?: return@launch
|
||||||
|
if (!offerUndo) return@launch
|
||||||
|
Snackbar.make(b.root, getString(if (pin) R.string.chat_pinned_msg else R.string.chat_unpinned_msg, thread.peerName), Snackbar.LENGTH_LONG)
|
||||||
|
.setAction(R.string.chat_undo) { setPinned(thread, !pin, offerUndo = false) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateEmptyView() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
|
||||||
|
if (!adapter.isEmpty) return
|
||||||
|
val since = ChatStore.sinceMillis(requireContext())
|
||||||
|
b.emptyView.text = when {
|
||||||
|
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
|
||||||
|
since > 0L -> getString(R.string.chats_empty_since,
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
|
||||||
|
else -> getString(R.string.chats_empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
actionMode?.finish()
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,37 +103,33 @@ 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 {
|
||||||
|
val sizePx = binding.ivContactPhoto.context.resources
|
||||||
|
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||||
|
.coerceAtLeast(96)
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
binding.ivContactPhoto.setImageBitmap(
|
||||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
|
||||||
val sizePx = binding.ivContactPhoto.context.resources
|
|
||||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
|
||||||
.coerceAtLeast(96)
|
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(bm)
|
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
paint.color = bgColor
|
|
||||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
|
||||||
paint.color = Color.WHITE
|
|
||||||
paint.textSize = sizePx * 0.42f
|
|
||||||
paint.textAlign = Paint.Align.CENTER
|
|
||||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
|
||||||
val metrics = paint.fontMetrics
|
|
||||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
|
||||||
return bm
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
|
||||||
|
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
||||||
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
paint.color = bgColor
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.textSize = sizePx * 0.42f
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
val metrics = paint.fontMetrics
|
||||||
|
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
|
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()
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
@@ -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()
|
||||||
@@ -528,9 +530,9 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
private fun routeSharedQrText(text: String) {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
setupDropdown()
|
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()
|
||||||
|
}
|
||||||
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 loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
|
||||||
val store = CredentialStore(ctx)
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||||
val mobile = if (includePhone) {
|
val store = CredentialStore(ctx)
|
||||||
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(
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
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)
|
||||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
setNeutralButton(R.string.close, null)
|
||||||
|
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
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 }
|
||||||
|
|
||||||
@@ -172,13 +175,13 @@ class TransferFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return
|
if (result.resultCode != Activity.RESULT_OK) return
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
|
||||||
|
|
||||||
// BML card/gateway QR — hand off to dedicated payment screen
|
// BML card/gateway/POS QR — hand off to dedicated payment screen
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
val fromCard = selectedAccount?.takeIf {
|
val fromCard = selectedAccount?.takeIf {
|
||||||
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
|
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
|
||||||
}
|
}
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber))
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,15 +245,21 @@ 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"
|
||||||
|
private const val ARG_AUTO_CONFIRM = "auto_confirm"
|
||||||
|
|
||||||
|
/** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */
|
||||||
|
const val RESULT_TRANSFER_DONE = "transfer_done"
|
||||||
|
|
||||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
|
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null, returnOnSuccess: Boolean = false) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_BML_QR_URL, qrUrl)
|
putString(ARG_BML_QR_URL, qrUrl)
|
||||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
|
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +288,9 @@ class TransferFragment : Fragment() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
amount: String?,
|
amount: String?,
|
||||||
remarks: String?,
|
remarks: String?,
|
||||||
fromAccountNumber: String? = null
|
fromAccountNumber: String? = null,
|
||||||
|
returnOnSuccess: Boolean = false,
|
||||||
|
autoConfirm: Boolean = false
|
||||||
) = TransferFragment().apply {
|
) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT, accountNumber)
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
@@ -289,10 +300,35 @@ class TransferFragment : Fragment() {
|
|||||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||||
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||||
|
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||||
|
if (autoConfirm) putBoolean(ARG_AUTO_CONFIRM, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the receipt, or — when opened with returnOnSuccess (e.g. from a chat) — goes back to
|
||||||
|
* the caller and tells it via [RESULT_TRANSFER_DONE] on the activity's fragment manager;
|
||||||
|
* the receipt is already in ReceiptStore.
|
||||||
|
*/
|
||||||
|
private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) {
|
||||||
|
if (!returnToCallerIfRequested()) {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When opened with returnOnSuccess, tells the caller the payment went through and goes back to
|
||||||
|
* it — closing the sheet this page is hosted in, if any. Returns false when not requested.
|
||||||
|
*/
|
||||||
|
internal fun returnToCallerIfRequested(): Boolean {
|
||||||
|
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) != true) return false
|
||||||
|
requireActivity().supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
|
||||||
|
val sheet = parentFragment as? androidx.fragment.app.DialogFragment
|
||||||
|
if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentTransferBinding.inflate(inflater, container, false)
|
_binding = FragmentTransferBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -305,6 +341,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 +366,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 +375,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 {
|
||||||
@@ -382,6 +411,16 @@ class TransferFragment : Fragment() {
|
|||||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
|
||||||
|
// Opened fully filled in (e.g. from a chat): go straight to the confirm dialog, once. Posted
|
||||||
|
// so the source account, picked when the accounts arrive, is selected first.
|
||||||
|
if (arguments?.getBoolean(ARG_AUTO_CONFIRM) == true) {
|
||||||
|
view.post {
|
||||||
|
if (_binding == null || arguments?.getBoolean(ARG_AUTO_CONFIRM) != true) return@post
|
||||||
|
arguments?.remove(ARG_AUTO_CONFIRM)
|
||||||
|
if (selectedAccount != null && resolvedAccountNumber.isNotBlank()) initiateTransfer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
|
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
|
||||||
|
|
||||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||||
@@ -408,7 +447,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 +576,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 +590,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 +759,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 +802,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 +895,7 @@ class TransferFragment : Fragment() {
|
|||||||
colorHex = matchedContact.bankColor,
|
colorHex = matchedContact.bankColor,
|
||||||
imageHash = matchedContact.customerImgHash
|
imageHash = matchedContact.customerImgHash
|
||||||
)
|
)
|
||||||
|
focusAmount()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +963,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 +976,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 +988,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 +1072,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 +1248,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 +1609,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 +1879,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,62 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import sh.sar.basedbank.databinding.SheetTransferBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible.
|
||||||
|
* The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet.
|
||||||
|
*/
|
||||||
|
class TransferSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: SheetTransferBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = SheetTransferBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
// Leave the top of the chat visible above the sheet.
|
||||||
|
binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt()
|
||||||
|
if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) {
|
||||||
|
val transferArgs = requireArguments()
|
||||||
|
childFragmentManager.beginTransaction()
|
||||||
|
.replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs })
|
||||||
|
.commitNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
(dialog as? BottomSheetDialog)?.let { d ->
|
||||||
|
// Keep the amount/remarks fields above the keyboard, but don't open it by itself.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
d.window?.setSoftInputMode(
|
||||||
|
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
|
||||||
|
)
|
||||||
|
d.behavior.skipCollapsed = true
|
||||||
|
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SHEET_HEIGHT_FRACTION = 0.8f
|
||||||
|
|
||||||
|
/** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */
|
||||||
|
fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient
|
|||||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
@@ -31,6 +32,7 @@ import sh.sar.basedbank.ui.home.HomeViewModel
|
|||||||
import sh.sar.basedbank.ui.home.TransferFragment
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import sh.sar.basedbank.util.RecentPick
|
import sh.sar.basedbank.util.RecentPick
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
@@ -173,9 +175,10 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
fun lookupQrMerchant(qrUrl: String) {
|
fun lookupQrMerchant(qrUrl: String) {
|
||||||
qrLookupAttempted = true
|
qrLookupAttempted = true
|
||||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
||||||
val base64Url = android.util.Base64.encodeToString(
|
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||||
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||||
|
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||||
val session = app.anyBmlSession() ?: return
|
val session = app.anyBmlSession() ?: return
|
||||||
|
|
||||||
// Lock the "To" input row while loading
|
// Lock the "To" input row while loading
|
||||||
@@ -185,14 +188,20 @@ class BmlTransferHandler(
|
|||||||
host?.setRefreshing(true)
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||||
catch (_: Exception) { null }
|
|
||||||
}
|
}
|
||||||
host?.setRefreshing(false)
|
host?.setRefreshing(false)
|
||||||
|
val info = result.getOrNull()
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
// An expired or rejected QR is BML telling us something specific — show its own
|
||||||
fragment.requireActivity().onBackPressedDispatcher.onBackPressed()
|
// wording and stay put with the To row restored, rather than bouncing the user out
|
||||||
|
// of the screen they just scanned from.
|
||||||
|
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||||
|
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||||
|
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||||
|
fragment.resetToFieldVisibility()
|
||||||
|
onStateChanged()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
qrInfo = info
|
qrInfo = info
|
||||||
@@ -342,6 +351,7 @@ class BmlTransferHandler(
|
|||||||
) {
|
) {
|
||||||
fragment.clearForm()
|
fragment.clearForm()
|
||||||
host?.triggerRefresh()
|
host?.triggerRefresh()
|
||||||
|
fragment.returnToCallerIfRequested()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
|||||||
@@ -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,524 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
import sh.sar.basedbank.ui.home.AccountHistoryAdapter
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One transfer shown as a chat bubble. BML history only carries the counterparty's name, so
|
||||||
|
* [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name.
|
||||||
|
*/
|
||||||
|
data class ChatMessage(
|
||||||
|
val id: String,
|
||||||
|
val peerKey: String,
|
||||||
|
val peerName: String,
|
||||||
|
val amount: Double, // negative = sent, positive = received
|
||||||
|
val currency: String,
|
||||||
|
val timeMillis: Long,
|
||||||
|
val accountNumber: String, // own account the money moved in/out of
|
||||||
|
val accountDisplayName: String,
|
||||||
|
val reference: String = "",
|
||||||
|
val note: String = "", // remarks — only known for transfers sent from this app
|
||||||
|
val peerAccount: String = "", // only known for transfers sent from this app
|
||||||
|
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
|
||||||
|
val dateOnly: Boolean = false, // bank gave only a date (Favara); time of day unknown
|
||||||
|
val merchant: Boolean = false // card / Scan to Pay payment to a business, not a person
|
||||||
|
) {
|
||||||
|
val isSent get() = amount < 0
|
||||||
|
/** In BML's account history (✓✓); otherwise only seen in a notification or receipt so far (✓). */
|
||||||
|
val isBooked get() = id.startsWith("bml_")
|
||||||
|
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
|
||||||
|
val date: String get() = DATE_FMT.format(Date(timeMillis))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of a person's accounts that can be sent to from their chat. */
|
||||||
|
data class ChatAccount(
|
||||||
|
val account: String,
|
||||||
|
val currency: String, // "MVR", "USD"; blank if not known yet
|
||||||
|
val contact: BankContact? // saved contact for this account, if any
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatThread(
|
||||||
|
val peerKey: String, // "name:<normalised real name>" or "shop:<normalised business name>"
|
||||||
|
val peerName: String, // contact nickname, else the real name
|
||||||
|
val realName: String, // account holder's name as the bank knows it; may be blank
|
||||||
|
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
|
||||||
|
val messages: List<ChatMessage>, // oldest first
|
||||||
|
val isMerchant: Boolean = false // a business paid by card / Scan to Pay; nothing to send to
|
||||||
|
) {
|
||||||
|
val last get() = messages.last()
|
||||||
|
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
|
||||||
|
/** The account last sent to, else the first known one; blank when none is known. */
|
||||||
|
val peerAccount: String get() =
|
||||||
|
messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount
|
||||||
|
?.takeIf { a -> accounts.any { it.account == a } }
|
||||||
|
?: accounts.firstOrNull()?.account.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted local record of BML transfers and card / Scan to Pay payments, shown as chats.
|
||||||
|
*
|
||||||
|
* Old history is never loaded: the first call to [sync] only stamps the start time, and every
|
||||||
|
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
|
||||||
|
* pages back through the latest history of every BML current/savings account (booked later), and
|
||||||
|
* merges in receipts of transfers sent from this app, keeping one message per transfer.
|
||||||
|
*/
|
||||||
|
object ChatStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "chats.json"
|
||||||
|
private const val PINS_FILE_NAME = "chat_pins.json"
|
||||||
|
private const val QR_FILE_NAME = "chat_merchant_qr.json"
|
||||||
|
/** Prefix [RecentsCache] uses for static BML merchant QRs the user has paid. */
|
||||||
|
private const val RECENT_QR_PREFIX = "bmlqr:"
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||||
|
private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
|
||||||
|
/** Card and Scan to Pay payments; narrative2 is the business name. */
|
||||||
|
private val MERCHANT_TYPES = setOf("Purchase")
|
||||||
|
|
||||||
|
/** "You have received MVR 5.00 from NAME to 7730*****1234" */
|
||||||
|
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
|
||||||
|
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
|
||||||
|
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
|
||||||
|
/** "You have paid MVR 5.00 from 4xxx********xxxx to BUSINESS" (Scan to Pay / card); the name may be cut short. */
|
||||||
|
private val NOTIF_PAID = Regex("""You have paid ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
|
||||||
|
/** Masked own account or card in notifications, e.g. "7730*****1234". */
|
||||||
|
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
|
||||||
|
|
||||||
|
/** How far apart two records of the same transfer can be timestamped. */
|
||||||
|
private const val MATCH_WINDOW_MS = 15 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Upper bound on history pages fetched per account in one sync. */
|
||||||
|
private const val MAX_PAGES_PER_SYNC = 5
|
||||||
|
|
||||||
|
private const val NAME_KEY_PREFIX = "name:"
|
||||||
|
private const val MERCHANT_KEY_PREFIX = "shop:"
|
||||||
|
|
||||||
|
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
|
||||||
|
|
||||||
|
fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US)
|
||||||
|
|
||||||
|
fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis }
|
||||||
|
|
||||||
|
fun isStarted(context: Context) = sinceMillis(context) > 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups stored messages into chats, most recent chat first. Call off the main thread.
|
||||||
|
*
|
||||||
|
* Chats are per person, keyed by the account holder's real name: bank history only names the
|
||||||
|
* sender, never their account, so money can't be told apart per account anyway. Every known
|
||||||
|
* account with that real name (saved contacts, accounts sent to) belongs to the chat, and the
|
||||||
|
* user picks which one to send to — typically their MVR or USD account.
|
||||||
|
*
|
||||||
|
* Real names come from [AccountNameStore], so a contact saved as "Ali" joins the
|
||||||
|
* "Mohamed Ali" chat.
|
||||||
|
*/
|
||||||
|
fun threads(context: Context, contacts: List<BankContact>): List<ChatThread> {
|
||||||
|
val messages = synchronized(lock) { load(context).messages }
|
||||||
|
val lookedUp = AccountNameStore.all(context)
|
||||||
|
val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount }
|
||||||
|
|
||||||
|
fun realNameOf(account: String): String? =
|
||||||
|
lookedUp[account]?.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// Notifications cut business names short ("FAMILY ROOM" for "FAMILY ROOM COFFEE"); file a
|
||||||
|
// short name under the longest known name it starts.
|
||||||
|
val merchantNames = messages.filter { it.merchant }.map { it.peerKey }.toSet()
|
||||||
|
fun merchantKey(name: String) =
|
||||||
|
merchantNames.filter { it.startsWith(name) }.maxByOrNull { it.length } ?: name
|
||||||
|
|
||||||
|
fun keyOf(m: ChatMessage): String {
|
||||||
|
if (m.merchant) return MERCHANT_KEY_PREFIX + merchantKey(m.peerKey)
|
||||||
|
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
|
||||||
|
return NAME_KEY_PREFIX + normalise(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every known account, grouped under its holder's real name.
|
||||||
|
val accountsByKey = HashMap<String, LinkedHashMap<String, ChatAccount>>()
|
||||||
|
fun addAccount(account: String, fallbackCurrency: String) {
|
||||||
|
val name = realNameOf(account) ?: return
|
||||||
|
val contact = contactsByAccount[account]
|
||||||
|
val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() }
|
||||||
|
?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency
|
||||||
|
accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() }
|
||||||
|
.getOrPut(account) { ChatAccount(account, currency, contact) }
|
||||||
|
}
|
||||||
|
contactsByAccount.keys.forEach { addAccount(it, "") }
|
||||||
|
messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) }
|
||||||
|
|
||||||
|
return messages.groupBy(::keyOf).map { (key, msgs) ->
|
||||||
|
val sorted = msgs.sortedBy { it.timeMillis }
|
||||||
|
if (key.startsWith(MERCHANT_KEY_PREFIX)) {
|
||||||
|
// History carries the full business name; notifications may not.
|
||||||
|
val name = sorted.lastOrNull { it.id.startsWith("bml_") }?.peerName
|
||||||
|
?: sorted.maxByOrNull { it.peerName.length }!!.peerName
|
||||||
|
return@map ChatThread(key, name, "", emptyList(), sorted, isMerchant = true)
|
||||||
|
}
|
||||||
|
val accounts = accountsByKey[key]?.values?.toList().orEmpty()
|
||||||
|
// Bank history and the account lookup carry the real name; a receipt only has our label.
|
||||||
|
val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName
|
||||||
|
?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
?: ""
|
||||||
|
val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } }
|
||||||
|
ChatThread(
|
||||||
|
peerKey = key,
|
||||||
|
peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName,
|
||||||
|
realName = realName,
|
||||||
|
accounts = accounts,
|
||||||
|
messages = sorted
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.last.timeMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */
|
||||||
|
suspend fun sync(context: Context, app: BasedBankApp, accounts: List<BankAccount>, contacts: List<BankContact>) {
|
||||||
|
val state = synchronized(lock) { load(context) }
|
||||||
|
if (state.sinceMillis == 0L) {
|
||||||
|
synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val since = state.sinceMillis
|
||||||
|
|
||||||
|
val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id })
|
||||||
|
val notifications = fetchNotifications(context, app, accounts, since)
|
||||||
|
val receipts = receiptMessages(context, accounts, since)
|
||||||
|
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = load(context)
|
||||||
|
save(context, current.copy(messages = merge(current.messages, history + notifications + receipts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
|
||||||
|
// SWIFT (foreign) beneficiaries can't be looked up through BML.
|
||||||
|
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
|
||||||
|
AccountNameStore.resolve(context, session, toResolve)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers from account history. BML only adds a transaction here once it is booked (often
|
||||||
|
* the next day), so recent ones come from [fetchNotifications] first.
|
||||||
|
*/
|
||||||
|
private suspend fun fetchHistory(
|
||||||
|
app: BasedBankApp,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
since: Long,
|
||||||
|
knownIds: Set<String>
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val fetched = mutableListOf<ChatMessage>()
|
||||||
|
for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) {
|
||||||
|
val fetcher = HistoryFetcher(account)
|
||||||
|
var pages = 0
|
||||||
|
// Page back until we reach transfers we already have or ones older than the start time.
|
||||||
|
while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) {
|
||||||
|
val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break }
|
||||||
|
pages++
|
||||||
|
if (page.isEmpty()) break
|
||||||
|
var reachedKnown = false
|
||||||
|
for (trx in page) {
|
||||||
|
val time = AccountHistoryAdapter.parseDateMillis(trx.date)
|
||||||
|
if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true
|
||||||
|
trx.toChatMessage(time, since)?.let { fetched += it }
|
||||||
|
}
|
||||||
|
if (reachedKnown) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
|
||||||
|
val name = counterpartyName?.trim().orEmpty()
|
||||||
|
val merchant = description in MERCHANT_TYPES
|
||||||
|
if ((description !in TRANSFER_TYPES && !merchant) || name.isBlank()) return null
|
||||||
|
// Favara (other-bank) entries carry only a date; keep those from the start day onwards.
|
||||||
|
val dateOnly = date.contains("T00:00:00")
|
||||||
|
if (if (dateOnly) time < startOfDay(since) else time < since) return null
|
||||||
|
return ChatMessage(
|
||||||
|
id = "bml_$id",
|
||||||
|
peerKey = normalise(name),
|
||||||
|
peerName = name,
|
||||||
|
amount = amount,
|
||||||
|
currency = currency,
|
||||||
|
timeMillis = time,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
reference = reference.orEmpty(),
|
||||||
|
dateOnly = dateOnly,
|
||||||
|
merchant = merchant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive
|
||||||
|
* instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page
|
||||||
|
* is not written back to the cache, which the notification poller uses to spot new alerts.
|
||||||
|
*/
|
||||||
|
private fun fetchNotifications(
|
||||||
|
context: Context,
|
||||||
|
app: BasedBankApp,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
since: Long
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val client = BmlNotificationsClient()
|
||||||
|
val notifications = LinkedHashMap<String, AppNotification>()
|
||||||
|
for ((loginId, session) in app.bmlSessions.toMap()) {
|
||||||
|
NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it }
|
||||||
|
try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } }
|
||||||
|
catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
return notifications.values
|
||||||
|
.filter { it.timestampMs >= since }
|
||||||
|
.mapNotNull { notificationToMessage(it, accounts) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notificationToMessage(n: AppNotification, accounts: List<BankAccount>): ChatMessage? {
|
||||||
|
val text = n.message.trim()
|
||||||
|
val received = NOTIF_RECEIVED.matchEntire(text)
|
||||||
|
val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null
|
||||||
|
val paid = if (received == null && sent == null) NOTIF_PAID.matchEntire(text) else null
|
||||||
|
val (currency, amountText, name, ownMasked) = when {
|
||||||
|
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) }
|
||||||
|
sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
|
||||||
|
paid != null -> paid.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
|
||||||
|
val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null
|
||||||
|
val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m ->
|
||||||
|
val (head, tail) = m.destructured
|
||||||
|
accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) }
|
||||||
|
}
|
||||||
|
return ChatMessage(
|
||||||
|
id = "ntf_${n.id}",
|
||||||
|
peerKey = normalise(name),
|
||||||
|
peerName = name.trim(),
|
||||||
|
amount = if (received != null) amount else -amount,
|
||||||
|
currency = currency,
|
||||||
|
timeMillis = n.timestampMs,
|
||||||
|
accountNumber = own?.accountNumber.orEmpty(),
|
||||||
|
accountDisplayName = own?.accountBriefName ?: "•••• ${ownMasked.takeLast(4)}",
|
||||||
|
merchant = paid != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
|
||||||
|
private fun receiptMessages(context: Context, accounts: List<BankAccount>, since: Long): List<ChatMessage> =
|
||||||
|
ReceiptStore.loadAll(context).mapNotNull { entry ->
|
||||||
|
val r = entry.data
|
||||||
|
if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null
|
||||||
|
val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null
|
||||||
|
val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt
|
||||||
|
val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel }
|
||||||
|
ChatMessage(
|
||||||
|
id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}",
|
||||||
|
peerKey = normalise(r.toLabel),
|
||||||
|
peerName = r.toLabel,
|
||||||
|
amount = -amount,
|
||||||
|
currency = r.currency,
|
||||||
|
timeMillis = time,
|
||||||
|
accountNumber = from?.accountNumber.orEmpty(),
|
||||||
|
accountDisplayName = r.fromLabel,
|
||||||
|
reference = r.bmlReference,
|
||||||
|
note = r.remarks,
|
||||||
|
peerAccount = r.toAccount,
|
||||||
|
receiptKey = entry.savedAt.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges stored and newly fetched messages so each transfer appears once. The same transfer can
|
||||||
|
* come from history, a notification and a receipt; the highest-ranked source is kept (history,
|
||||||
|
* then notification, then receipt) and picks up what the others add: the exact time, the note,
|
||||||
|
* the recipient account and the receipt.
|
||||||
|
*/
|
||||||
|
private fun merge(existing: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||||
|
val byId = LinkedHashMap<String, ChatMessage>()
|
||||||
|
incoming.forEach { byId[it.id] = it }
|
||||||
|
existing.forEach { byId[it.id] = it } // stored copies already carry merged details
|
||||||
|
|
||||||
|
val kept = mutableListOf<ChatMessage>()
|
||||||
|
val absorbed = HashMap<String, MutableSet<Int>>() // kept id -> source ranks merged into it
|
||||||
|
for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) {
|
||||||
|
val r = rank(candidate)
|
||||||
|
val index = kept.indexOfFirst { k ->
|
||||||
|
rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate)
|
||||||
|
}
|
||||||
|
if (index >= 0) {
|
||||||
|
val k = kept[index]
|
||||||
|
kept[index] = k.copy(
|
||||||
|
timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis,
|
||||||
|
dateOnly = k.dateOnly && candidate.dateOnly,
|
||||||
|
accountNumber = k.accountNumber.ifBlank { candidate.accountNumber },
|
||||||
|
reference = k.reference.ifBlank { candidate.reference },
|
||||||
|
note = k.note.ifBlank { candidate.note },
|
||||||
|
peerAccount = k.peerAccount.ifBlank { candidate.peerAccount },
|
||||||
|
receiptKey = k.receiptKey.ifBlank { candidate.receiptKey },
|
||||||
|
merchant = k.merchant || candidate.merchant
|
||||||
|
)
|
||||||
|
absorbed.getOrPut(k.id) { mutableSetOf() } += r
|
||||||
|
} else {
|
||||||
|
kept += candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept.sortedBy { it.timeMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rank(m: ChatMessage) = when {
|
||||||
|
m.id.startsWith("bml_") -> 0
|
||||||
|
m.id.startsWith("ntf_") -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
|
||||||
|
if (a.reference.isNotBlank() && a.reference == b.reference) return true
|
||||||
|
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
|
||||||
|
// A payment to a business is never the same as a transfer to a person (receipts aren't tagged).
|
||||||
|
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
|
||||||
|
if (!receipt && a.merchant != b.merchant) return false
|
||||||
|
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false
|
||||||
|
if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
|
||||||
|
if (a.dateOnly || b.dateOnly) {
|
||||||
|
// A date-only entry could be any transfer that day, so the names must agree too.
|
||||||
|
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
|
||||||
|
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
|
||||||
|
}
|
||||||
|
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply {
|
||||||
|
timeInMillis = millis
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
|
||||||
|
fun clearAll(context: Context) = synchronized(lock) {
|
||||||
|
File(context.filesDir, FILE_NAME).delete()
|
||||||
|
File(context.filesDir, PINS_FILE_NAME).delete()
|
||||||
|
File(context.filesDir, QR_FILE_NAME).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Saved merchant QRs ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The static QR (no preset amount) last paid at the business named [merchantName], or null.
|
||||||
|
* The app already remembers such QRs among its recents, but only the last few; they are
|
||||||
|
* copied here so a business's QR is kept for good. Call off the main thread.
|
||||||
|
*/
|
||||||
|
fun merchantQr(context: Context, merchantName: String): String? = synchronized(lock) {
|
||||||
|
val file = File(context.filesDir, QR_FILE_NAME)
|
||||||
|
val saved = try {
|
||||||
|
if (!file.exists()) JSONObject() else JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||||
|
} catch (_: Exception) { JSONObject() }
|
||||||
|
var changed = false
|
||||||
|
for (recent in RecentsCache.load(context)) {
|
||||||
|
if (!recent.accountNumber.startsWith(RECENT_QR_PREFIX)) continue
|
||||||
|
val key = normalise(recent.displayName)
|
||||||
|
val url = recent.accountNumber.removePrefix(RECENT_QR_PREFIX)
|
||||||
|
if (key.isNotBlank() && saved.optString(key) != url) { saved.put(key, url); changed = true }
|
||||||
|
}
|
||||||
|
if (changed) try { file.writeText(CacheEncryption.encrypt(saved.toString())) } catch (_: Exception) {}
|
||||||
|
|
||||||
|
// Names differ slightly between the QR, history and alerts; accept one starting the other.
|
||||||
|
val name = normalise(merchantName)
|
||||||
|
val keys = saved.keys().asSequence().toList()
|
||||||
|
val match = keys.firstOrNull { it == name }
|
||||||
|
?: keys.filter { it.startsWith(name) || name.startsWith(it) }.maxByOrNull { it.length }
|
||||||
|
match?.let { saved.optString(it).takeIf { url -> url.isNotBlank() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pinned chats ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Pinned chat keys, most recently pinned first. */
|
||||||
|
fun pins(context: Context): List<String> = synchronized(lock) {
|
||||||
|
val file = File(context.filesDir, PINS_FILE_NAME)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPinned(context: Context, peerKey: String, pinned: Boolean) {
|
||||||
|
val current = pins(context).filter { it != peerKey }
|
||||||
|
val updated = if (pinned) listOf(peerKey) + current else current
|
||||||
|
synchronized(lock) {
|
||||||
|
try {
|
||||||
|
File(context.filesDir, PINS_FILE_NAME)
|
||||||
|
.writeText(CacheEncryption.encrypt(JSONArray(updated).toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(context: Context): State {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return State(0L, emptyList())
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||||
|
val arr = root.optJSONArray("messages") ?: JSONArray()
|
||||||
|
State(
|
||||||
|
sinceMillis = root.optLong("since", 0L),
|
||||||
|
messages = (0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
ChatMessage(
|
||||||
|
id = o.optString("id"),
|
||||||
|
peerKey = o.optString("peerKey"),
|
||||||
|
peerName = o.optString("peerName"),
|
||||||
|
amount = o.optDouble("amount", 0.0),
|
||||||
|
currency = o.optString("currency"),
|
||||||
|
timeMillis = o.optLong("time", 0L),
|
||||||
|
accountNumber = o.optString("account"),
|
||||||
|
accountDisplayName = o.optString("accountName"),
|
||||||
|
reference = o.optString("reference"),
|
||||||
|
note = o.optString("note"),
|
||||||
|
peerAccount = o.optString("peerAccount"),
|
||||||
|
receiptKey = o.optString("receiptKey"),
|
||||||
|
dateOnly = o.optBoolean("dateOnly", false),
|
||||||
|
merchant = o.optBoolean("merchant", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { State(0L, emptyList()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(context: Context, state: State) {
|
||||||
|
try {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (m in state.messages) arr.put(JSONObject().apply {
|
||||||
|
put("id", m.id)
|
||||||
|
put("peerKey", m.peerKey)
|
||||||
|
put("peerName", m.peerName)
|
||||||
|
put("amount", m.amount)
|
||||||
|
put("currency", m.currency)
|
||||||
|
put("time", m.timeMillis)
|
||||||
|
put("account", m.accountNumber)
|
||||||
|
put("accountName", m.accountDisplayName)
|
||||||
|
put("reference", m.reference)
|
||||||
|
put("note", m.note)
|
||||||
|
put("peerAccount", m.peerAccount)
|
||||||
|
put("receiptKey", m.receiptKey)
|
||||||
|
put("dateOnly", m.dateOnly)
|
||||||
|
put("merchant", m.merchant)
|
||||||
|
})
|
||||||
|
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,22 +9,58 @@ data class PaymvQrData(
|
|||||||
|
|
||||||
object PaymvQrParser {
|
object PaymvQrParser {
|
||||||
|
|
||||||
|
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
|
||||||
|
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
|
||||||
|
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||||
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
*
|
||||||
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
* Three shapes exist:
|
||||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
* - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
|
||||||
|
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
|
||||||
|
* `35` → `20` → `01`;
|
||||||
|
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
|
||||||
|
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
|
||||||
|
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
|
||||||
|
*
|
||||||
|
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
|
||||||
|
* that follow it.
|
||||||
*/
|
*/
|
||||||
fun extractBmlGatewayUrl(raw: String): String? {
|
fun bmlQrPayTarget(raw: String): String? {
|
||||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||||
return try {
|
val ref = bmlQrReference(raw) ?: return null
|
||||||
val root = parseTlv(raw)
|
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||||
} catch (_: Exception) {
|
}
|
||||||
null
|
|
||||||
}
|
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
|
||||||
|
private fun supplementaryDomain(raw: String): String? = try {
|
||||||
|
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
|
||||||
|
private fun bmlQrReference(raw: String): String? = try {
|
||||||
|
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
|
||||||
|
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
|
||||||
|
inner?.get("01")?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
|
||||||
|
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
|
||||||
|
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
|
||||||
|
* "Unsupported payment link").
|
||||||
|
*/
|
||||||
|
fun bmlPayRequestKey(target: String): String {
|
||||||
|
if (target.startsWith("https://")) return target
|
||||||
|
return bmlQrReference(target) ?: target
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parse(raw: String): PaymvQrData? {
|
fun parse(raw: String): PaymvQrData? {
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="10dp" />
|
||||||
|
<stroke android:width="1dp" android:color="?attr/colorOutline" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="14dp" />
|
||||||
|
<solid android:color="?attr/colorSurfaceContainerHigh" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2H6V9zM14,14H6v-2h8v2zM18,8H6V6h12v2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,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,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,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,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M4,9l1.5,-5h13L20,9M4,9v11h16V9M4,9h16M9,20v-6h6v6" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/toAccountBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/colorSecondaryContainer"
|
||||||
|
android:foreground="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="10dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="@string/chat_to"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToAccount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToNickname"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToCurrency"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_chat_chip"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivToSwitch"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:rotation="90"
|
||||||
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/saveContactBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/colorSurfaceContainerHigh"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/chat_not_in_contacts"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSaveContact"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/chat_save_contact" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/summaryCard"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="12dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@drawable/bg_chat_summary"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="14dp"
|
||||||
|
android:paddingVertical="10dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSumLabel1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSumValue1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sumColumn2"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSumLabel2"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSumValue2"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sumColumn3"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSumLabel3"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSumValue3"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvMessages"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCannotSend"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/chat_cannot_send"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/composer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCurrencyWarning"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorError"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnFrom"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:ellipsize="middle"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer"
|
||||||
|
app:icon="@drawable/ic_arrow_right"
|
||||||
|
app:iconGravity="end"
|
||||||
|
app:iconSize="16dp"
|
||||||
|
app:iconTint="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/amountRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilAmount"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1.2"
|
||||||
|
android:hint="@string/chat_amount"
|
||||||
|
app:boxCornerRadiusBottomEnd="28dp"
|
||||||
|
app:boxCornerRadiusBottomStart="28dp"
|
||||||
|
app:boxCornerRadiusTopEnd="28dp"
|
||||||
|
app:boxCornerRadiusTopStart="28dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etAmount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:imeOptions="actionNext"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilNote"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/chat_note"
|
||||||
|
app:boxCornerRadiusBottomEnd="28dp"
|
||||||
|
app:boxCornerRadiusBottomStart="28dp"
|
||||||
|
app:boxCornerRadiusTopEnd="28dp"
|
||||||
|
app:boxCornerRadiusTopStart="28dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etNote"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:imeOptions="actionSend"
|
||||||
|
android:inputType="textCapSentences"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSend"
|
||||||
|
style="@style/Widget.Material3.Button.Icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/chat_send"
|
||||||
|
app:cornerRadius="28dp"
|
||||||
|
app:icon="@drawable/ic_send"
|
||||||
|
app:iconGravity="textEnd" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Hosts the Transfer page off-screen when only its confirm dialog is wanted. -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/hiddenTransferHost"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnPayAgain"
|
||||||
|
style="@style/Widget.Material3.Button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginHorizontal="12dp"
|
||||||
|
android:layout_marginVertical="12dp"
|
||||||
|
android:text="@string/chat_pay_again"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cornerRadius="28dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="12dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||||
|
app:startIconDrawable="@android:drawable/ic_menu_search"
|
||||||
|
app:boxCornerRadiusTopStart="24dp"
|
||||||
|
app:boxCornerRadiusTopEnd="24dp"
|
||||||
|
app:boxCornerRadiusBottomStart="24dp"
|
||||||
|
app:boxCornerRadiusBottomEnd="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etSearch"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/chats_search_hint"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:imeOptions="actionSearch" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvChats"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="65dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/emptyView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginHorizontal="32dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/fabScanPay"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|start"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:contentDescription="@string/chat_scan_to_pay"
|
||||||
|
app:srcCompat="@drawable/ic_qr_scan" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -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>
|
<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>
|
||||||
|
|
||||||
|
</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,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivAvatar"
|
||||||
|
android:layout_width="52dp"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/tvTime"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/tvPreview"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintBaseline_toBaselineOf="@id/tvName" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPreview"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/ivPin"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvName"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivPin"
|
||||||
|
android:layout_width="16dp"
|
||||||
|
android:layout_height="16dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:contentDescription="@string/chat_pinned"
|
||||||
|
android:src="@drawable/ic_pin"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/tvPreview"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/tvPreview" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/bubbleRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardBubble"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardElevation="0dp"
|
||||||
|
app:strokeWidth="0dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="150dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingEnd="14dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAmount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxWidth="240dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnReceipt"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginEnd="-8dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/chat_view_receipt"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@drawable/ic_receipt"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNote"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:maxWidth="240dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="end"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAccount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxWidth="150dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:alpha="0.8"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:alpha="0.8"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTicks"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDate"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="4dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -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,55 +2,163 @@
|
|||||||
<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">
|
||||||
|
|
||||||
<TextView
|
<!-- Header: logo, bank + holder name, countdown ring -->
|
||||||
android:id="@+id/tvOtpLabel"
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
|
||||||
android:textColor="?attr/colorOnSurfaceVariant"
|
|
||||||
android:layout_marginBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvOtpCode"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
|
||||||
android:textColor="?attr/colorPrimary"
|
|
||||||
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_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:max="30"
|
android:orientation="horizontal"
|
||||||
app:trackCornerRadius="4dp"
|
android:gravity="center_vertical">
|
||||||
app:indicatorColor="?attr/colorPrimary"
|
|
||||||
app:trackColor="?attr/colorSurfaceVariant" />
|
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
android:id="@+id/tvOtpCountdown"
|
android:id="@+id/ivBankLogo"
|
||||||
android:layout_width="wrap_content"
|
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
|
||||||
|
android:id="@+id/tvOtpLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<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_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:indeterminate="false"
|
||||||
|
android:max="30"
|
||||||
|
app:indicatorSize="40dp"
|
||||||
|
app:trackThickness="3dp"
|
||||||
|
app:trackCornerRadius="2dp"
|
||||||
|
app:indicatorColor="?attr/colorPrimary"
|
||||||
|
app:trackColor="?attr/colorOutlineVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCountdown"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Current code + copy -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="12dp"
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
android:orientation="horizontal"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<item name="action_open_contacts" type="id" />
|
||||||
|
<item name="action_pin_chat" type="id" />
|
||||||
|
</resources>
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
<string name="nav_add_account">Add Login</string>
|
<string name="nav_add_account">Add Login</string>
|
||||||
<string name="nav_accounts">Accounts</string>
|
<string name="nav_accounts">Accounts</string>
|
||||||
<string name="nav_contacts">Contacts</string>
|
<string name="nav_contacts">Contacts</string>
|
||||||
|
<string name="nav_chats">Chats</string>
|
||||||
<string name="nav_activities">Recent Transfers</string>
|
<string name="nav_activities">Recent Transfers</string>
|
||||||
<string name="nav_transfer_history">Transaction History</string>
|
<string name="nav_transfer_history">Transaction History</string>
|
||||||
<string name="nav_finances">Finances</string>
|
<string name="nav_finances">Finances</string>
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
<string name="nav_more">More</string>
|
<string name="nav_more">More</string>
|
||||||
<string name="nav_desc_accounts">View all your bank accounts</string>
|
<string name="nav_desc_accounts">View all your bank accounts</string>
|
||||||
<string name="nav_desc_contacts">Manage your transfer contacts</string>
|
<string name="nav_desc_contacts">Manage your transfer contacts</string>
|
||||||
|
<string name="nav_desc_chats">Your transfers with each person, as chats</string>
|
||||||
<string name="nav_desc_transfer">Send money to a contact</string>
|
<string name="nav_desc_transfer">Send money to a contact</string>
|
||||||
<string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string>
|
<string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string>
|
||||||
<string name="nav_desc_activities">View your recent transfers</string>
|
<string name="nav_desc_activities">View your recent transfers</string>
|
||||||
@@ -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,61 @@
|
|||||||
<!-- 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_preview_paid">Paid %s</string>
|
||||||
|
<string name="chat_today">Today</string>
|
||||||
|
<string name="chat_yesterday">Yesterday</string>
|
||||||
|
<string name="chat_from_account">From account</string>
|
||||||
|
<string name="chat_pick_account">Send from</string>
|
||||||
|
<string name="chat_pick_card">Pay with</string>
|
||||||
|
<string name="chat_from_chip">%1$s · %2$s · %3$s</string>
|
||||||
|
<string name="chat_currency_warning">⚠ Sending USD to an MVR account. It will be converted at BML\'s rate and can\'t be reversed.</string>
|
||||||
|
<string name="chat_currency_blocked">MVR can\'t be sent to a USD account. Pick a USD account to send from.</string>
|
||||||
|
<string name="chat_cant_send_mvr_to_usd">Can\'t send MVR to a USD account</string>
|
||||||
|
<string name="chat_needs_usd_source">Needs a USD account to send from</string>
|
||||||
|
<string name="chat_transfer_sent">Transfer sent</string>
|
||||||
|
<string name="chat_payment_sent">Payment sent</string>
|
||||||
|
<string name="chat_view_receipt">Receipt</string>
|
||||||
|
<string name="chat_not_in_contacts">Not in your contacts</string>
|
||||||
|
<string name="chat_save_contact">Save contact</string>
|
||||||
|
<string name="chat_send">Send</string>
|
||||||
|
<string name="chat_to">To</string>
|
||||||
|
<string name="chat_pick_recipient_account">Send to</string>
|
||||||
|
<string name="chat_receipt_missing">Receipt no longer available</string>
|
||||||
|
<string name="chat_amount">Amount</string>
|
||||||
|
<string name="chat_amount_invalid">Enter an amount</string>
|
||||||
|
<string name="chat_cannot_send">Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.</string>
|
||||||
|
<string name="chat_into_short">into %s</string>
|
||||||
|
<string name="chat_account_to">%1$s → %2$s</string>
|
||||||
|
<string name="chat_tick_seen">Seen in BML alert</string>
|
||||||
|
<string name="chat_tick_booked">Booked in BML history</string>
|
||||||
|
<string name="chat_note">Note</string>
|
||||||
|
<string name="chat_pay_again">Pay again with Scan to Pay</string>
|
||||||
|
<string name="chat_pay_business">Pay %s</string>
|
||||||
|
<string name="chat_scan_to_pay">Scan to Pay</string>
|
||||||
|
<string name="chat_merchant_subtitle">Business · Scan to Pay & card</string>
|
||||||
|
<string name="chat_sum_sent">%s · sent</string>
|
||||||
|
<string name="chat_sum_received">received</string>
|
||||||
|
<string name="chat_sum_net">net</string>
|
||||||
|
<string name="chat_sum_spent">Spent here in %s</string>
|
||||||
|
<string name="chat_sum_payments">payments</string>
|
||||||
|
<string name="chat_send_again">Send again · %s</string>
|
||||||
|
<string name="chat_view_receipt_full">View receipt</string>
|
||||||
|
<string name="chat_copy_reference">Copy reference</string>
|
||||||
|
<string name="chat_reference_copied">Reference copied</string>
|
||||||
|
<string name="chat_pinned">Pinned</string>
|
||||||
|
<string name="chat_pin">Pin</string>
|
||||||
|
<string name="chat_unpin">Unpin</string>
|
||||||
|
<string name="chat_selected_one">1 selected</string>
|
||||||
|
<string name="chat_pinned_msg">%s pinned</string>
|
||||||
|
<string name="chat_unpinned_msg">%s unpinned</string>
|
||||||
|
<string name="chat_undo">Undo</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
|
|||||||
|
|
||||||
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
||||||
|
|
||||||
|
### 3. POS QR (`mv.com.bml.qtr`)
|
||||||
|
|
||||||
|
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
|
||||||
|
`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as
|
||||||
|
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
|
||||||
|
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
|
||||||
|
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
|
||||||
|
understands it. See
|
||||||
|
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PayMV QR Format (TLV)
|
## PayMV QR Format (TLV)
|
||||||
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
|
|||||||
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
||||||
```
|
```
|
||||||
|
|
||||||
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
|
`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
|
||||||
|
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference.
|
||||||
|
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
|
||||||
|
rather than relying on either being required.
|
||||||
|
|
||||||
### Headers
|
### Headers
|
||||||
|
|
||||||
| Header | Value |
|
| Header | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `accept` | `application/json` — **required in practice**, see below |
|
||||||
| `Authorization` | `Bearer <access_token>` |
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
| `x-app-version` | `2.1.44.348` |
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
|
||||||
|
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
|
||||||
|
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
|
||||||
|
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --request GET \
|
curl --request GET \
|
||||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
||||||
|
--header 'accept: application/json' \
|
||||||
--header 'Authorization: Bearer <access_token>' \
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
--header 'x-app-version: 2.1.44.348'
|
--header 'x-app-version: 2.1.44.348'
|
||||||
@@ -116,6 +136,16 @@ curl --request GET \
|
|||||||
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
||||||
| `currency` | Currency code (typically `"MVR"`) |
|
| `currency` | Currency code (typically `"MVR"`) |
|
||||||
|
|
||||||
|
### Failure Responses
|
||||||
|
|
||||||
|
`success: false` comes back with a code and a user-facing message; the client shows BML's own
|
||||||
|
wording and keeps the user on the Transfer screen.
|
||||||
|
|
||||||
|
| Code | Message |
|
||||||
|
|---|---|
|
||||||
|
| `103` | The payment request has expired |
|
||||||
|
| `112` | Unsupported payment link |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Pay (3-Step TOTP Flow)
|
## Step 2 — Pay (3-Step TOTP Flow)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
|
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
|
||||||
|
|
||||||
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`).
|
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
|
||||||
|
|
||||||
|
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
|||||||
| `00` | Format indicator | Always `"01"` |
|
| `00` | Format indicator | Always `"01"` |
|
||||||
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
|
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
|
||||||
| `26` | Merchant account information | Container — see sub-tags below |
|
| `26` | Merchant account information | Container — see sub-tags below |
|
||||||
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only |
|
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
|
||||||
| `52` | Merchant category code | `"0000"` (generic) |
|
| `52` | Merchant category code | `"0000"` (generic) |
|
||||||
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
||||||
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
|
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
|
||||||
| `58` | Country code | `"MV"` |
|
| `58` | Country code | `"MV"` |
|
||||||
| `59` | Merchant / recipient name | Max 25 characters |
|
| `59` | Merchant / recipient name | Max 25 characters |
|
||||||
|
| `60` | Merchant city / store code | BML POS QRs only |
|
||||||
| `62` | Additional data field | Container — see sub-tags below |
|
| `62` | Additional data field | Container — see sub-tags below |
|
||||||
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
||||||
| `80` | Supplementary data | Container — timestamp and domain |
|
| `80` | Supplementary data | Container — timestamp and domain |
|
||||||
@@ -153,6 +154,67 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex
|
|||||||
|
|
||||||
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
|
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
|
||||||
|
|
||||||
|
`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for
|
||||||
|
combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr`
|
||||||
|
supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BML POS QR (`mv.com.bml.qtr`)
|
||||||
|
|
||||||
|
BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain
|
||||||
|
(tag `80` → `00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account
|
||||||
|
number to transfer to. The same `35` → `20` container is used as in combined QRs, but sub-tag `01`
|
||||||
|
holds a bare reference instead of a URL.
|
||||||
|
|
||||||
|
Example (CRC verified, same CRC-16/CCITT-FALSE as above):
|
||||||
|
|
||||||
|
```
|
||||||
|
00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA
|
||||||
|
```
|
||||||
|
|
||||||
|
| TLV path | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `00` | `01` | Format indicator |
|
||||||
|
| `01` | `12` | Dynamic QR |
|
||||||
|
| `35`→`20`→`00` | `02` | Version / format of the container (combined QRs use the URL form) |
|
||||||
|
| `35`→`20`→`01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `<type>:<32 hex>` |
|
||||||
|
| `35`→`20`→`02` | `9809724081` | Merchant identifier (10 digits) |
|
||||||
|
| `35`→`20`→`03` | `74009538` | Terminal identifier (8 digits) |
|
||||||
|
| `52` | `0000` | MCC |
|
||||||
|
| `53` | `462` | MVR |
|
||||||
|
| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` |
|
||||||
|
| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code |
|
||||||
|
| `62`→`05` | `dtyams497d` | Reference / bill number |
|
||||||
|
| `62`→`08` | `POPE` | Purpose / terminal label |
|
||||||
|
| `80`→`00` | `mv.com.bml.qtr` | Domain — identifies the POS format |
|
||||||
|
| `80`→`01` | `2026-09-21T13:33:22.00000` | Timestamp |
|
||||||
|
|
||||||
|
### Pay-Request Lookup Key
|
||||||
|
|
||||||
|
The lookup key is the **bare reference**, Base64-encoded without padding:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk
|
||||||
|
```
|
||||||
|
|
||||||
|
BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with
|
||||||
|
`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must*
|
||||||
|
carry is `accept: application/json` — see
|
||||||
|
[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers).
|
||||||
|
|
||||||
|
Confirmed against the live API (the example QR had already expired, so BML answered `103` rather
|
||||||
|
than with merchant details — but `103` means the reference itself resolved):
|
||||||
|
|
||||||
|
| Key tried | Response |
|
||||||
|
|---|---|
|
||||||
|
| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised |
|
||||||
|
| the whole EMV payload | `112` — "Unsupported payment link" ❌ |
|
||||||
|
| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix |
|
||||||
|
|
||||||
|
`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so
|
||||||
|
exactly one request is made either way.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Example Payload
|
## Example Payload
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
|
|||||||
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
|
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
|
||||||
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
|
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
|
||||||
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
|
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
|
||||||
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount |
|
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
|
||||||
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
|
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re-
|
|||||||
|
|
||||||
## BML QR Merchant Payment Flow
|
## BML QR Merchant Payment Flow
|
||||||
|
|
||||||
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner.
|
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's.
|
||||||
|
|
||||||
Two sub-modes:
|
Three sub-modes:
|
||||||
|
|
||||||
| Mode | Trigger | Extra step |
|
| Mode | `bmlQrPayTarget()` returns | Extra step |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
|
| Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
|
||||||
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate |
|
| Gateway QR | the QR text, or the URL at TLV `35`→`20`→`01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate |
|
||||||
|
| POS QR | the whole EMV payload, for QRs whose tag `80`→`00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below |
|
||||||
|
|
||||||
|
`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35`→`20`→`01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
|
1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
|
||||||
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
|
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
|
||||||
3. Remarks field is locked (not applicable for merchant payments).
|
3. Remarks field is locked (not applicable for merchant payments).
|
||||||
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
|
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
|
||||||
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
|
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
|
||||||
|
|
||||||
|
**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string.
|
||||||
|
|
||||||
|
> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Transfer Button Enable Conditions
|
## Transfer Button Enable Conditions
|
||||||
@@ -238,7 +245,7 @@ Flow:
|
|||||||
The transfer button is only enabled when all of the following are true:
|
The transfer button is only enabled when all of the following are true:
|
||||||
|
|
||||||
- A source account is selected
|
- A source account is selected
|
||||||
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set)
|
- A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
|
||||||
- Amount is greater than `0`
|
- Amount is greater than `0`
|
||||||
- No connectivity error for `NO_INTERNET` or for the source bank
|
- No connectivity error for `NO_INTERNET` or for the source bank
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch
|
|||||||
|
|
||||||
| Caller | Result handling |
|
| Caller | Result handling |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` |
|
| [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` |
|
||||||
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
|
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
|
||||||
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
|
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
|
||||||
| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
|
| [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
|
||||||
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
|
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
|
||||||
|
|
||||||
### Share-to-Scan Fast Path
|
### Share-to-Scan Fast Path
|
||||||
|
|||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user