Compare commits
21
Commits
04af4e1bbd
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a9731797
|
||
|
|
50badc7d54
|
||
|
|
d4f86bb738
|
||
|
|
b35f44f35b
|
||
|
|
1a58ce8b54
|
||
|
|
2dd84ec50a
|
||
|
|
33651ca107
|
||
|
|
ae307e3118
|
||
|
|
3ab75bff92
|
||
|
|
1753d648bd
|
||
|
|
423b0bf1e1
|
||
|
|
d713047970
|
||
|
|
d59a6fad82
|
||
|
|
8e47101401
|
||
|
|
9431a90cd0
|
||
|
|
3a10f36c39
|
||
|
|
27c428d1f6
|
||
|
|
7b4f650f4e
|
||
|
|
00e109562b
|
||
|
|
cd4b3fef8b
|
||
|
|
389344a192
|
Generated
+15
-2
@@ -3,7 +3,7 @@
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<option name="selectionMode" value="DIALOG" />
|
||||
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
@@ -11,7 +11,20 @@
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
<DialogSelection>
|
||||
<targets>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||
</handle>
|
||||
</Target>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||
</handle>
|
||||
</Target>
|
||||
</targets>
|
||||
</DialogSelection>
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "1.0.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -64,6 +64,9 @@ dependencies {
|
||||
// RecyclerView for accounts list
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
|
||||
// CircularProgressDrawable for spinning search icons
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
// OkHttp for API calls
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.HomeActivity"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.QrScannerActivity"
|
||||
|
||||
@@ -19,11 +19,19 @@ class BasedBankApp : Application() {
|
||||
var fullName: String = ""
|
||||
var mibSession: MibSession? = null
|
||||
var mibProfiles: List<MibProfile> = emptyList()
|
||||
var bmlSession: BmlSession? = null
|
||||
/** Active BML sessions keyed by loginId (= BML username). */
|
||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||
var bmlAccounts: List<MibAccount> = emptyList()
|
||||
var fahipaySession: FahipaySession? = null
|
||||
var fahipayAccounts: List<MibAccount> = emptyList()
|
||||
|
||||
/** Returns the BML session for the given account (matched via loginTag). */
|
||||
fun bmlSessionFor(account: MibAccount): BmlSession? =
|
||||
bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||
|
||||
/** Returns any available BML session (for non-account-specific operations). */
|
||||
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
val mibMutex = Mutex()
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,8 +21,6 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
@@ -31,7 +32,6 @@ class LockActivity : AppCompatActivity() {
|
||||
private lateinit var salt: String
|
||||
private lateinit var storedHash: String
|
||||
private var biometricsEnabled = false
|
||||
private var isLegacyFormat = false
|
||||
private var isVerifying = false
|
||||
|
||||
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||
@@ -39,28 +39,32 @@ class LockActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val MAX_ATTEMPTS = 5
|
||||
private const val LOCKOUT_MS = 30_000L
|
||||
const val EXTRA_RESUME = "resume"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
|
||||
// Try new encrypted format first; fall back to legacy SHA-256
|
||||
val stored = CredentialStore(this).loadSecurityHash()
|
||||
if (stored != null) {
|
||||
salt = stored.first
|
||||
storedHash = stored.second
|
||||
isLegacyFormat = false
|
||||
} else {
|
||||
salt = prefs.getString("security_salt", "") ?: ""
|
||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
||||
isLegacyFormat = true
|
||||
}
|
||||
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||
salt = stored.first
|
||||
storedHash = stored.second
|
||||
|
||||
if (method == "pin") {
|
||||
binding.viewPin.visibility = View.VISIBLE
|
||||
@@ -149,7 +153,6 @@ class LockActivity : AppCompatActivity() {
|
||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||
isVerifying = false
|
||||
if (ok) {
|
||||
migrateIfNeeded(entered)
|
||||
resetFailures()
|
||||
proceed()
|
||||
} else {
|
||||
@@ -174,7 +177,6 @@ class LockActivity : AppCompatActivity() {
|
||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||
isVerifying = false
|
||||
if (ok) {
|
||||
migrateIfNeeded(entered)
|
||||
resetFailures()
|
||||
proceed()
|
||||
} else {
|
||||
@@ -216,30 +218,8 @@ class LockActivity : AppCompatActivity() {
|
||||
|
||||
private fun verify(input: String): Boolean {
|
||||
if (storedHash.isBlank()) return false
|
||||
return if (isLegacyFormat) {
|
||||
sha256Legacy(salt + input) == storedHash
|
||||
} else {
|
||||
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||
pbkdf2(input, saltBytes) == storedHash
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On the first successful unlock after legacy SHA-256 format is detected,
|
||||
* transparently migrate to PBKDF2 + CredentialStore.
|
||||
*/
|
||||
private fun migrateIfNeeded(input: String) {
|
||||
if (!isLegacyFormat) return
|
||||
try {
|
||||
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val newHash = pbkdf2(input, newSalt)
|
||||
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
|
||||
CredentialStore(this).saveSecurityHash(saltB64, newHash)
|
||||
// Remove legacy plaintext fields
|
||||
getSharedPreferences("prefs", MODE_PRIVATE).edit()
|
||||
.remove("security_salt").remove("security_hash").apply()
|
||||
isLegacyFormat = false
|
||||
} catch (_: Exception) { /* migration will retry next unlock */ }
|
||||
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||
return pbkdf2(input, saltBytes) == storedHash
|
||||
}
|
||||
|
||||
private fun triggerBiometric() {
|
||||
@@ -270,8 +250,12 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun proceed() {
|
||||
startActivity(Intent(this, HomeActivity::class.java))
|
||||
finish()
|
||||
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||
finish()
|
||||
} else {
|
||||
startActivity(Intent(this, HomeActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Brute-force tracking ──────────────────────────────────────────────────
|
||||
@@ -308,9 +292,4 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
|
||||
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -166,17 +166,17 @@ class BmlLoginFlow {
|
||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||
|
||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
||||
val accounts = fetchAccounts(session)
|
||||
val accounts = fetchAccounts(session, "bml_$username")
|
||||
return Pair(session, accounts)
|
||||
}
|
||||
|
||||
fun fetchAccounts(session: BmlSession): List<MibAccount> {
|
||||
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseDashboard(json ?: return emptyList(), "bml_${session.deviceId}")
|
||||
return parseDashboard(json ?: return emptyList(), loginTag)
|
||||
}
|
||||
|
||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||
@@ -324,11 +324,11 @@ class BmlLoginFlow {
|
||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
fun fetchContacts(session: BmlSession): List<MibBeneficiary> {
|
||||
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
return parseContacts(json)
|
||||
return parseContacts(json, loginId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -634,29 +634,28 @@ class BmlLoginFlow {
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
if (!isVisible) continue // debit cards and other hidden cards — skip
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
if (isPrepaid) {
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
prepaidCards.add(MibAccount(
|
||||
profileName = "Personal",
|
||||
profileType = "BML_PREPAID",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = product,
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
} else {
|
||||
// Linked debit cards have no independent balance or account link — skip
|
||||
}
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
prepaidCards.add(MibAccount(
|
||||
profileName = "Personal",
|
||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(current),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,7 +693,7 @@ class BmlLoginFlow {
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun parseContacts(json: String): List<MibBeneficiary> {
|
||||
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||
@@ -715,7 +714,8 @@ class BmlLoginFlow {
|
||||
benefStatus = item.optString("status", "S"),
|
||||
transferCyDesc = item.optString("currency", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = "BML"
|
||||
benefCategoryId = "BML",
|
||||
profileId = loginId
|
||||
))
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -34,6 +34,7 @@ class AccountHistoryAdapter(
|
||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||
|
||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||
imageCache[counterpartyName] = bitmap
|
||||
@@ -164,6 +165,7 @@ class AccountHistoryAdapter(
|
||||
} else {
|
||||
b.llHeaderBlocked.visibility = View.GONE
|
||||
}
|
||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
|
||||
}
|
||||
|
||||
private fun friendlyType(raw: String): String {
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
@@ -82,6 +83,9 @@ class AccountHistoryFragment : Fragment() {
|
||||
adapter = AccountHistoryAdapter(account)
|
||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||
adapter.onTransferClick = { acc ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
@@ -130,7 +134,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
|
||||
private fun isBmlCard() = account.profileType == "BML_PREPAID"
|
||||
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||
private fun isFahipay() = account.profileType == "FAHIPAY"
|
||||
|
||||
private fun hasMore(): Boolean = when {
|
||||
@@ -185,7 +189,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
isBmlCard() -> {
|
||||
val session = app.bmlSession ?: return@withContext emptyList()
|
||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
@@ -199,7 +203,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSession ?: return@withContext emptyList()
|
||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
|
||||
@@ -3,17 +3,18 @@ package sh.sar.basedbank.ui.home
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.util.BmlDashboardParser
|
||||
import sh.sar.basedbank.util.MibAccountParser
|
||||
|
||||
class AccountsAdapter(
|
||||
accounts: List<MibAccount>,
|
||||
@@ -21,7 +22,7 @@ class AccountsAdapter(
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class SectionTitle(val label: String, val chip: String) : Item()
|
||||
data class SectionTitle(val label: String) : Item()
|
||||
data class Account(val account: MibAccount) : Item()
|
||||
data class Card(val account: MibAccount) : Item()
|
||||
}
|
||||
@@ -35,19 +36,40 @@ class AccountsAdapter(
|
||||
}
|
||||
|
||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
||||
val regular = accounts.filter { it.profileType != "BML_PREPAID" }
|
||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" }
|
||||
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||
|
||||
if (regular.isNotEmpty()) {
|
||||
add(Item.SectionTitle("Accounts", ""))
|
||||
regular.forEach { add(Item.Account(it)) }
|
||||
// Group non-prepaid accounts by their derived section title, preserving order
|
||||
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
|
||||
for (acc in nonPrepaid) {
|
||||
val title = sectionTitle(acc)
|
||||
groups.getOrPut(title) { mutableListOf() }.add(acc)
|
||||
}
|
||||
for ((title, group) in groups) {
|
||||
add(Item.SectionTitle(title))
|
||||
group.forEach { add(Item.Account(it)) }
|
||||
}
|
||||
|
||||
if (prepaid.isNotEmpty()) {
|
||||
add(Item.SectionTitle("Cards", "BML"))
|
||||
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
||||
prepaid.forEach { add(Item.Card(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectionTitle(account: MibAccount): String {
|
||||
val profileLabel = when (account.profileType) {
|
||||
"0" -> "Personal"
|
||||
"1" -> "Business"
|
||||
else -> account.profileName
|
||||
}
|
||||
val bank = when {
|
||||
account.profileType.startsWith("BML") -> "Bank of Maldives"
|
||||
account.profileType == "FAHIPAY" -> "Fahipay"
|
||||
else -> "Maldives Islamic Bank"
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||
is Item.SectionTitle -> TYPE_HEADER
|
||||
is Item.Account -> TYPE_ACCOUNT
|
||||
@@ -57,7 +79,7 @@ class AccountsAdapter(
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
TYPE_HEADER -> SectionViewHolder(ItemProfileHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
||||
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
@@ -73,16 +95,10 @@ class AccountsAdapter(
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
private inner class SectionViewHolder(private val binding: ItemProfileHeaderBinding) :
|
||||
private inner class SectionViewHolder(private val binding: ItemDateHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(item: Item.SectionTitle) {
|
||||
binding.tvProfileName.text = item.label
|
||||
if (item.chip.isNotEmpty()) {
|
||||
binding.tvProfileType.text = item.chip
|
||||
binding.tvProfileType.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.tvProfileType.visibility = View.GONE
|
||||
}
|
||||
binding.tvDateHeader.text = item.label
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,18 +107,12 @@ class AccountsAdapter(
|
||||
fun bind(account: MibAccount) {
|
||||
binding.tvAccountName.text = account.accountBriefName
|
||||
binding.tvAccountNumber.text = account.accountNumber
|
||||
binding.tvPillBank.text = when {
|
||||
account.profileType.startsWith("BML") -> "BML"
|
||||
account.profileType == "FAHIPAY" -> "FP"
|
||||
else -> null
|
||||
}
|
||||
binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
|
||||
binding.tvPillProfile.text = when (account.profileType) {
|
||||
"0" -> "Personal"
|
||||
"1" -> "Business"
|
||||
else -> account.profileName
|
||||
}
|
||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
val label = if (account.profileType.startsWith("BML"))
|
||||
BmlDashboardParser.productLabel(account.accountTypeName)
|
||||
else
|
||||
MibAccountParser.productLabel(account.accountTypeName)
|
||||
binding.tvPillType.text = label
|
||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
binding.root.setOnLongClickListener {
|
||||
copyToClipboard(it.context, account.accountNumber)
|
||||
@@ -114,15 +124,10 @@ class AccountsAdapter(
|
||||
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount) {
|
||||
val brand = cardBrand(account.accountTypeName)
|
||||
binding.tvCardBrand.text = brand.label
|
||||
binding.tvCardBrand.background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = 100f
|
||||
setColor(Color.parseColor(brand.color))
|
||||
}
|
||||
binding.tvCardName.text = account.accountBriefName
|
||||
binding.tvCardNumber.text = account.accountNumber
|
||||
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName))
|
||||
binding.tvCardName.text = account.accountBriefName
|
||||
binding.tvCardNumber.text = account.accountNumber
|
||||
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName)
|
||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
|
||||
@@ -150,29 +155,12 @@ class AccountsAdapter(
|
||||
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun friendlyAccountType(raw: String): String {
|
||||
val u = raw.trim().uppercase()
|
||||
return when {
|
||||
u == "SAVINGS ACCOUNT" ||
|
||||
u == "SAVING ACCOUNT" -> "Savings"
|
||||
u == "CURRENT ACCOUNT" ||
|
||||
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
||||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
||||
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
||||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
||||
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
||||
else -> raw.trim()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Brand(val label: String, val color: String)
|
||||
|
||||
private fun cardBrand(productName: String): Brand = when {
|
||||
private fun cardBrandIcon(productName: String): Int = when {
|
||||
productName.contains("AMEX", ignoreCase = true) ||
|
||||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> Brand("AMEX", "#016FD0")
|
||||
productName.contains("VISA", ignoreCase = true) -> Brand("VISA", "#1A1F71")
|
||||
productName.contains("MASTERCARD", ignoreCase = true) -> Brand("MC", "#FF5F00")
|
||||
else -> Brand("CARD", "#555555")
|
||||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
|
||||
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
|
||||
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
|
||||
else -> R.drawable.ic_nav_card
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,18 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Filter
|
||||
import android.widget.Filterable
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -43,7 +49,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
private data class DestinationOption(
|
||||
val label: String,
|
||||
val isBml: Boolean,
|
||||
val mibProfile: MibProfile? = null
|
||||
val mibProfile: MibProfile? = null,
|
||||
val bmlLoginId: String? = null,
|
||||
val subtitle: String = ""
|
||||
)
|
||||
|
||||
private var destinations: List<DestinationOption> = emptyList()
|
||||
@@ -86,18 +94,44 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
for (profile in app.mibProfiles) {
|
||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
|
||||
}
|
||||
if (app.bmlSession != null) {
|
||||
list.add(DestinationOption("BML · Personal", isBml = true))
|
||||
val store = CredentialStore(requireContext())
|
||||
for ((loginId, _) in app.bmlSessions) {
|
||||
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
|
||||
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
|
||||
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private fun setupDestinationDropdown() {
|
||||
val labels = destinations.map { it.label }
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels)
|
||||
val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getDropDownView(position, convertView, parent)
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, parent, false)
|
||||
val opt = destinations[position]
|
||||
view.findViewById<TextView>(android.R.id.text1).text = opt.label
|
||||
val text2 = view.findViewById<TextView>(android.R.id.text2)
|
||||
if (opt.subtitle.isNotBlank()) {
|
||||
text2.text = opt.subtitle
|
||||
text2.visibility = View.VISIBLE
|
||||
} else {
|
||||
text2.visibility = View.GONE
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getFilter() = object : Filter() {
|
||||
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = destinations; count = destinations.size }
|
||||
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||
override fun convertResultToString(r: Any?) = ""
|
||||
}
|
||||
}
|
||||
binding.actvDestination.setAdapter(adapter)
|
||||
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
||||
selectedDest = destinations[position]
|
||||
binding.actvDestination.setText(destinations[position].label, false)
|
||||
clearLookupResult()
|
||||
updateMibOnlyVisibility()
|
||||
binding.btnSave.isEnabled = false
|
||||
@@ -129,6 +163,22 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLookupLoading() {
|
||||
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||
setStyle(CircularProgressDrawable.DEFAULT)
|
||||
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||
start()
|
||||
}
|
||||
binding.tilAccount.endIconDrawable = spinner
|
||||
binding.tilAccount.isEnabled = false
|
||||
}
|
||||
|
||||
private fun stopLookupLoading() {
|
||||
binding.tilAccount.isEnabled = true
|
||||
binding.tilAccount.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||
}
|
||||
|
||||
private fun setupAccountSearch() {
|
||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||
}
|
||||
@@ -167,7 +217,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
return
|
||||
}
|
||||
|
||||
binding.tilAccount.isEnabled = false
|
||||
startLookupLoading()
|
||||
binding.tilDestination.isEnabled = false
|
||||
binding.btnSave.isEnabled = false
|
||||
|
||||
@@ -175,7 +225,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
||||
}
|
||||
binding.tilAccount.isEnabled = true
|
||||
stopLookupLoading()
|
||||
binding.tilDestination.isEnabled = true
|
||||
if (result != null) {
|
||||
showLookupResult(result, input)
|
||||
@@ -186,7 +236,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
private fun lookupForBml(input: String): BmlAccountValidation? {
|
||||
val bmlSess = app.bmlSession ?: return null
|
||||
val loginId = selectedDest?.bmlLoginId ?: return null
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return null
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
|
||||
// 1) Try BML validate
|
||||
@@ -236,7 +287,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
if (mibResult != null) return mibResult
|
||||
|
||||
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
||||
val bmlSess = app.bmlSession ?: return null
|
||||
val bmlSess = app.anyBmlSession() ?: return null
|
||||
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
@@ -341,6 +392,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
binding.tilAlias.error = null
|
||||
|
||||
binding.btnSave.isEnabled = false
|
||||
binding.btnSave.text = "Saving..."
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
@@ -352,13 +404,15 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
dismiss()
|
||||
} else {
|
||||
binding.btnSave.isEnabled = true
|
||||
binding.btnSave.text = "Save"
|
||||
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToBml(alias: String): Boolean {
|
||||
val bmlSess = app.bmlSession ?: return false
|
||||
val loginId = selectedDest?.bmlLoginId ?: return false
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return false
|
||||
val lookup = bmlLookup ?: return false
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
val account = lookup.account
|
||||
@@ -425,12 +479,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (dest.isBml) {
|
||||
val bmlSess = app.bmlSession ?: return@launch
|
||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess)
|
||||
val loginId = dest.bmlLoginId ?: return@launch
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return@launch
|
||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
|
||||
val existing = viewModel.contacts.value ?: emptyList()
|
||||
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
||||
viewModel.contacts.postValue(merged)
|
||||
ContactsCache.saveBml(requireContext(), fresh)
|
||||
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
|
||||
} else {
|
||||
val profile = dest.mibProfile ?: return@launch
|
||||
val mibSess = app.mibSession ?: return@launch
|
||||
|
||||
@@ -122,6 +122,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
attachMediator(initialPages)
|
||||
|
||||
binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() }
|
||||
binding.etSheetSearch.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
val allIndex = pagerAdapter.pages.indexOfFirst { it.tag == null }
|
||||
if (allIndex >= 0) binding.viewPager.setCurrentItem(allIndex, true)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||
val pages = buildList {
|
||||
@@ -203,11 +209,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID"
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT"
|
||||
|
||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" }
|
||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||
|
||||
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
|
||||
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
|
||||
|
||||
@@ -158,7 +158,7 @@ class ContactsFragment : Fragment() {
|
||||
colorHex = contact.bankColor,
|
||||
imageHash = contact.customerImgHash
|
||||
)
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||
}
|
||||
|
||||
private fun confirmDelete(contact: MibBeneficiary) {
|
||||
@@ -185,7 +185,7 @@ class ContactsFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun deleteBml(contact: MibBeneficiary): Boolean {
|
||||
val sess = app.bmlSession ?: return false
|
||||
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
||||
val contactId = contact.benefNo.removePrefix("bml_")
|
||||
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||
}
|
||||
@@ -205,7 +205,11 @@ class ContactsFragment : Fragment() {
|
||||
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
|
||||
viewModel.contacts.value = updated
|
||||
if (contact.benefCategoryId == "BML") {
|
||||
ContactsCache.saveBml(requireContext(), updated.filter { it.benefCategoryId == "BML" })
|
||||
updated.filter { it.benefCategoryId == "BML" }
|
||||
.groupBy { it.profileId }
|
||||
.forEach { (loginId, contacts) ->
|
||||
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, contacts)
|
||||
}
|
||||
} else {
|
||||
ContactsCache.save(
|
||||
requireContext(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.os.CountDownTimer
|
||||
import android.os.Handler
|
||||
@@ -14,6 +15,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -64,24 +66,37 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||
|
||||
private var isLocked = false
|
||||
|
||||
private val autolockRunnable = Runnable {
|
||||
countdownTimer?.cancel(); countdownTimer = null
|
||||
warningDialog?.dismiss(); warningDialog = null
|
||||
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
.getString("security_method", null) != null
|
||||
if (securitySet) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
if (securitySet) lock()
|
||||
}
|
||||
|
||||
private fun lock() {
|
||||
isLocked = true
|
||||
startActivity(
|
||||
Intent(this, sh.sar.basedbank.LockActivity::class.java)
|
||||
.putExtra(sh.sar.basedbank.LockActivity.EXTRA_RESUME, true)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityHomeBinding.inflate(layoutInflater)
|
||||
if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) {
|
||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
setContentView(binding.root)
|
||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
setSupportActionBar(binding.toolbar)
|
||||
|
||||
toggle = ActionBarDrawerToggle(
|
||||
@@ -119,7 +134,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||
viewModel.accounts.value = merged
|
||||
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
|
||||
if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts)
|
||||
if (app.bmlAccounts.isNotEmpty()) {
|
||||
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
|
||||
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
|
||||
}
|
||||
if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts)
|
||||
|
||||
val cachedFinancing = FinancingCache.load(this)
|
||||
@@ -128,11 +146,12 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
||||
|
||||
refreshFinancing(app.mibSession, app.mibProfiles)
|
||||
if (app.bmlSession != null) refreshBmlLimits(app.bmlSession!!)
|
||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||
} else {
|
||||
// Came from lock screen — show caches immediately, refresh everything in background
|
||||
val store = CredentialStore(this)
|
||||
val cachedMib = AccountCache.load(this)
|
||||
val cachedBml = AccountCache.loadBml(this)
|
||||
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||
val cachedFahipay = AccountCache.loadFahipay(this)
|
||||
val merged = cachedMib + cachedBml + cachedFahipay
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||
@@ -141,8 +160,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cachedLimits = ForeignLimitsCache.load(this)
|
||||
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
||||
|
||||
val store = CredentialStore(this)
|
||||
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store)
|
||||
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
|
||||
}
|
||||
|
||||
// Show dashboard on first create
|
||||
@@ -199,18 +217,19 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(itemId: Int) {
|
||||
when (itemId) {
|
||||
R.id.nav_dashboard -> show(DashboardFragment())
|
||||
R.id.nav_accounts -> show(AccountsFragment())
|
||||
R.id.nav_contacts -> show(ContactsFragment())
|
||||
R.id.nav_transfer -> show(TransferFragment())
|
||||
R.id.nav_transfer_history -> show(TransferHistoryFragment())
|
||||
R.id.nav_finances -> show(FinancingFragment())
|
||||
R.id.nav_otp -> show(OtpFragment())
|
||||
R.id.nav_settings -> show(SettingsFragment())
|
||||
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
||||
val dest = fragment ?: when (itemId) {
|
||||
R.id.nav_dashboard -> DashboardFragment()
|
||||
R.id.nav_accounts -> AccountsFragment()
|
||||
R.id.nav_contacts -> ContactsFragment()
|
||||
R.id.nav_transfer -> TransferFragment()
|
||||
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||
R.id.nav_finances -> FinancingFragment()
|
||||
R.id.nav_otp -> OtpFragment()
|
||||
R.id.nav_settings -> SettingsFragment()
|
||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||
}
|
||||
show(dest)
|
||||
binding.navigationView.setCheckedItem(itemId)
|
||||
val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more)
|
||||
if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) {
|
||||
@@ -220,6 +239,13 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun setBottomNavVisible(visible: Boolean) {
|
||||
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
if (isBottom) {
|
||||
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
fun setRefreshing(visible: Boolean) {
|
||||
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
@@ -233,6 +259,13 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning from LockActivity — skip the elapsed check and reset state.
|
||||
if (isLocked) {
|
||||
isLocked = false
|
||||
pauseTime = 0L
|
||||
resetAutolockTimer()
|
||||
return
|
||||
}
|
||||
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||
// QrScannerActivity was in the foreground), lock immediately.
|
||||
if (pauseTime > 0L) {
|
||||
@@ -240,8 +273,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L)
|
||||
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null
|
||||
if (timeout > 0L && elapsed >= timeout && securitySet) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
||||
finish()
|
||||
lock()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -312,8 +344,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.action_lock) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
||||
finish()
|
||||
lock()
|
||||
return true
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
@@ -323,9 +354,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
fun relogin() {
|
||||
val store = CredentialStore(this)
|
||||
val hasMib = store.hasMibCredentials()
|
||||
val hasBml = store.hasBmlCredentials()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val hasFahipay = store.hasFahipayCredentials()
|
||||
if (!hasMib && !hasBml && !hasFahipay) {
|
||||
if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
@@ -334,24 +365,26 @@ class HomeActivity : AppCompatActivity() {
|
||||
val current = viewModel.accounts.value ?: emptyList()
|
||||
viewModel.accounts.value = current.filter { acc ->
|
||||
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false
|
||||
if (!hasBml && acc.profileType.startsWith("BML")) return@filter false
|
||||
if (acc.profileType.startsWith("BML")) {
|
||||
val loginId = acc.loginTag.removePrefix("bml_")
|
||||
return@filter loginId in bmlLoginIds
|
||||
}
|
||||
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
|
||||
true
|
||||
}
|
||||
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store)
|
||||
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
|
||||
}
|
||||
|
||||
private fun autoRefresh(
|
||||
mibCreds: CredentialStore.MibCredentials?,
|
||||
bmlCreds: CredentialStore.BmlCredentials?,
|
||||
fahipayCreds: CredentialStore.FahipayCredentials?,
|
||||
store: CredentialStore
|
||||
) {
|
||||
if (mibCreds == null && bmlCreds == null && fahipayCreds == null) return
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
if (mibCreds == null && bmlLoginIds.isEmpty() && fahipayCreds == null) return
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
// MIB and BML login run in parallel
|
||||
val mibJob = mibCreds?.let {
|
||||
async(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -367,39 +400,36 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val bmlJob = bmlCreds?.let {
|
||||
async(Dispatchers.IO) {
|
||||
// One async job per BML login, all run in parallel
|
||||
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
||||
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
||||
loginId to async(Dispatchers.IO) {
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
val savedToken = store.loadBmlSession()
|
||||
val loginTag = "bml_$loginId"
|
||||
val savedToken = store.loadBmlSession(loginId)
|
||||
|
||||
// Try cached token first
|
||||
if (savedToken != null) {
|
||||
try {
|
||||
val session = BmlSession(savedToken.first, savedToken.second)
|
||||
val accounts = bmlFlow.fetchAccounts(session)
|
||||
val accounts = bmlFlow.fetchAccounts(session, loginTag)
|
||||
val app = application as BasedBankApp
|
||||
app.bmlSession = session
|
||||
app.bmlAccounts = accounts
|
||||
AccountCache.saveBml(this@HomeActivity, accounts)
|
||||
app.bmlSessions[loginId] = session
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||
return@async Pair(session, accounts)
|
||||
} catch (_: AuthExpiredException) {
|
||||
// Token expired — fall through to full login
|
||||
} catch (_: Exception) {
|
||||
// Network or other error — fall through to full login
|
||||
}
|
||||
}
|
||||
|
||||
// Full login (token missing or expired)
|
||||
try {
|
||||
val (session, accounts) = bmlFlow.login(it.username, it.password, it.otpSeed)
|
||||
store.saveBmlSession(session.accessToken, session.deviceId)
|
||||
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
|
||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
||||
val app = application as BasedBankApp
|
||||
app.bmlSession = session
|
||||
app.bmlAccounts = accounts
|
||||
AccountCache.saveBml(this@HomeActivity, accounts)
|
||||
app.bmlSessions[loginId] = session
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||
Pair(session, accounts)
|
||||
} catch (_: Exception) {
|
||||
Pair(null, AccountCache.loadBml(this@HomeActivity))
|
||||
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,7 +439,6 @@ class HomeActivity : AppCompatActivity() {
|
||||
val fahipayFlow = FahipayLoginFlow()
|
||||
val deviceUuid = store.getOrCreateFahipayDeviceUuid()
|
||||
|
||||
// Try cached session first
|
||||
val savedSession = store.loadFahipaySession()
|
||||
if (savedSession != null) {
|
||||
try {
|
||||
@@ -425,15 +454,12 @@ class HomeActivity : AppCompatActivity() {
|
||||
AccountCache.saveFahipay(this@HomeActivity, accounts)
|
||||
return@async Pair(session, accounts)
|
||||
} catch (_: Exception) {
|
||||
// Session expired — fall through to full login
|
||||
}
|
||||
}
|
||||
|
||||
// Full re-login (only works if user has no 2FA, or 2FA was skipped)
|
||||
try {
|
||||
val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid)
|
||||
if (step.twoFactorRequired) {
|
||||
// Can't auto-complete 2FA — use cached data
|
||||
return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
|
||||
}
|
||||
val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
|
||||
@@ -456,14 +482,16 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity)
|
||||
val (bmlSession, bmlAccounts) = bmlJob?.await() ?: Pair(null, AccountCache.loadBml(this@HomeActivity))
|
||||
val bmlResults = bmlJobs.map { (_, job) -> job.await() }
|
||||
val bmlAccounts = bmlResults.flatMap { it.second }
|
||||
val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity))
|
||||
|
||||
val app = application as BasedBankApp
|
||||
app.bmlAccounts = bmlAccounts
|
||||
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts)
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
|
||||
val app = application as BasedBankApp
|
||||
if (bmlSession != null) refreshBmlLimits(bmlSession)
|
||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||
refreshFinancing(app.mibSession, app.mibProfiles)
|
||||
}
|
||||
}
|
||||
@@ -486,16 +514,21 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun refreshBmlContacts(app: BasedBankApp) {
|
||||
val session = app.bmlSession ?: return
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
if (app.bmlSessions.isEmpty()) return
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val bmlContacts = withContext(Dispatchers.IO) { bmlFlow.fetchContacts(session) }
|
||||
if (bmlContacts.isNotEmpty()) {
|
||||
ContactsCache.saveBml(this@HomeActivity, bmlContacts)
|
||||
val allBmlContacts = withContext(Dispatchers.IO) {
|
||||
app.bmlSessions.flatMap { (loginId, session) ->
|
||||
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
|
||||
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
|
||||
contacts
|
||||
}
|
||||
}
|
||||
if (allBmlContacts.isNotEmpty()) {
|
||||
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
|
||||
val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
|
||||
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
|
||||
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), fahipayContacts))
|
||||
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, allBmlContacts), fahipayContacts))
|
||||
}
|
||||
} catch (_: Exception) { /* keep cached */ }
|
||||
}
|
||||
@@ -503,10 +536,11 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
fun loadAllContacts() {
|
||||
val app = application as BasedBankApp
|
||||
val store = sh.sar.basedbank.util.CredentialStore(this)
|
||||
// Populate ViewModel from cache immediately if empty
|
||||
if (viewModel.contacts.value.isNullOrEmpty()) {
|
||||
val cached = mergeContacts(
|
||||
mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this)),
|
||||
mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this, store.getBmlLoginIds())),
|
||||
ContactsCache.loadFahipay(this)
|
||||
)
|
||||
if (cached.isNotEmpty()) viewModel.contacts.value = cached
|
||||
@@ -534,7 +568,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) }
|
||||
ContactsCache.saveFahipay(this@HomeActivity, contacts, categories)
|
||||
val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
|
||||
val bmlContacts = ContactsCache.loadBml(this@HomeActivity)
|
||||
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
|
||||
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
|
||||
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), contacts))
|
||||
val mibCategories = ContactsCache.loadCategories(this@HomeActivity)
|
||||
viewModel.contactCategories.postValue(mibCategories + categories)
|
||||
@@ -580,7 +615,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
if (allContacts.isNotEmpty()) {
|
||||
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
|
||||
val bmlContacts = ContactsCache.loadBml(this@HomeActivity)
|
||||
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
|
||||
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
|
||||
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
|
||||
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
|
||||
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
|
||||
@@ -612,17 +648,19 @@ class HomeActivity : AppCompatActivity() {
|
||||
val others = current.filter { it.profileType != "FAHIPAY" }
|
||||
viewModel.accounts.postValue(others + fresh)
|
||||
} else if (src.profileType.startsWith("BML")) {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.bmlSession ?: return@withContext null
|
||||
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
||||
try {
|
||||
val accounts = BmlLoginFlow().fetchAccounts(sess)
|
||||
AccountCache.saveBml(this@HomeActivity, accounts)
|
||||
app.bmlAccounts = accounts
|
||||
val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag)
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
|
||||
app.bmlAccounts = otherBml + accounts
|
||||
accounts
|
||||
} catch (_: Exception) { null }
|
||||
} ?: return@launch
|
||||
val mibOnly = current.filter { !it.profileType.startsWith("BML") }
|
||||
viewModel.accounts.postValue(mibOnly + fresh)
|
||||
val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag }
|
||||
viewModel.accounts.postValue(otherAccounts + fresh)
|
||||
} else {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.mibSession ?: return@withContext null
|
||||
|
||||
@@ -75,9 +75,10 @@ class OtpFragment : Fragment() {
|
||||
val name = store.loadMibFullName()
|
||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||
}
|
||||
store.loadBmlCredentials()?.let { creds ->
|
||||
val name = store.loadBmlFullName()
|
||||
entries.add(OtpEntry(if (name != null) "BML · $name" else "BML", creds.otpSeed))
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
||||
}
|
||||
|
||||
val adapter = OtpAdapter(entries)
|
||||
@@ -105,13 +106,14 @@ class OtpFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (store.loadBmlFullName() == null) {
|
||||
app.bmlSession?.let { session ->
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
|
||||
val session = app.bmlSessions[loginId] ?: continue
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
|
||||
}
|
||||
if (info != null) {
|
||||
store.saveBmlUserProfile(CredentialStore.BmlUserProfile(
|
||||
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
|
||||
fullName = info.fullName,
|
||||
email = info.email,
|
||||
mobile = info.mobile,
|
||||
@@ -119,7 +121,8 @@ class OtpFragment : Fragment() {
|
||||
idCard = info.idCard,
|
||||
birthdate = info.birthdate
|
||||
))
|
||||
val idx = entries.indexOfFirst { it.seed == store.loadBmlCredentials()?.otpSeed }
|
||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||
@@ -84,8 +87,23 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
// Black camera background — always use light (white) system bar icons
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = false
|
||||
}
|
||||
val originalBtnMarginBottom = (48 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.btnContainer) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
(view.layoutParams as android.widget.FrameLayout.LayoutParams).also {
|
||||
it.bottomMargin = originalBtnMarginBottom + bars.bottom
|
||||
view.layoutParams = it
|
||||
}
|
||||
insets
|
||||
}
|
||||
binding.btnCancel.setOnClickListener { finish() }
|
||||
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
||||
|
||||
|
||||
@@ -59,10 +59,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
container.removeAllViews()
|
||||
|
||||
val hasMib = store.hasMibCredentials()
|
||||
val hasBml = store.hasBmlCredentials()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val hasFahipay = store.hasFahipayCredentials()
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (hasMib || hasBml || hasFahipay) View.VISIBLE else View.GONE
|
||||
binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) View.VISIBLE else View.GONE
|
||||
|
||||
if (hasMib) {
|
||||
val profile = store.loadMibUserProfile()
|
||||
@@ -86,10 +86,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBml) {
|
||||
val profile = store.loadBmlUserProfile()
|
||||
for (loginId in bmlLoginIds) {
|
||||
val profile = store.loadBmlUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||
val profileNames = AccountCache.loadBml(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
||||
val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||
showLoginDetails(
|
||||
title = getString(R.string.bml_name),
|
||||
@@ -105,7 +105,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
profileNames.forEach { appendLine(" • $it") }
|
||||
}
|
||||
}.trim(),
|
||||
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store) } }
|
||||
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -183,11 +183,12 @@ class SettingsLoginsFragment : Fragment() {
|
||||
buildLoginsSection()
|
||||
}
|
||||
|
||||
private fun logoutBml(store: CredentialStore) {
|
||||
private fun logoutBml(store: CredentialStore, loginId: String) {
|
||||
val ctx = requireContext()
|
||||
store.clearBmlCredentials(); store.clearBmlSession()
|
||||
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.bmlSession = null; app.bmlAccounts = emptyList()
|
||||
app.bmlSessions.remove(loginId)
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
|
||||
@@ -35,8 +35,6 @@ class SettingsSecurityFragment : Fragment() {
|
||||
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
if (canUseBiometrics) {
|
||||
binding.rowBiometrics.visibility = View.VISIBLE
|
||||
|
||||
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
binding.switchBiometrics.isChecked = unlockEnabled
|
||||
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
@@ -54,6 +52,10 @@ class SettingsSecurityFragment : Fragment() {
|
||||
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||
}
|
||||
} else {
|
||||
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||
binding.switchBiometrics.isEnabled = false
|
||||
binding.switchBiometricsTransfer.isEnabled = false
|
||||
}
|
||||
|
||||
// Auto-lock
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -63,7 +64,9 @@ class TransferFragment : Fragment() {
|
||||
|
||||
private var selectedAccount: MibAccount? = null
|
||||
private val session get() = (requireActivity().application as BasedBankApp).mibSession
|
||||
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession
|
||||
private fun bmlSessionFor(account: MibAccount?) =
|
||||
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
|
||||
?: (requireActivity().application as BasedBankApp).anyBmlSession()
|
||||
|
||||
// Resolved recipient info — set after successful lookup or prefill
|
||||
private var resolvedAccountNumber = ""
|
||||
@@ -93,6 +96,11 @@ class TransferFragment : Fragment() {
|
||||
private const val ARG_SUBTITLE = "contact_subtitle"
|
||||
private const val ARG_COLOR = "contact_color"
|
||||
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||
|
||||
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||
}
|
||||
|
||||
fun newInstance(
|
||||
accountNumber: String,
|
||||
@@ -152,6 +160,22 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLookupLoading() {
|
||||
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||
setStyle(CircularProgressDrawable.DEFAULT)
|
||||
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||
start()
|
||||
}
|
||||
binding.tilTo.endIconDrawable = spinner
|
||||
binding.tilTo.isEnabled = false
|
||||
}
|
||||
|
||||
private fun stopLookupLoading() {
|
||||
binding.tilTo.isEnabled = true
|
||||
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||
}
|
||||
|
||||
private fun setupFromDropdown() {
|
||||
binding.btnClearFromInfo.setOnClickListener {
|
||||
selectedAccount = null
|
||||
@@ -171,6 +195,16 @@ class TransferFragment : Fragment() {
|
||||
updateAmountPrefix(picked)
|
||||
showFromCard(picked)
|
||||
}
|
||||
|
||||
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||
if (fromNumber != null && selectedAccount == null) {
|
||||
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
||||
if (match != null) {
|
||||
selectedAccount = match
|
||||
updateAmountPrefix(match)
|
||||
showFromCard(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +222,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
val typeLabel = when {
|
||||
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
||||
account.profileType == "BML_CREDIT" -> "Credit Card"
|
||||
account.accountTypeName.isNotBlank() -> account.accountTypeName
|
||||
else -> account.profileType
|
||||
}
|
||||
@@ -256,7 +291,7 @@ class TransferFragment : Fragment() {
|
||||
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val accountNumber = binding.etTo.text?.toString()?.trim() ?: ""
|
||||
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
|
||||
if (accountNumber.isBlank()) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -287,7 +322,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
val mibSess = session
|
||||
val bmlSess = bmlSession
|
||||
val bmlSess = bmlSessionFor(selectedAccount)
|
||||
if (mibSess == null && bmlSess == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -295,7 +330,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
|
||||
|
||||
binding.tilTo.isEnabled = false
|
||||
startLookupLoading()
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
var errorMsg: String? = null
|
||||
@@ -339,7 +374,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.tilTo.isEnabled = true
|
||||
stopLookupLoading()
|
||||
if (info != null) {
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
|
||||
@@ -374,7 +409,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun lookupFahipayTarget(number: String) {
|
||||
binding.tilTo.isEnabled = false
|
||||
startLookupLoading()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
data class LookupResult(
|
||||
val dhiraagu: DhiraaguClient.Result,
|
||||
@@ -401,7 +436,7 @@ class TransferFragment : Fragment() {
|
||||
LookupResult(d, o)
|
||||
}
|
||||
}
|
||||
binding.tilTo.isEnabled = true
|
||||
stopLookupLoading()
|
||||
|
||||
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -533,7 +568,7 @@ class TransferFragment : Fragment() {
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
||||
|
||||
val isSrcBml = src.profileType.startsWith("BML")
|
||||
val isSrcCard = src.profileType == "BML_PREPAID"
|
||||
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
|
||||
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||
val currency = src.currencyName.ifBlank { "MVR" }
|
||||
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||
@@ -562,11 +597,13 @@ class TransferFragment : Fragment() {
|
||||
?.transferCyDesc?.ifBlank { "MVR" }
|
||||
?: if (isDestMib) "MVR" else "MVR"
|
||||
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
|
||||
val isSrcCredit = src.profileType == "BML_CREDIT"
|
||||
|
||||
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
||||
|
||||
val doTransfer: () -> Unit = {
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||
if (!isSrcBml) {
|
||||
@@ -576,6 +613,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
binding.btnTransfer.isEnabled = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
if (ok && receipt != null) {
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
@@ -592,7 +630,7 @@ class TransferFragment : Fragment() {
|
||||
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
|
||||
if (isUsdToMvr) {
|
||||
if (isUsdToMvr || isSrcCredit) {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
val container = LinearLayout(ctx).apply {
|
||||
@@ -600,13 +638,24 @@ class TransferFragment : Fragment() {
|
||||
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
|
||||
}
|
||||
container.addView(TextView(ctx).apply { text = mainMsg })
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
|
||||
setTextColor(Color.RED)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||
})
|
||||
if (isUsdToMvr) {
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
|
||||
setTextColor(Color.RED)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||
})
|
||||
}
|
||||
if (isSrcCredit) {
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
|
||||
setTextColor(Color.RED)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||
})
|
||||
}
|
||||
dialogBuilder.setView(container)
|
||||
} else {
|
||||
dialogBuilder.setMessage(mainMsg)
|
||||
@@ -726,8 +775,9 @@ class TransferFragment : Fragment() {
|
||||
allAccounts: List<MibAccount>,
|
||||
allContacts: List<MibBeneficiary>
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val sess = bmlSession ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
||||
val otp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
||||
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: return Triple(false, "OTP unavailable", null)
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
@@ -735,7 +785,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
// Determine type + credit account
|
||||
val isDestMyCard = allAccounts.any { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount }
|
||||
val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
|
||||
val (transferType, creditAccount, bank) = when {
|
||||
isSrcCard -> {
|
||||
// CAD: card → own BML account
|
||||
@@ -744,7 +794,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
isDestMyCard -> {
|
||||
// CPA: BML CASA → own card top-up
|
||||
val card = allAccounts.first { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount }
|
||||
val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
|
||||
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
|
||||
}
|
||||
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||
@@ -767,7 +817,7 @@ class TransferFragment : Fragment() {
|
||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||
|
||||
// Step 2: confirm with fresh OTP
|
||||
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
|
||||
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) } ?: otp
|
||||
|
||||
return try {
|
||||
@@ -917,8 +967,8 @@ class TransferFragment : Fragment() {
|
||||
) : BaseAdapter(), Filterable {
|
||||
|
||||
private val items: List<Any> = buildList {
|
||||
val regular = accounts.filter { it.profileType != "BML_PREPAID" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" }
|
||||
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||
addAll(regular)
|
||||
if (cards.isNotEmpty()) {
|
||||
add(getString(R.string.cards))
|
||||
@@ -927,7 +977,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
|
||||
?.takeUnless { it.profileType == "BML_PREPAID" && !it.statusDesc.equals("Active", ignoreCase = true) }
|
||||
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
|
||||
|
||||
override fun getCount() = items.size
|
||||
override fun getItem(position: Int) = items[position]
|
||||
@@ -958,8 +1008,10 @@ class TransferFragment : Fragment() {
|
||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
.also { it.root.tag = it }
|
||||
}
|
||||
val inactive = acc.profileType == "BML_PREPAID" && !acc.statusDesc.equals("Active", ignoreCase = true)
|
||||
b.tvDropdownAccountName.text = acc.accountBriefName
|
||||
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
||||
val isBmlAccount = acc.profileType.startsWith("BML")
|
||||
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.root.alpha = if (inactive) 0.4f else 1f
|
||||
|
||||
@@ -62,7 +62,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
) {
|
||||
fun hasMore(): Boolean = when {
|
||||
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
account.profileType == "BML_PREPAID" -> cardMonthOffset < 2
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
||||
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
}
|
||||
@@ -143,7 +143,6 @@ class TransferHistoryFragment : Fragment() {
|
||||
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val mibSession = app.mibSession
|
||||
val bmlSession = app.bmlSession
|
||||
|
||||
lifecycleScope.launch {
|
||||
val newTransactions = withContext(Dispatchers.IO) {
|
||||
@@ -155,8 +154,8 @@ class TransferHistoryFragment : Fragment() {
|
||||
async {
|
||||
try {
|
||||
when {
|
||||
state.account.profileType == "BML_PREPAID" -> {
|
||||
val session = bmlSession ?: return@async emptyList()
|
||||
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> {
|
||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
@@ -170,7 +169,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val session = bmlSession ?: return@async emptyList()
|
||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = state.account.internalId,
|
||||
|
||||
@@ -313,6 +313,12 @@ class TransferReceiptFragment : Fragment() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = "Receipt"
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -67,11 +67,27 @@ class CredentialsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||
|
||||
val loginFieldWatcher = object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
}
|
||||
binding.etUsername.addTextChangedListener(loginFieldWatcher)
|
||||
binding.etPassword.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateLoginButtonState(); updateOtpDisplay() }
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
if (bankType != "FAHIPAY") {
|
||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() }
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
updateOtpDisplay()
|
||||
updateLoginButtonState()
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
@@ -88,10 +104,35 @@ class CredentialsFragment : Fragment() {
|
||||
otpHandler.removeCallbacks(otpRunnable)
|
||||
}
|
||||
|
||||
private fun resolveOtpSeed(input: String): String {
|
||||
val secret = if (input.startsWith("otpauth://totp/"))
|
||||
android.net.Uri.parse(input).getQueryParameter("secret") ?: input
|
||||
else
|
||||
input
|
||||
return secret.replace("\\s".toRegex(), "").replace("-", "").uppercase()
|
||||
}
|
||||
|
||||
private fun updateLoginButtonState() {
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
val password = binding.etPassword.text.toString()
|
||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||
binding.btnLogin.isEnabled = when (bankType) {
|
||||
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateOtpDisplay() {
|
||||
val seed = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||
val seed = resolveOtpSeed(otpSeedRaw)
|
||||
if (seed.isEmpty()) {
|
||||
binding.cardOtp.visibility = View.GONE
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
val password = binding.etPassword.text.toString()
|
||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -104,7 +145,7 @@ class CredentialsFragment : Fragment() {
|
||||
binding.otpTimer.progress = remaining
|
||||
binding.cardOtp.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
binding.cardOtp.visibility = View.GONE
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +157,7 @@ class CredentialsFragment : Fragment() {
|
||||
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
val password = binding.etPassword.text.toString()
|
||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||
|
||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||
binding.tvError.text = "Please fill in all fields"
|
||||
@@ -173,7 +214,7 @@ class CredentialsFragment : Fragment() {
|
||||
private fun attemptBmlLogin() {
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
val password = binding.etPassword.text.toString()
|
||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||
|
||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||
binding.tvError.text = "Please fill in all fields"
|
||||
@@ -185,6 +226,7 @@ class CredentialsFragment : Fragment() {
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
|
||||
val loginId = username
|
||||
val flow = BmlLoginFlow()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
@@ -192,11 +234,12 @@ class CredentialsFragment : Fragment() {
|
||||
flow.login(username, password, otpSeed)
|
||||
}
|
||||
val store = CredentialStore(requireContext())
|
||||
store.saveBmlCredentials(username, password, otpSeed)
|
||||
store.saveBmlSession(session.accessToken, session.deviceId)
|
||||
store.saveBmlCredentials(loginId, username, password, otpSeed)
|
||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
||||
withContext(Dispatchers.IO) {
|
||||
val info = flow.fetchUserInfo(session)
|
||||
if (info != null) store.saveBmlUserProfile(
|
||||
loginId,
|
||||
CredentialStore.BmlUserProfile(
|
||||
fullName = info.fullName,
|
||||
email = info.email,
|
||||
@@ -207,11 +250,11 @@ class CredentialsFragment : Fragment() {
|
||||
)
|
||||
)
|
||||
}
|
||||
AccountCache.saveBml(requireContext(), accounts)
|
||||
AccountCache.saveBml(requireContext(), loginId, accounts)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.bmlSession = session
|
||||
app.bmlAccounts = accounts
|
||||
// Merge with any existing MIB accounts already in app
|
||||
app.bmlSessions[loginId] = session
|
||||
// Merge with any existing BML accounts from other logins
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
|
||||
app.accounts = app.accounts + accounts
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
@@ -10,7 +12,13 @@ class LoginActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package sh.sar.basedbank.ui.onboarding
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.os.CountDownTimer
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import sh.sar.basedbank.R
|
||||
@@ -17,46 +21,68 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||
|
||||
private lateinit var binding: ActivityOnboardingBinding
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private var countDownTimer: CountDownTimer? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
val adapter = OnboardingPagerAdapter(this)
|
||||
binding.viewPager.adapter = adapter
|
||||
|
||||
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
|
||||
|
||||
// Pre-select language chip without triggering the listener
|
||||
val savedLang = prefs.getString("language", null)
|
||||
binding.languageChipGroup.setOnCheckedStateChangeListener(null)
|
||||
when (savedLang) {
|
||||
"en" -> binding.chipEnglish.isChecked = true
|
||||
"dv" -> binding.chipDhivehi.isChecked = true
|
||||
}
|
||||
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||
if (checkedIds.isNotEmpty()) {
|
||||
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
|
||||
// Disable tap-to-navigate on dots: touch listener must be on the individual
|
||||
// tab views inside SlidingTabStrip (child 0), because they consume ACTION_DOWN
|
||||
// before the TabLayout's own touch listener ever fires.
|
||||
val tabStrip = binding.dotsIndicator.getChildAt(0) as? android.view.ViewGroup
|
||||
tabStrip?.let {
|
||||
for (i in 0 until it.childCount) {
|
||||
it.getChildAt(i).setOnTouchListener { _, _ -> true }
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-select language button without triggering the listener
|
||||
val savedLang = prefs.getString("language", null)
|
||||
binding.languageToggle.clearOnButtonCheckedListeners()
|
||||
when (savedLang) {
|
||||
"en" -> binding.btnLangEnglish.isChecked = true
|
||||
"dv" -> binding.btnLangDhivehi.isChecked = true
|
||||
}
|
||||
binding.languageToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (isChecked) selectLanguage(if (checkedId == R.id.btnLangEnglish) "en" else "dv")
|
||||
}
|
||||
|
||||
supportFragmentManager.setFragmentResultListener(OnboardingFragment.RESULT_SCROLLED_TO_BOTTOM, this) { _, _ ->
|
||||
startGetStartedCountdown()
|
||||
}
|
||||
|
||||
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE
|
||||
// Block forward swipe on slide 1 until security is set up
|
||||
if (position == 1) {
|
||||
binding.viewPager.isUserInputEnabled =
|
||||
prefs.getString("security_method", null) != null
|
||||
} else {
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
binding.languageSection.visibility = if (position == 0) View.VISIBLE else View.GONE
|
||||
binding.viewPager.isUserInputEnabled = when {
|
||||
position > 2 -> false
|
||||
position == 1 -> prefs.getString("security_method", null) != null
|
||||
else -> true
|
||||
}
|
||||
updateButtons(position, adapter.itemCount)
|
||||
}
|
||||
})
|
||||
|
||||
binding.languageChipGroup.visibility = View.VISIBLE
|
||||
binding.languageSection.visibility = View.VISIBLE
|
||||
updateButtons(0, adapter.itemCount)
|
||||
|
||||
binding.btnNext.setOnClickListener {
|
||||
@@ -71,10 +97,21 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
countDownTimer?.cancel()
|
||||
}
|
||||
|
||||
// Called by SecuritySetupFragment when setup is complete
|
||||
override fun onSecuritySetupComplete() {
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
|
||||
}
|
||||
|
||||
// Called by SecuritySetupFragment when user resets to reconfigure
|
||||
override fun onSecuritySetupReset() {
|
||||
binding.viewPager.isUserInputEnabled = false
|
||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
|
||||
}
|
||||
|
||||
private fun selectLanguage(lang: String) {
|
||||
@@ -83,23 +120,34 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
||||
}
|
||||
|
||||
private fun startGetStartedCountdown() {
|
||||
binding.btnGetStarted.isEnabled = false
|
||||
countDownTimer?.cancel()
|
||||
countDownTimer = object : CountDownTimer(5000, 1000) {
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
val seconds = (millisUntilFinished / 1000 + 1).toInt()
|
||||
binding.btnGetStarted.text = "${getString(R.string.get_started)} ($seconds)"
|
||||
}
|
||||
override fun onFinish() {
|
||||
binding.btnGetStarted.text = getString(R.string.get_started)
|
||||
binding.btnGetStarted.isEnabled = true
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun updateButtons(position: Int, count: Int) {
|
||||
val langSelected = prefs.getString("language", null) != null
|
||||
val securityDone = prefs.getString("security_method", null) != null
|
||||
val isLast = position == count - 1
|
||||
|
||||
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
|
||||
if (isLast) binding.btnGetStarted.isEnabled = false
|
||||
|
||||
// Hide Next on slide 1 until security is done (avoids a disabled-button-with-no-explanation)
|
||||
binding.btnNext.visibility = when {
|
||||
isLast -> View.GONE
|
||||
position == 1 && !securityDone -> View.GONE
|
||||
else -> View.VISIBLE
|
||||
}
|
||||
binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
|
||||
binding.btnNext.isEnabled = when (position) {
|
||||
0 -> langSelected
|
||||
1 -> securityDone
|
||||
else -> true
|
||||
else -> true // position 2 (configure) has no gate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package sh.sar.basedbank.ui.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||
|
||||
class OnboardingConfigureFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentOnboardingConfigureBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentOnboardingConfigureBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
|
||||
// Navigation — default Drawer
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||
}
|
||||
|
||||
// Theme — default System
|
||||
val savedTheme = prefs.getString("theme", "system")
|
||||
binding.themeToggle.check(when (savedTheme) {
|
||||
"light" -> R.id.btnThemeLight
|
||||
"dark" -> R.id.btnThemeDark
|
||||
else -> R.id.btnThemeSystem
|
||||
})
|
||||
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
val (key, mode) = when (checkedId) {
|
||||
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
prefs.edit().putString("theme", key).apply()
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
}
|
||||
|
||||
// Biometrics
|
||||
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
if (canUseBiometrics) {
|
||||
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
binding.switchBiometrics.isChecked = unlockEnabled
|
||||
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
|
||||
|
||||
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
|
||||
binding.switchBiometricsTransfer.isEnabled = isChecked
|
||||
if (!isChecked) {
|
||||
binding.switchBiometricsTransfer.isChecked = false
|
||||
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
|
||||
}
|
||||
}
|
||||
|
||||
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||
}
|
||||
} else {
|
||||
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||
binding.switchBiometrics.isEnabled = false
|
||||
binding.switchBiometricsTransfer.isEnabled = false
|
||||
}
|
||||
|
||||
// Block screenshots — default on
|
||||
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||
applyFlagSecure(blockScreenshots)
|
||||
binding.switchBlockScreenshots.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("block_screenshots", isChecked).apply()
|
||||
applyFlagSecure(isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFlagSecure(enabled: Boolean) {
|
||||
val win = activity?.window ?: return
|
||||
if (enabled) win.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||
else win.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.ScrollView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
|
||||
@@ -12,6 +14,7 @@ class OnboardingFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentOnboardingSlideBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var scrolledToBottom = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
|
||||
@@ -19,17 +22,45 @@ class OnboardingFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val title = requireArguments().getString(ARG_TITLE, "")
|
||||
val desc = requireArguments().getString(ARG_DESC, "")
|
||||
val titleRes = requireArguments().getInt(ARG_TITLE)
|
||||
val descRes = requireArguments().getInt(ARG_DESC)
|
||||
val icon = requireArguments().getInt(ARG_ICON, 0)
|
||||
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
|
||||
val isLast = requireArguments().getBoolean(ARG_IS_LAST, false)
|
||||
|
||||
binding.icon.visibility = if (isLast) View.GONE else View.VISIBLE
|
||||
binding.icon.setImageResource(icon)
|
||||
binding.title.text = title
|
||||
binding.description.text = desc
|
||||
binding.title.text = getString(titleRes)
|
||||
binding.description.text = getString(descRes)
|
||||
binding.description.gravity = if (isLast) android.view.Gravity.START else android.view.Gravity.CENTER
|
||||
|
||||
// On the first slide, show the two placeholder cards for upcoming banks
|
||||
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
|
||||
|
||||
if (isLast) setupScrollToBottomDetection()
|
||||
}
|
||||
|
||||
private fun setupScrollToBottomDetection() {
|
||||
val scrollView = binding.scrollView
|
||||
// If content fits without scrolling, fire immediately after layout
|
||||
scrollView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
scrollView.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val child = scrollView.getChildAt(0) ?: return
|
||||
if (child.height <= scrollView.height) notifyScrolledToBottom()
|
||||
}
|
||||
})
|
||||
scrollView.setOnScrollChangeListener { v, _, scrollY, _, _ ->
|
||||
val sv = v as ScrollView
|
||||
val child = sv.getChildAt(0) ?: return@setOnScrollChangeListener
|
||||
if (scrollY + sv.height >= child.height) notifyScrolledToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyScrolledToBottom() {
|
||||
if (scrolledToBottom) return
|
||||
scrolledToBottom = true
|
||||
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -38,17 +69,20 @@ class OnboardingFragment : Fragment() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RESULT_SCROLLED_TO_BOTTOM = "scroll_to_bottom"
|
||||
private const val ARG_TITLE = "title"
|
||||
private const val ARG_DESC = "desc"
|
||||
private const val ARG_ICON = "icon"
|
||||
private const val ARG_IS_FIRST = "is_first"
|
||||
private const val ARG_IS_LAST = "is_last"
|
||||
|
||||
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
|
||||
arguments = bundleOf(
|
||||
ARG_TITLE to slide.title,
|
||||
ARG_DESC to slide.description,
|
||||
ARG_TITLE to slide.titleRes,
|
||||
ARG_DESC to slide.descRes,
|
||||
ARG_ICON to slide.iconRes,
|
||||
ARG_IS_FIRST to slide.isFirst
|
||||
ARG_IS_FIRST to slide.isFirst,
|
||||
ARG_IS_LAST to slide.isLast
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,36 +9,39 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
|
||||
|
||||
private val slides = listOf(
|
||||
OnboardingSlide(
|
||||
title = activity.getString(R.string.onboarding_title_1),
|
||||
description = activity.getString(R.string.onboarding_desc_1),
|
||||
titleRes = R.string.onboarding_title_1,
|
||||
descRes = R.string.onboarding_desc_1,
|
||||
iconRes = R.drawable.ic_launcher_foreground,
|
||||
isFirst = true
|
||||
),
|
||||
OnboardingSlide(
|
||||
title = activity.getString(R.string.onboarding_title_2),
|
||||
description = activity.getString(R.string.onboarding_desc_2),
|
||||
titleRes = R.string.onboarding_title_2,
|
||||
descRes = R.string.onboarding_desc_2,
|
||||
iconRes = R.drawable.ic_launcher_foreground,
|
||||
isFirst = false
|
||||
),
|
||||
OnboardingSlide(
|
||||
title = activity.getString(R.string.onboarding_title_3),
|
||||
description = activity.getString(R.string.onboarding_desc_3),
|
||||
titleRes = R.string.onboarding_title_3,
|
||||
descRes = R.string.onboarding_desc_3,
|
||||
iconRes = R.drawable.ic_launcher_foreground,
|
||||
isFirst = false
|
||||
isFirst = false,
|
||||
isLast = true
|
||||
)
|
||||
)
|
||||
|
||||
override fun getItemCount() = slides.size
|
||||
override fun getItemCount() = slides.size + 1 // +1 for OnboardingConfigureFragment at position 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
1 -> SecuritySetupFragment()
|
||||
else -> OnboardingFragment.newInstance(slides[position])
|
||||
2 -> OnboardingConfigureFragment()
|
||||
else -> OnboardingFragment.newInstance(slides[position - if (position > 2) 1 else 0])
|
||||
}
|
||||
}
|
||||
|
||||
data class OnboardingSlide(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val titleRes: Int,
|
||||
val descRes: Int,
|
||||
val iconRes: Int,
|
||||
val isFirst: Boolean
|
||||
val isFirst: Boolean,
|
||||
val isLast: Boolean = false
|
||||
)
|
||||
|
||||
@@ -86,17 +86,21 @@ class PatternView @JvmOverloads constructor(
|
||||
if (errorState) return false
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
recording = true; selected.clear()
|
||||
hit(event.x, event.y)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
touchX = event.x; touchY = event.y
|
||||
hit(event.x, event.y)
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
parent?.requestDisallowInterceptTouchEvent(false)
|
||||
recording = false
|
||||
invalidate()
|
||||
onPatternComplete?.invoke(selected.map { it.index })
|
||||
if (event.action == MotionEvent.ACTION_UP)
|
||||
onPatternComplete?.invoke(selected.map { it.index })
|
||||
}
|
||||
}
|
||||
invalidate()
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import sh.sar.basedbank.R
|
||||
@@ -21,6 +20,7 @@ class SecuritySetupFragment : Fragment() {
|
||||
|
||||
interface Callback {
|
||||
fun onSecuritySetupComplete()
|
||||
fun onSecuritySetupReset()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -33,7 +33,7 @@ class SecuritySetupFragment : Fragment() {
|
||||
private var _b: FragmentSecuritySetupBinding? = null
|
||||
private val b get() = _b!!
|
||||
|
||||
private enum class Step { CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM, BIOMETRIC }
|
||||
private enum class Step { CONFIGURED, CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM }
|
||||
|
||||
private var step = Step.CHOOSE
|
||||
private val pinDigits = mutableListOf<Int>()
|
||||
@@ -48,9 +48,6 @@ class SecuritySetupFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false
|
||||
if (!changeMode && prefs.getString("security_method", null) != null) {
|
||||
(activity as? Callback)?.onSecuritySetupComplete()
|
||||
}
|
||||
|
||||
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
|
||||
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
|
||||
@@ -62,20 +59,21 @@ class SecuritySetupFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
|
||||
b.btnChangeLock.setOnClickListener {
|
||||
prefs.edit().remove("security_method").apply()
|
||||
(activity as? Callback)?.onSecuritySetupReset()
|
||||
goTo(Step.CHOOSE)
|
||||
}
|
||||
|
||||
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) }
|
||||
|
||||
b.btnEnableBiometrics.setOnClickListener {
|
||||
prefs.edit().putBoolean("biometrics_enabled", true).apply()
|
||||
finishSetup()
|
||||
}
|
||||
b.btnSkipBiometrics.setOnClickListener {
|
||||
prefs.edit().putBoolean("biometrics_enabled", false).apply()
|
||||
finishSetup()
|
||||
}
|
||||
|
||||
buildNumpad()
|
||||
goTo(Step.CHOOSE)
|
||||
|
||||
if (!changeMode && prefs.getString("security_method", null) != null) {
|
||||
goTo(Step.CONFIGURED)
|
||||
} else {
|
||||
goTo(Step.CHOOSE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNumpad() {
|
||||
@@ -144,7 +142,7 @@ class SecuritySetupFragment : Fragment() {
|
||||
Step.PIN_CONFIRM -> {
|
||||
if (entered == firstPin) {
|
||||
saveCredential("pin", entered)
|
||||
goToBiometricOrFinish()
|
||||
finishSetup()
|
||||
} else {
|
||||
b.tvPinDots.text = getString(R.string.pin_no_match)
|
||||
pinDigits.clear()
|
||||
@@ -172,7 +170,7 @@ class SecuritySetupFragment : Fragment() {
|
||||
Step.PATTERN_CONFIRM -> {
|
||||
if (pattern == firstPattern) {
|
||||
saveCredential("pattern", pattern.joinToString(""))
|
||||
goToBiometricOrFinish()
|
||||
finishSetup()
|
||||
} else {
|
||||
b.patternView.showError()
|
||||
b.tvPatternStatus.text = getString(R.string.pattern_no_match)
|
||||
@@ -187,29 +185,12 @@ class SecuritySetupFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToBiometricOrFinish() {
|
||||
// In change mode, biometrics is managed from Settings — skip this step
|
||||
if (arguments?.getBoolean(ARG_CHANGE_MODE, false) == true) {
|
||||
finishSetup()
|
||||
return
|
||||
}
|
||||
val canAuth = BiometricManager.from(requireContext())
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
|
||||
if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
goTo(Step.BIOMETRIC)
|
||||
} else {
|
||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.edit().putBoolean("biometrics_enabled", false).apply()
|
||||
finishSetup()
|
||||
}
|
||||
}
|
||||
|
||||
private fun goTo(s: Step) {
|
||||
step = s
|
||||
b.viewConfigured.visibility = if (s == Step.CONFIGURED) View.VISIBLE else View.GONE
|
||||
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE
|
||||
b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE
|
||||
b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE
|
||||
b.viewBiometric.visibility = if (s == Step.BIOMETRIC) View.VISIBLE else View.GONE
|
||||
|
||||
when (s) {
|
||||
Step.PIN_ENTER -> {
|
||||
@@ -236,9 +217,6 @@ class SecuritySetupFragment : Fragment() {
|
||||
val hash = pbkdf2(input, salt)
|
||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
||||
.putString("security_method", method)
|
||||
// Remove legacy plaintext fields if they exist from an old install
|
||||
.remove("security_salt")
|
||||
.remove("security_hash")
|
||||
.apply()
|
||||
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
||||
}
|
||||
@@ -256,6 +234,7 @@ class SecuritySetupFragment : Fragment() {
|
||||
private fun finishSetup() {
|
||||
val cb = activity as? Callback
|
||||
if (cb != null) {
|
||||
goTo(Step.CONFIGURED)
|
||||
cb.onSecuritySetupComplete()
|
||||
} else {
|
||||
parentFragmentManager.popBackStack()
|
||||
|
||||
@@ -9,9 +9,10 @@ object AccountCache {
|
||||
|
||||
private const val PREFS = "account_cache"
|
||||
private const val KEY_MIB = "mib_accounts"
|
||||
private const val KEY_BML = "bml_accounts"
|
||||
private const val KEY_FAHIPAY = "fahipay_accounts"
|
||||
|
||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||
|
||||
fun save(context: Context, accounts: List<MibAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
@@ -36,7 +37,7 @@ object AccountCache {
|
||||
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun saveBml(context: Context, accounts: List<MibAccount>) {
|
||||
fun saveBml(context: Context, loginId: String, accounts: List<MibAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
@@ -56,15 +57,14 @@ object AccountCache {
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_BML, CacheEncryption.encrypt(arr.toString())).apply()
|
||||
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadBml(context: Context): List<MibAccount> {
|
||||
fun loadBml(context: Context, loginId: String): List<MibAccount> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_BML, null) ?: return emptyList()
|
||||
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val json = CacheEncryption.decrypt(raw)
|
||||
val arr = JSONArray(json)
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibAccount(
|
||||
@@ -84,9 +84,12 @@ object AccountCache {
|
||||
internalId = o.optString("internalId", "")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) { emptyList() }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
|
||||
loginIds.flatMap { loadBml(context, it) }
|
||||
|
||||
fun saveFahipay(context: Context, accounts: List<MibAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
|
||||
@@ -11,6 +11,21 @@ object AccountInputParser {
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip spaces and remove a 960/+960 country code prefix, but only when
|
||||
* the stripped result is exactly 7 digits (so "9603456" is left intact).
|
||||
*/
|
||||
fun normalize(input: String): String {
|
||||
var s = input.replace(" ", "")
|
||||
val stripped = when {
|
||||
s.startsWith("+960") -> s.removePrefix("+960")
|
||||
s.startsWith("960") -> s.removePrefix("960")
|
||||
else -> null
|
||||
}
|
||||
if (stripped != null && stripped.matches(Regex("^\\d{7}$"))) s = stripped
|
||||
return s
|
||||
}
|
||||
|
||||
fun detect(input: String): InputType {
|
||||
val s = input.trim()
|
||||
return when {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
object BmlDashboardParser {
|
||||
|
||||
/**
|
||||
* Returns a display-ready product label for a BML dashboard account or card.
|
||||
* Known BML product names are mapped to short friendly labels.
|
||||
* Everything else is title-cased (first letter of each word capitalised).
|
||||
*/
|
||||
fun productLabel(raw: String): String {
|
||||
val u = raw.trim().uppercase()
|
||||
return when {
|
||||
u == "SAVINGS ACCOUNT" -> "Savings"
|
||||
u == "CURRENT ACCOUNT" ||
|
||||
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
||||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
||||
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
||||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
||||
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
||||
else -> toTitleCase(raw)
|
||||
}
|
||||
}
|
||||
|
||||
fun toTitleCase(input: String): String =
|
||||
input.trim().lowercase().split(" ").joinToString(" ") { word ->
|
||||
word.replaceFirstChar { it.uppercaseChar() }
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,9 @@ object ContactsCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveBml(context: Context, contacts: List<MibBeneficiary>) {
|
||||
private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
|
||||
|
||||
fun saveBml(context: Context, loginId: String, contacts: List<MibBeneficiary>) {
|
||||
val arr = JSONArray()
|
||||
for (c in contacts) {
|
||||
arr.put(JSONObject().apply {
|
||||
@@ -99,18 +101,18 @@ object ContactsCache {
|
||||
put("benefStatus", c.benefStatus)
|
||||
put("transferCyDesc", c.transferCyDesc)
|
||||
put("benefCategoryId", c.benefCategoryId)
|
||||
put("profileId", c.profileId)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString("bml_contacts", CacheEncryption.encrypt(arr.toString())).apply()
|
||||
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadBml(context: Context): List<MibBeneficiary> {
|
||||
fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString("bml_contacts", null) ?: return emptyList()
|
||||
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val json = CacheEncryption.decrypt(raw)
|
||||
val arr = JSONArray(json)
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
MibBeneficiary(
|
||||
@@ -125,12 +127,16 @@ object ContactsCache {
|
||||
benefStatus = o.optString("benefStatus"),
|
||||
transferCyDesc = o.optString("transferCyDesc", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = o.optString("benefCategoryId", "BML")
|
||||
benefCategoryId = o.optString("benefCategoryId", "BML"),
|
||||
profileId = o.optString("profileId", "")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) { emptyList() }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadBml(context: Context, loginIds: List<String>): List<MibBeneficiary> =
|
||||
loginIds.flatMap { loadBml(context, it) }
|
||||
|
||||
fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
val arr = JSONArray()
|
||||
|
||||
@@ -24,7 +24,6 @@ class CredentialStore(context: Context) {
|
||||
// ── MIB login credentials ─────────────────────────────────────────────────
|
||||
|
||||
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
|
||||
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
|
||||
fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card")
|
||||
|
||||
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
|
||||
@@ -91,58 +90,84 @@ class CredentialStore(context: Context) {
|
||||
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ── BML login credentials ─────────────────────────────────────────────────
|
||||
// ── BML login credentials (multi-login, keyed by loginId = username) ────────
|
||||
|
||||
fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
|
||||
fun getBmlLoginIds(): List<String> {
|
||||
val json = prefs.getString("bml_login_ids", null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = org.json.JSONArray(json)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||
|
||||
private fun addBmlLoginId(loginId: String) {
|
||||
val ids = getBmlLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
ids.add(loginId)
|
||||
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeBmlLoginId(loginId: String) {
|
||||
val ids = getBmlLoginIds().toMutableList()
|
||||
if (ids.remove(loginId))
|
||||
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
|
||||
fun saveBmlCredentials(loginId: String, username: String, password: String, otpSeed: String) {
|
||||
addBmlLoginId(loginId)
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit()
|
||||
.putString("bml_enc_username", encrypt(username, key))
|
||||
.putString("bml_enc_password", encrypt(password, key))
|
||||
.putString("bml_enc_otp_seed", encrypt(otpSeed, key))
|
||||
.putString("bml_${loginId}_enc_username", encrypt(username, key))
|
||||
.putString("bml_${loginId}_enc_password", encrypt(password, key))
|
||||
.putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadBmlCredentials(): BmlCredentials? {
|
||||
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encUsername = prefs.getString("bml_enc_username", null) ?: return null
|
||||
val encPassword = prefs.getString("bml_enc_password", null) ?: return null
|
||||
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
|
||||
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||
val encPassword = prefs.getString("bml_${loginId}_enc_password", null) ?: return null
|
||||
val encSeed = prefs.getString("bml_${loginId}_enc_otp_seed", null) ?: return null
|
||||
return try {
|
||||
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun clearBmlCredentials() {
|
||||
fun clearBmlCredentials(loginId: String) {
|
||||
removeBmlLoginId(loginId)
|
||||
prefs.edit()
|
||||
.remove("bml_enc_username")
|
||||
.remove("bml_enc_password")
|
||||
.remove("bml_enc_otp_seed")
|
||||
.remove("bml_${loginId}_enc_username")
|
||||
.remove("bml_${loginId}_enc_password")
|
||||
.remove("bml_${loginId}_enc_otp_seed")
|
||||
.apply()
|
||||
}
|
||||
|
||||
// ── BML session token ─────────────────────────────────────────────────────
|
||||
// ── BML session token (per loginId) ───────────────────────────────────────
|
||||
|
||||
fun saveBmlSession(accessToken: String, deviceId: String) {
|
||||
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit()
|
||||
.putString("bml_enc_token", encrypt(accessToken, key))
|
||||
.putString("bml_enc_device_id", encrypt(deviceId, key))
|
||||
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
|
||||
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadBmlSession(): Pair<String, String>? {
|
||||
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
||||
val key = getOrCreateKey()
|
||||
val encToken = prefs.getString("bml_enc_token", null) ?: return null
|
||||
val encDeviceId = prefs.getString("bml_enc_device_id", null) ?: return null
|
||||
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
||||
val encDeviceId = prefs.getString("bml_${loginId}_enc_device_id", null) ?: return null
|
||||
return try {
|
||||
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun clearBmlSession() {
|
||||
fun clearBmlSession(loginId: String) {
|
||||
prefs.edit()
|
||||
.remove("bml_enc_token")
|
||||
.remove("bml_enc_device_id")
|
||||
.remove("bml_${loginId}_enc_token")
|
||||
.remove("bml_${loginId}_enc_device_id")
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -315,16 +340,6 @@ class CredentialStore(context: Context) {
|
||||
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveBmlFullName(name: String) {
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit().putString("bml_enc_full_name", encrypt(name, key)).apply()
|
||||
}
|
||||
|
||||
fun loadBmlFullName(): String? {
|
||||
val key = getOrCreateKey()
|
||||
val enc = prefs.getString("bml_enc_full_name", null) ?: return null
|
||||
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveMibUserProfile(p: MibUserProfile) {
|
||||
val json = JSONObject().apply {
|
||||
@@ -355,7 +370,7 @@ class CredentialStore(context: Context) {
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveBmlUserProfile(p: BmlUserProfile) {
|
||||
fun saveBmlUserProfile(loginId: String, p: BmlUserProfile) {
|
||||
val json = JSONObject().apply {
|
||||
put("fullName", p.fullName)
|
||||
put("email", p.email)
|
||||
@@ -365,13 +380,12 @@ class CredentialStore(context: Context) {
|
||||
put("birthdate", p.birthdate)
|
||||
}.toString()
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit().putString("bml_enc_profile", encrypt(json, key)).apply()
|
||||
prefs.edit().putString("bml_enc_full_name", encrypt(p.fullName, key)).apply()
|
||||
prefs.edit().putString("bml_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||
}
|
||||
|
||||
fun loadBmlUserProfile(): BmlUserProfile? {
|
||||
fun loadBmlUserProfile(loginId: String): BmlUserProfile? {
|
||||
val key = getOrCreateKey()
|
||||
val enc = prefs.getString("bml_enc_profile", null) ?: return null
|
||||
val enc = prefs.getString("bml_${loginId}_enc_profile", null) ?: return null
|
||||
return try {
|
||||
val o = JSONObject(decrypt(enc, key))
|
||||
BmlUserProfile(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
object MibAccountParser {
|
||||
|
||||
/**
|
||||
* Returns a display-ready product label for a MIB (Faisanet) account type name.
|
||||
* Known MIB accountTypeName values are mapped to short friendly labels.
|
||||
* Everything else is returned trimmed as-is.
|
||||
*/
|
||||
fun productLabel(raw: String): String {
|
||||
val u = raw.trim().uppercase()
|
||||
return when {
|
||||
u == "SAVING ACCOUNT" -> "Savings"
|
||||
u == "CURRENT ACCOUNT" -> "Current"
|
||||
else -> raw.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="#016FD0"
|
||||
android:pathData="M16.015 14.378c0-.32-.135-.496-.344-.622-.21-.12-.464-.135-.81-.135h-1.543v2.82h.675v-1.027h.72c.24 0 .39.024.478.125.12.13.104.38.104.55v.35h.66v-.555c-.002-.25-.017-.376-.108-.516-.06-.08-.18-.18-.33-.234l.02-.008c.18-.072.48-.297.48-.747zm-.87.407l-.028-.002c-.09.053-.195.058-.33.058h-.81v-.63h.824c.12 0 .24 0 .33.05.098.048.156.147.15.255 0 .12-.045.215-.134.27zM20.297 15.837H19v.6h1.304c.676 0 1.05-.278 1.05-.884 0-.28-.066-.448-.187-.582-.153-.133-.392-.193-.73-.207l-.376-.015c-.104 0-.18 0-.255-.03-.09-.03-.15-.105-.15-.21 0-.09.017-.166.09-.21.083-.046.177-.066.272-.06h1.23v-.602h-1.35c-.704 0-.958.437-.958.84 0 .9.776.855 1.407.87.104 0 .18.015.225.06.046.03.082.106.082.18 0 .077-.035.15-.08.18-.06.053-.15.07-.277.07zM0 0v10.096L.81 8.22h1.75l.225.464V8.22h2.043l.45 1.02.437-1.013h6.502c.295 0 .56.057.756.236v-.23h1.787v.23c.307-.17.686-.23 1.12-.23h2.606l.24.466v-.466h1.918l.254.465v-.466h1.858v3.948H20.87l-.36-.6v.585h-2.353l-.256-.63h-.583l-.27.614h-1.213c-.48 0-.84-.104-1.08-.24v.24h-2.89v-.884c0-.12-.03-.12-.105-.135h-.105v1.036H6.067v-.48l-.21.48H4.69l-.202-.48v.465H2.235l-.256-.624H1.4l-.256.624H0V24h23.786v-7.108c-.27.135-.613.18-.973.18H21.09v-.255c-.21.165-.57.255-.914.255H14.71v-.9c0-.12-.018-.12-.12-.12h-.075v1.022h-1.8v-1.066c-.298.136-.643.15-.928.136h-.214v.915h-2.18l-.54-.617-.57.6H4.742v-3.93h3.61l.518.602.554-.6h2.412c.28 0 .74.03.942.225v-.24h2.177c.202 0 .644.045.903.225v-.24h3.265v.24c.163-.164.508-.24.803-.24h1.89v.24c.194-.15.464-.24.84-.24h1.176V0H0zM21.156 14.955c.004.005.006.012.01.016.01.01.024.01.032.02l-.042-.035zM23.828 13.082h.065v.555h-.065zM23.865 15.03v-.005c-.03-.025-.046-.048-.075-.07-.15-.153-.39-.215-.764-.225l-.36-.012c-.12 0-.194-.007-.27-.03-.09-.03-.15-.105-.15-.21 0-.09.03-.16.09-.204.076-.045.15-.05.27-.05h1.223v-.588h-1.283c-.69 0-.96.437-.96.84 0 .9.78.855 1.41.87.104 0 .18.015.224.06.046.03.076.106.076.18 0 .07-.034.138-.09.18-.045.056-.136.07-.27.07h-1.288v.605h1.287c.42 0 .734-.118.9-.36h.03c.09-.134.135-.3.135-.523 0-.24-.045-.39-.135-.526zM18.597 14.208v-.583h-2.235V16.458h2.235v-.585h-1.57v-.57h1.533v-.584h-1.532v-.51M13.51 8.787h.685V11.6h-.684zM13.126 9.543l-.007.006c0-.314-.13-.5-.34-.624-.217-.125-.47-.135-.81-.135H10.43v2.82h.674v-1.034h.72c.24 0 .39.03.487.12.122.136.107.378.107.548v.354h.677v-.553c0-.25-.016-.375-.11-.516-.09-.107-.202-.19-.33-.237.172-.07.472-.3.472-.75zm-.855.396h-.015c-.09.054-.195.056-.33.056H11.1v-.623h.825c.12 0 .24.004.33.05.09.04.15.128.15.25s-.047.22-.134.266zM15.92 9.373h.632v-.6h-.644c-.464 0-.804.105-1.02.33-.286.3-.362.69-.362 1.11 0 .512.123.833.36 1.074.232.238.645.31.97.31h.78l.255-.627h1.39l.262.627h1.36v-2.11l1.272 2.11h.95l.002.002V8.786h-.684v1.963l-1.18-1.96h-1.02V11.4L18.11 8.744h-1.004l-.943 2.22h-.3c-.177 0-.362-.03-.468-.134-.125-.15-.186-.36-.186-.662 0-.285.08-.51.194-.63.133-.135.272-.165.516-.165zm1.668-.108l.464 1.118v.002h-.93l.466-1.12zM2.38 10.97l.254.628H4V9.393l.972 2.205h.584l.973-2.202.015 2.202h.69v-2.81H6.118l-.807 1.904-.876-1.905H3.343v2.663L2.205 8.787h-.997L.01 11.597h.72l.26-.626h1.39zm-.688-1.705l.46 1.118-.003.002h-.915l.457-1.12zM11.856 13.62H9.714l-.85.923-.825-.922H5.346v2.82H8l.855-.932.824.93h1.302v-.94h.838c.6 0 1.17-.164 1.17-.945l-.006-.003c0-.78-.598-.93-1.128-.93zM7.67 15.853l-.014-.002H6.02v-.557h1.47v-.574H6.02v-.51H7.7l.733.82-.764.824zm2.642.33l-1.03-1.147 1.03-1.108v2.253zm1.553-1.258h-.885v-.717h.885c.24 0 .42.098.42.344 0 .243-.15.372-.42.372zM9.967 9.373v-.586H7.73V11.6h2.237v-.58H8.4v-.564h1.527V9.88H8.4v-.507" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zm-2,15l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
|
||||
</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="#FF5F00"
|
||||
android:pathData="M11.343 18.031c.058.049.12.098.181.146-1.177.783-2.59 1.238-4.107 1.238C3.32 19.416 0 16.096 0 12c0-4.095 3.32-7.416 7.416-7.416 1.518 0 2.931.456 4.105 1.238-.06.051-.12.098-.165.15C9.6 7.489 8.595 9.688 8.595 12c0 2.311 1.001 4.51 2.748 6.031zm5.241-13.447c-1.52 0-2.931.456-4.105 1.238.06.051.12.098.165.15C14.4 7.489 15.405 9.688 15.405 12c0 2.31-1.001 4.507-2.748 6.031-.058.049-.12.098-.181.146 1.177.783 2.588 1.238 4.107 1.238C20.68 19.416 24 16.096 24 12c0-4.094-3.32-7.416-7.416-7.416zM12 6.174c-.096.075-.189.15-.28.231C10.156 7.764 9.169 9.765 9.169 12c0 2.236.987 4.236 2.551 5.595.09.08.185.158.28.232.096-.074.189-.152.28-.232 1.563-1.359 2.551-3.359 2.551-5.595 0-2.235-.987-4.236-2.551-5.595-.09-.08-.184-.156-.28-.231z" />
|
||||
</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="#1A1F71"
|
||||
android:pathData="M9.112 8.262L5.97 15.758H3.92L2.374 9.775c-0.094,-0.368,-0.175,-0.503,-0.461,-0.658C1.447 8.864 0.677 8.627 0 8.479l0.046,-0.217h3.3a0.904,0.904,0,0,1,0.894,0.764l0.817 4.338 2.018,-5.102zM17.145 13.311c0.008,-1.979,-2.736,-2.088,-2.717,-2.972 0.006,-0.269 0.262,-0.555 0.822,-0.628a3.66,3.66,0,0,1,1.913,0.336l0.34,-1.59a5.207,5.207,0,0,0,-1.814,-0.333c-1.917 0,-3.266 1.02,-3.278 2.479,-0.012 1.079 0.963 1.68 1.698 2.04 0.756 0.367 1.01 0.603 1.006 0.931,-0.005 0.504,-0.602 0.725,-1.16 0.734,-0.975 0.015,-1.54,-0.263,-1.992,-0.473l-0.351 1.642c0.453 0.208 1.289 0.39 2.156 0.398 2.037 0 3.37,-1.006 3.377,-2.564zM22.206 15.758H24l-1.565,-7.496h-1.656a0.883,0.883,0,0,0,-0.826,0.55l-2.909 6.946h2.036l0.405,-1.12h2.488zM20.043 13.102l1.02,-2.815 0.588 2.815zM11.883 8.262l-1.603 7.496H8.34l1.605,-7.496z" />
|
||||
</vector>
|
||||
@@ -4,17 +4,20 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/drawerLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<!-- Main content -->
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
android:background="?attr/colorSurface"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="wrap_content"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
@@ -49,6 +52,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:fitsSystemWindows="true"
|
||||
app:menu="@menu/bottom_nav_menu" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -61,6 +65,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:fitsSystemWindows="true"
|
||||
app:menu="@menu/drawer_menu" />
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
android:background="?attr/colorSurface"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/navHostFragment"
|
||||
|
||||
@@ -27,31 +27,49 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/languageChipGroup"
|
||||
android:layout_width="wrap_content"
|
||||
<LinearLayout
|
||||
android:id="@+id/languageSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="false">
|
||||
android:visibility="gone">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipEnglish"
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="English"
|
||||
style="@style/Widget.Material3.Chip.Filter" />
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/select_language"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipDhivehi"
|
||||
android:layout_width="wrap_content"
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/languageToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ދިވެހި"
|
||||
style="@style/Widget.Material3.Chip.Filter" />
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true">
|
||||
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLangEnglish"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="English" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLangDhivehi"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="ދިވެހި" />
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/dotsIndicator"
|
||||
@@ -61,6 +79,8 @@
|
||||
android:background="@null"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
app:tabBackground="@drawable/tab_indicator_selector"
|
||||
app:tabGravity="center"
|
||||
app:tabIndicatorHeight="0dp"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/btnContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
android:hint="@string/otp_seed"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:endIconMode="password_toggle"
|
||||
app:helperText="@string/otp_seed_hint"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etOtpSeed"
|
||||
@@ -116,7 +115,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="gone"
|
||||
android:visibility="invisible"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
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="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:paddingTop="40dp">
|
||||
|
||||
<!-- Appearance -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_appearance"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_navigation"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/navModeToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnNavDrawer"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_nav_drawer" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnNavBottom"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_nav_bottom" />
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/theme"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/themeToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnThemeSystem"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/theme_system" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnThemeLight"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/theme_light" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnThemeDark"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/theme_dark" />
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<!-- Privacy & Security -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_privacy_security"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Biometrics -->
|
||||
<LinearLayout
|
||||
android:id="@+id/rowBiometrics"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_biometrics"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBiometricsHint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_biometrics_unavailable"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_biometrics_unlock"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchBiometrics"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_biometrics_transfer"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchBiometricsTransfer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Block screenshots -->
|
||||
<LinearLayout
|
||||
android:id="@+id/rowBlockScreenshots"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_block_screenshots"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_block_screenshots_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchBlockScreenshots"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -1,94 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
<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:fillViewport="true">
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:paddingTop="64dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_marginBottom="40dp"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:paddingTop="64dp"
|
||||
android:paddingBottom="16dp">
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_marginBottom="40dp"
|
||||
android:contentDescription="@string/app_name" />
|
||||
<ScrollView
|
||||
android:id="@+id/scrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:lineSpacingMultiplier="1.4" />
|
||||
|
||||
<!-- Bank logo cards shown only on first slide -->
|
||||
<LinearLayout
|
||||
android:id="@+id/placeholderCards"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="40dp"
|
||||
android:weightSum="2"
|
||||
android:visibility="gone">
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:lineSpacingMultiplier="1.4" />
|
||||
|
||||
<!-- Supported services shown only on first slide -->
|
||||
<LinearLayout
|
||||
android:id="@+id/placeholderCards"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="40dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/onboarding_supported_services"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:src="@drawable/mib_faisanet_logo"
|
||||
android:scaleType="centerInside"
|
||||
android:padding="12dp"
|
||||
android:contentDescription="@string/mib_name" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:src="@drawable/bml_logo_vector"
|
||||
android:scaleType="centerInside"
|
||||
android:padding="8dp"
|
||||
android:contentDescription="@string/bml_name" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:src="@drawable/fahipay_logo_long"
|
||||
android:scaleType="centerInside"
|
||||
android:contentDescription="@string/fahipay_name" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="#FFFFFF">
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Renderable receipt card -->
|
||||
@@ -206,12 +206,6 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Pushes buttons to bottom of screen -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Action buttons — outside renderable area -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
@@ -219,7 +213,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="#FFFFFF"
|
||||
android:background="?attr/colorSurface"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
android:paddingBottom="24dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔒"
|
||||
android:textSize="56sp"
|
||||
<ImageView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:src="@drawable/ic_lock"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:contentDescription="@null"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
@@ -255,73 +256,49 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Step: Biometric prompt -->
|
||||
|
||||
<!-- Step: Already configured -->
|
||||
<LinearLayout
|
||||
android:id="@+id/viewBiometric"
|
||||
android:id="@+id/viewConfigured"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="40dp"
|
||||
android:paddingBottom="24dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:src="@drawable/ic_check_circle"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:contentDescription="@null"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔐"
|
||||
android:textSize="72sp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/biometric_title"
|
||||
android:text="@string/security_already_configured"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="12dp" />
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/biometric_desc"
|
||||
android:text="@string/security_already_configured_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="14dp"
|
||||
android:text="@string/biometric_security_note"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:gravity="center" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnEnableBiometrics"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:text="@string/enable_biometrics"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSkipBiometrics"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:id="@+id/btnChangeLock"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/skip_biometrics" />
|
||||
android:text="@string/settings_change_lock" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="16dp"
|
||||
android:visibility="gone">
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
@@ -40,7 +39,17 @@
|
||||
android:text="@string/settings_biometrics"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="8dp" />
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBiometricsHint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_biometrics_unavailable"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
<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="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutlineVariant">
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="20dp"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center_vertical"
|
||||
android:foreground="?attr/selectableItemBackground">
|
||||
|
||||
@@ -43,7 +39,7 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: segmented pill (bank | type | profile) + balance -->
|
||||
<!-- Right: segmented pill (bank | type) + balance -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -58,41 +54,11 @@
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/pill_segment_bg">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPillBank"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="16dp"
|
||||
android:background="?attr/colorOutline" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPillType"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="16dp"
|
||||
android:background="?attr/colorOutline" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPillProfile"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
@@ -111,4 +77,10 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<LinearLayout
|
||||
@@ -165,6 +166,20 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnHeaderTransfer"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:minWidth="0dp"
|
||||
android:minHeight="0dp"
|
||||
app:icon="@drawable/ic_send"
|
||||
app:iconSize="20dp"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="0dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
<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="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutlineVariant">
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="20dp"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="14dp"
|
||||
android:gravity="center_vertical"
|
||||
android:foreground="?attr/selectableItemBackground">
|
||||
|
||||
<!-- Brand chip -->
|
||||
<TextView
|
||||
android:id="@+id/tvCardBrand"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="6dp"
|
||||
<!-- Brand logo -->
|
||||
<ImageView
|
||||
android:id="@+id/ivCardBrand"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="@android:color/white"
|
||||
android:fontFamily="monospace"
|
||||
android:background="@drawable/chip_background" />
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="Card brand" />
|
||||
|
||||
<!-- Card name + number -->
|
||||
<LinearLayout
|
||||
@@ -54,6 +46,14 @@
|
||||
android:layout_marginTop="2dp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCardProduct"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Status pill + balance (prepaid only) -->
|
||||
@@ -89,4 +89,10 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<string name="app_name">BasedBank</string>
|
||||
|
||||
<!-- Onboarding -->
|
||||
<string name="onboarding_supported_services">ހިދުމަތްތައް</string>
|
||||
<string name="select_language">ބަސް ހިޔާލު ކުރޭ</string>
|
||||
<string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string>
|
||||
<string name="onboarding_desc_1">BasedBank ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string>
|
||||
<string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string>
|
||||
<string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string>
|
||||
<string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</string>
|
||||
<string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string>
|
||||
<string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.\n\nDhiraagu އާއި Ooredoo ގެ API ބޭނުންކޮށްގެން ފޯން ނަންބަރުގެ ތަފްސީލު ބެލޭ.\n\nމި އެޕް ތިޔައާ ބެހޭ ތަފްސީލެއް ނެހެދޭ، ޑިވެލޮޕަރަށް ވެސް ނުފޮނުވާ. ހުރިހާ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string>
|
||||
<string name="coming_soon">ފަހުން ލިބޭ</string>
|
||||
<string name="next">ދެން</string>
|
||||
<string name="get_started">ފަށާ</string>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
<string name="app_name">BasedBank</string>
|
||||
|
||||
<!-- Onboarding -->
|
||||
<string name="onboarding_supported_services">Supported services</string>
|
||||
<string name="select_language">Select Language</string>
|
||||
<string name="onboarding_title_1">Your Banks, One App</string>
|
||||
<string name="onboarding_desc_1">BasedBank brings all your Maldivian bank accounts together in one place. Check balances, view accounts, and more — without switching between apps.</string>
|
||||
<string name="onboarding_title_2">More Banks Coming</string>
|
||||
<string name="onboarding_desc_2">Support for additional banks is on the way. Stay tuned as we expand coverage across the Maldives.</string>
|
||||
<string name="onboarding_title_3">Get Started</string>
|
||||
<string name="onboarding_desc_3">Add your bank credentials and start viewing your accounts. Your data stays on your device.</string>
|
||||
<string name="onboarding_title_3">Before You Begin</string>
|
||||
<string name="onboarding_desc_3">BasedBank is an independent, third-party app. It is not affiliated with, endorsed by, or officially supported by any bank or financial institution.\n\nThis app works by logging into your internet banking services directly using your credentials and communicating with bank APIs. It is built using techniques derived from reverse-engineered official bank apps. Behaviour may change or break without notice if banks update their systems.\n\nDhiraagu and Ooredoo APIs are used to look up details about phone numbers.\n\nThis app does not collect any analytics, telemetry, or personal data, and does not transmit any information about you or your usage to the developer. Everything stays entirely on your device.\n\nBy tapping Get Started, you acknowledge and accept that:\n\n• Errors, failures, or service interruptions may occur at any time\n• Your bank may detect third-party access and apply restrictions or take other actions against your account\n• The developer of this app is not liable for any loss, damage, or consequences arising from your use of this app\n• You use this app entirely at your own risk</string>
|
||||
<string name="coming_soon">Coming Soon</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="get_started">Get Started</string>
|
||||
@@ -49,6 +51,9 @@
|
||||
<!-- Security setup -->
|
||||
<string name="security_setup">Secure Your App</string>
|
||||
<string name="security_setup_desc">Choose how you want to lock BasedBank when you\'re away.</string>
|
||||
<string name="security_already_configured">App Lock Configured</string>
|
||||
<string name="security_already_configured_desc">Your app lock is set up.</string>
|
||||
|
||||
<string name="method_pin">PIN Code</string>
|
||||
<string name="method_pin_desc">4–8 digit numeric PIN</string>
|
||||
<string name="method_pattern">Draw Pattern</string>
|
||||
@@ -104,6 +109,7 @@
|
||||
<string name="settings_security">Security</string>
|
||||
<string name="settings_change_lock">Change PIN / Pattern</string>
|
||||
<string name="settings_biometrics">Use biometrics</string>
|
||||
<string name="settings_biometrics_unavailable">No biometrics enrolled on this device</string>
|
||||
<string name="settings_biometrics_unlock">To unlock app</string>
|
||||
<string name="settings_biometrics_transfer">Confirm transfer</string>
|
||||
<string name="biometric_transfer_title">Confirm Transfer</string>
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||
<exclude domain="cache" path="."/>
|
||||
<exclude domain="root" path="cache/"/>
|
||||
</full-backup-content>
|
||||
@@ -14,7 +14,7 @@
|
||||
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||
<exclude domain="cache" path="."/>
|
||||
<exclude domain="root" path="cache/"/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="credential_store.xml"/>
|
||||
@@ -26,6 +26,6 @@
|
||||
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||
<exclude domain="cache" path="."/>
|
||||
<exclude domain="root" path="cache/"/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.9.1"
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.1.21"
|
||||
coreKtx = "1.10.1"
|
||||
junit = "4.13.2"
|
||||
|
||||
Reference in New Issue
Block a user