23 Commits

Author SHA1 Message Date
b1e73533f6 version 1.0.4
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m45s
2026-05-19 18:26:19 +05:00
3a5b9459a9 customize quick actions and bottom bar
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 18:21:36 +05:00
9c9729e268 UI/edgetoedge non edge screen fixes
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 17:24:01 +05:00
399cfbf108 transfer button enables after you fill required feilds
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 16:50:49 +05:00
19f4d01015 add long bml logo
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 16:39:44 +05:00
8c40322ff0 fahipay serialized multi-login added
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 16:13:36 +05:00
782e2e7674 refactor codebase to be more module for later adding new banks.. add support for single profile mib accounts.. add suport for disabling mib profiles in settings
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-19 14:48:04 +05:00
89a9731797 new version
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m24s
2026-05-19 00:15:19 +05:00
50badc7d54 support for edge to edge
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 00:13:42 +05:00
d4f86bb738 optimize OTP seed (check password =) remove legacy code
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 23:57:05 +05:00
b35f44f35b optimize bank login screens, add support for otpauth://totp/ URI
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:49:42 +05:00
1a58ce8b54 disable welcome screen page switch with dots
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-18 23:36:31 +05:00
2dd84ec50a countdown now starts after scoll
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:30:21 +05:00
33651ca107 redesign the welcome pages
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 23:12:51 +05:00
ae307e3118 add biometrics disabled hint
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 21:46:34 +05:00
3ab75bff92 fix contact add with multi BML logins
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 05:52:18 +05:00
1753d648bd loading indicator for account info fetch and tranfer
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 05:31:20 +05:00
423b0bf1e1 dark theme on bml recipt and hide bottom nav bar on recipt page 2026-05-18 05:21:46 +05:00
d713047970 keep bottom bar behind keyboard
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 05:12:49 +05:00
d59a6fad82 new feature: resume last activity after unlocking instead of goind to dashboard everytime
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 05:09:42 +05:00
8e47101401 refactor: extract BML/MIB product label parsing into dedicated parser utilities
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 04:53:40 +05:00
9431a90cd0 add card icons
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 04:35:20 +05:00
3a10f36c39 fix prod build
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 00:43:05 +05:00
87 changed files with 3000 additions and 1051 deletions

View File

@@ -4,14 +4,27 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z"> <DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" /> <DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </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> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

View File

@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 3
versionName = "1.0" versionName = "1.0.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -64,6 +64,9 @@ dependencies {
// RecyclerView for accounts list // RecyclerView for accounts list
implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.recyclerview:recyclerview:1.3.2")
// CircularProgressDrawable for spinning search icons
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// OkHttp for API calls // OkHttp for API calls
implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("com.squareup.okhttp3:okhttp:4.11.0")

View File

@@ -48,7 +48,8 @@
<activity <activity
android:name=".ui.home.HomeActivity" android:name=".ui.home.HomeActivity"
android:exported="false" /> android:exported="false"
android:windowSoftInputMode="adjustPan" />
<activity <activity
android:name=".ui.home.QrScannerActivity" android:name=".ui.home.QrScannerActivity"

View File

@@ -17,14 +17,42 @@ class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout // Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList() var accounts: List<MibAccount> = emptyList()
var fullName: String = "" var fullName: String = ""
var mibSession: MibSession? = null /** Active MIB sessions keyed by loginId (= MIB username). */
var mibProfiles: List<MibProfile> = emptyList() val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<MibAccount> = emptyList()
/** Active BML sessions keyed by loginId (= BML username). */ /** Active BML sessions keyed by loginId (= BML username). */
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf() val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList() var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null /** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<MibAccount> = emptyList() var fahipayAccounts: List<MibAccount> = emptyList()
/** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: MibAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")]
/** Returns any available MIB session. */
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
/** Returns all MIB profiles across all logins. */
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
fun mibFlowFor(loginId: String): MibLoginFlow =
mibLoginFlows.getOrPut(loginId) {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSessions[loginId] = session
mibProfilesMap[loginId] = profiles
}
}
}
/** Returns any available MibLoginFlow. */
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
/** Returns the BML session for the given account (matched via loginTag). */ /** Returns the BML session for the given account (matched via loginTag). */
fun bmlSessionFor(account: MibAccount): BmlSession? = fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")] bmlSessions[account.loginTag.removePrefix("bml_")]
@@ -32,18 +60,13 @@ class BasedBankApp : Application() {
/** Returns any available BML session (for non-account-specific operations). */ /** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull() fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */ /** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex() val mibMutex = Mutex()
val mibLoginFlow by lazy {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSession = session
mibProfiles = profiles
}
}
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)

View File

@@ -10,6 +10,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat 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 androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -18,8 +21,6 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.PBEKeySpec
@@ -31,7 +32,6 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String private lateinit var salt: String
private lateinit var storedHash: String private lateinit var storedHash: String
private var biometricsEnabled = false private var biometricsEnabled = false
private var isLegacyFormat = false
private var isVerifying = false private var isVerifying = false
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE) private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
@@ -39,28 +39,32 @@ class LockActivity : AppCompatActivity() {
companion object { companion object {
private const val MAX_ATTEMPTS = 5 private const val MAX_ATTEMPTS = 5
private const val LOCKOUT_MS = 30_000L private const val LOCKOUT_MS = 30_000L
const val EXTRA_RESUME = "resume"
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater) binding = ActivityLockBinding.inflate(layoutInflater)
setContentView(binding.root) 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) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin" method = prefs.getString("security_method", "pin") ?: "pin"
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false) biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
// Try new encrypted format first; fall back to legacy SHA-256 val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
val stored = CredentialStore(this).loadSecurityHash() salt = stored.first
if (stored != null) { storedHash = stored.second
salt = stored.first
storedHash = stored.second
isLegacyFormat = false
} else {
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
isLegacyFormat = true
}
if (method == "pin") { if (method == "pin") {
binding.viewPin.visibility = View.VISIBLE binding.viewPin.visibility = View.VISIBLE
@@ -149,7 +153,6 @@ class LockActivity : AppCompatActivity() {
val ok = withContext(Dispatchers.Default) { verify(entered) } val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false isVerifying = false
if (ok) { if (ok) {
migrateIfNeeded(entered)
resetFailures() resetFailures()
proceed() proceed()
} else { } else {
@@ -174,7 +177,6 @@ class LockActivity : AppCompatActivity() {
val ok = withContext(Dispatchers.Default) { verify(entered) } val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false isVerifying = false
if (ok) { if (ok) {
migrateIfNeeded(entered)
resetFailures() resetFailures()
proceed() proceed()
} else { } else {
@@ -216,30 +218,8 @@ class LockActivity : AppCompatActivity() {
private fun verify(input: String): Boolean { private fun verify(input: String): Boolean {
if (storedHash.isBlank()) return false if (storedHash.isBlank()) return false
return if (isLegacyFormat) { val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
sha256Legacy(salt + input) == storedHash return pbkdf2(input, saltBytes) == 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 */ }
} }
private fun triggerBiometric() { private fun triggerBiometric() {
@@ -270,8 +250,12 @@ class LockActivity : AppCompatActivity() {
} }
private fun proceed() { private fun proceed() {
startActivity(Intent(this, HomeActivity::class.java)) if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
finish() finish()
} else {
startActivity(Intent(this, HomeActivity::class.java))
finish()
}
} }
// ── Brute-force tracking ────────────────────────────────────────────────── // ── 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) }
} }

View File

@@ -618,6 +618,7 @@ class BmlLoginFlow {
if (accountType == "CASA") { if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0) val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount( casaAccounts.add(MibAccount(
bank = "BML",
profileName = "Personal", profileName = "Personal",
profileType = "BML", profileType = "BML",
accountNumber = accountNumber, accountNumber = accountNumber,
@@ -641,6 +642,7 @@ class BmlLoginFlow {
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount( prepaidCards.add(MibAccount(
bank = "BML",
profileName = "Personal", profileName = "Personal",
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT", profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber, accountNumber = accountNumber,

View File

@@ -186,6 +186,7 @@ class FahipayLoginFlow {
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount = fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
MibAccount( MibAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" }, profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY", profileType = "FAHIPAY",
accountNumber = profile.walletAccount, accountNumber = profile.walletAccount,

View File

@@ -29,6 +29,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
// Stored after login so the session can be silently recovered on 419 // Stored after login so the session can be silently recovered on 419
@Volatile private var loginId: String = ""
@Volatile private var storedUsername: String? = null @Volatile private var storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null @Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null @Volatile private var storedOtpSeed: String? = null
@@ -58,11 +59,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
* Returns list of accounts from all profiles on success. * Returns list of accounts from all profiles on success.
*/ */
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> { fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
loginId = username
storedUsername = username storedUsername = username
storedPasswordHash = passwordHash storedPasswordHash = passwordHash
storedOtpSeed = otpSeed storedOtpSeed = otpSeed
val appId = getOrCreateAppId() val appId = getOrCreateAppId()
val keys = credentialStore.loadMibKeys() val keys = credentialStore.loadMibKeys(loginId)
return if (keys != null) { return if (keys != null) {
regularLogin(username, passwordHash, appId, keys.first, keys.second) regularLogin(username, passwordHash, appId, keys.first, keys.second)
@@ -106,7 +108,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val keyData = otpResp.getJSONArray("data").getJSONObject(0) val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1") val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2") val key2 = keyData.getString("key2")
credentialStore.saveMibKeys(key1, key2) credentialStore.saveMibKeys(loginId, key1, key2)
return regularLogin(username, passwordHash, appId, key1, key2) return regularLogin(username, passwordHash, appId, key1, key2)
} }
@@ -136,10 +138,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
val profiles = parseProfiles(loginResp) val profiles = parseProfiles(loginResp)
lastSession = session2 lastSession = session2
lastProfiles = profiles lastProfiles = profiles // keep ALL profiles so settings can show them all
return fetchAllProfiles(session2, profiles, "mib_$username")
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
// When the server already selected the profile and returned balances in A41
// (single-profile case: profileSelected=true), use those accounts directly
// without making an extra P47 call (which the server ignores or rejects).
if (loginResp.optBoolean("profileSelected", false)) {
val a41Balances = loginResp.optJSONArray("accountBalance")
if (a41Balances != null && a41Balances.length() > 0) {
val selectedId = loginResp.optString("selectedProfileId")
val profile = profiles.firstOrNull { it.profileId == selectedId }
?: profiles.firstOrNull()
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
val allAccounts = mutableListOf<MibAccount>()
for (i in 0 until a41Balances.length()) {
val a = a41Balances.getJSONObject(i)
allAccounts.add(
MibAccount(
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
loginTag = "mib_$username",
profileId = profile.profileId
)
)
}
return allAccounts
}
}
}
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
} }
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────
@@ -271,8 +315,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val a = accountBalances.getJSONObject(i) val a = accountBalances.getJSONObject(i)
allAccounts.add( allAccounts.add(
MibAccount( MibAccount(
bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
cifType = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
@@ -389,11 +435,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
private fun generateOtp(seed: String): String = Totp.generate(seed) private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String { private fun getOrCreateAppId(): String {
var id = credentialStore.loadMibAppId() var id = credentialStore.loadMibAppId(loginId)
if (id == null) { if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("") id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
credentialStore.saveMibAppId(id) credentialStore.saveMibAppId(loginId, id)
} }
return id return id
} }

View File

@@ -20,8 +20,10 @@ data class MibProfile(
) )
data class MibAccount( data class MibAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String, val profileName: String,
val profileType: String, val profileType: String,
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
val accountNumber: String, val accountNumber: String,
val accountBriefName: String, val accountBriefName: String,
val currencyName: String, val currencyName: String,

View File

@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
@@ -20,7 +21,8 @@ import java.util.Date
import java.util.Locale import java.util.Locale
class AccountHistoryAdapter( class AccountHistoryAdapter(
private val account: MibAccount private val account: MibAccount,
private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
@@ -138,7 +140,7 @@ class AccountHistoryAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) { when (holder) {
is HeaderVH -> holder.bind(account) is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label) is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction) is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit else -> Unit
@@ -147,37 +149,20 @@ class AccountHistoryAdapter(
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) : inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
RecyclerView.ViewHolder(b.root) { RecyclerView.ViewHolder(b.root) {
fun bind(acc: MibAccount) { fun bind(d: AccountHistoryDisplay) {
b.tvHeaderAccountName.text = acc.accountBriefName b.tvHeaderAccountName.text = d.name
b.tvHeaderAccountNumber.text = acc.accountNumber b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = when { b.tvHeaderPillBank.text = d.bankPill
acc.profileType.startsWith("BML") -> "BML" b.tvHeaderPillType.text = d.typeLabel
acc.profileType == "FAHIPAY" -> "FP" b.tvHeaderAvailable.text = d.availableBalance
else -> null b.tvHeaderBalance.text = d.workingBalance
} if (d.blockedBalance != null) {
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName) b.tvHeaderBlocked.text = d.blockedBalance
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
if (blocked > 0.0) {
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
b.llHeaderBlocked.visibility = View.VISIBLE b.llHeaderBlocked.visibility = View.VISIBLE
} else { } else {
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
} }
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) } b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
}
private fun friendlyType(raw: String): String {
val u = raw.trim().uppercase()
return when {
u.contains("SAVING") -> "Savings"
u.contains("CURRENT") -> "Current"
u.contains("WADIAH") -> "Islamic"
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
u.contains("PREPAID") -> "Prepaid"
else -> raw.trim().take(12)
}
} }
} }

View File

@@ -10,6 +10,8 @@ import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -17,23 +19,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class AccountHistoryFragment : Fragment() { class AccountHistoryFragment : Fragment() {
@@ -43,21 +40,13 @@ class AccountHistoryFragment : Fragment() {
private lateinit var adapter: AccountHistoryAdapter private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount private lateinit var account: MibAccount
private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = "" private var searchQuery = ""
private var firstPageDone = false private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>() private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = mutableSetOf<String>() private val pendingIconUrls = mutableSetOf<String>()
// Pagination state
private var mibNextStart = 1
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
private var bmlNextPage = 1
private var bmlTotalPages = -1
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
private var fahipayNextStart = 0
private var fahipayTotal = -1
private var isLoading = false private var isLoading = false
private val pageSize = 10 private val pageSize = 10
@@ -79,8 +68,10 @@ class AccountHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
fetcher = HistoryFetcher(account)
adapter = AccountHistoryAdapter(account) val historyDisplay = AccountHistoryParser.from(account) ?: return
adapter = AccountHistoryAdapter(account, historyDisplay)
adapter.onImageNeeded = { name -> loadContactImage(name) } adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc -> adapter.onTransferClick = { acc ->
@@ -88,6 +79,16 @@ class AccountHistoryFragment : Fragment() {
} }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
@@ -104,11 +105,10 @@ class AccountHistoryFragment : Fragment() {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: "" searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay() filterAndDisplay()
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage() if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
} }
}) })
// Load cache immediately, then fetch fresh data in background
val cached = TransactionCache.load(requireContext(), account.accountNumber) val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
allTransactions.addAll(cached) allTransactions.addAll(cached)
@@ -133,19 +133,8 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
} }
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private fun isFahipay() = account.profileType == "FAHIPAY"
private fun hasMore(): Boolean = when {
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
private fun loadNextPage() { private fun loadNextPage() {
if (isLoading || !hasMore()) return if (isLoading || !fetcher.hasMore()) return
isLoading = true isLoading = true
if (firstPageDone && allTransactions.isNotEmpty()) { if (firstPageDone && allTransactions.isNotEmpty()) {
@@ -155,68 +144,7 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) { val transactions = fetcher.fetchNextPage(app, pageSize)
when {
isFahipay() -> {
val session = app.fahipaySession ?: return@withContext emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
list
}
isMib() -> {
val session = app.mibSession ?: return@withContext emptyList()
app.mibMutex.withLock {
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
list
}
}
isBmlCard() -> {
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)
cardMonthOffset++
BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
else -> {
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
list
}
}
}
isLoading = false isLoading = false
@@ -233,7 +161,6 @@ class AccountHistoryFragment : Fragment() {
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions) TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
if (searchQuery.isBlank()) { if (searchQuery.isBlank()) {
// Append incrementally to preserve scroll position
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
adapter.appendTransactions(sorted) adapter.appendTransactions(sorted)
binding.emptyView.visibility = View.GONE binding.emptyView.visibility = View.GONE
@@ -243,7 +170,7 @@ class AccountHistoryFragment : Fragment() {
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
} }
if (searchQuery.isNotBlank() && hasMore()) loadNextPage() if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
@@ -261,7 +188,7 @@ class AccountHistoryFragment : Fragment() {
return return
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -288,8 +215,7 @@ class AccountHistoryFragment : Fragment() {
val response = client.newCall(Request.Builder().url(url).build()).execute() val response = client.newCall(Request.Builder().url(url).build()).execute()
val bytes = response.body?.bytes() ?: return@launch val bytes = response.body?.bytes() ?: return@launch
response.close() response.close()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
?: return@launch
MerchantIconCache.save(requireContext(), url, bitmap) MerchantIconCache.save(requireContext(), url, bitmap)
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) } withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
} catch (_: Exception) { } catch (_: Exception) {

View File

@@ -3,8 +3,6 @@ package sh.sar.basedbank.ui.home
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -14,16 +12,20 @@ import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter( class AccountsAdapter(
accounts: List<MibAccount>, accounts: List<MibAccount>,
private val onAccountClick: (MibAccount) -> Unit = {} private val onAccountClick: (MibAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((MibAccount) -> Unit)? = null
private sealed class Item { private sealed class Item {
data class SectionTitle(val label: String) : Item() data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item() data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount) : Item() data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
} }
private val items: MutableList<Item> = buildItems(accounts).toMutableList() private val items: MutableList<Item> = buildItems(accounts).toMutableList()
@@ -35,38 +37,38 @@ class AccountsAdapter(
} }
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList { private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" } val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } val nonCards = displayed.filter { !it.second.isCard }
val cards = displayed.filter { it.second.isCard }
// Group non-prepaid accounts by their derived section title, preserving order val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
val groups = LinkedHashMap<String, MutableList<MibAccount>>() for ((acc, display) in nonCards) {
for (acc in nonPrepaid) {
val title = sectionTitle(acc) val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc) groups.getOrPut(title) { mutableListOf() }.add(acc to display)
} }
for ((title, group) in groups) { for ((title, group) in groups) {
add(Item.SectionTitle(title)) add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) } group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
} }
if (prepaid.isNotEmpty()) { if (cards.isNotEmpty()) {
add(Item.SectionTitle("Cards · Bank of Maldives")) add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) } cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
} }
} }
private fun sectionTitle(account: MibAccount): String { private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) { val bankName = when (account.bank) {
"0" -> "Personal" "BML" -> "Bank of Maldives"
"1" -> "Business" "FAHIPAY" -> "Fahipay"
else -> account.profileName "MIB" -> "Maldives Islamic Bank"
else -> account.bank
} }
val bank = when { val profileLabel = when (account.bank) {
account.profileType.startsWith("BML") -> "Bank of Maldives" "MIB" -> account.cifType.ifBlank { account.profileName }
account.profileType == "FAHIPAY" -> "Fahipay" else -> account.profileName
else -> "Maldives Islamic Bank"
} }
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
} }
override fun getItemViewType(position: Int) = when (items[position]) { override fun getItemViewType(position: Int) = when (items[position]) {
@@ -78,17 +80,17 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
return when (viewType) { return when (viewType) {
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false)) TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false)) TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false)) else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
} }
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) { when (val item = items[position]) {
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item) is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
is Item.Account -> (holder as AccountViewHolder).bind(item.account) is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
is Item.Card -> (holder as CardViewHolder).bind(item.account) is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
} }
} }
@@ -103,14 +105,15 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) : private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: MibAccount, display: AccountListDisplay) {
binding.tvAccountName.text = account.accountBriefName binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = account.accountNumber binding.tvAccountNumber.text = display.number
binding.tvPillType.text = friendlyAccountType(account.accountTypeName) binding.tvAccountType.text = display.typeLabel
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}" binding.tvBalance.text = display.balance
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber) copyToClipboard(it.context, display.number)
true true
} }
} }
@@ -118,28 +121,22 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) : private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: MibAccount, display: AccountListDisplay) {
val brand = cardBrand(account.accountTypeName) binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardBrand.text = brand.label binding.tvCardName.text = display.name
binding.tvCardBrand.background = GradientDrawable().apply { binding.tvCardNumber.text = display.number
shape = GradientDrawable.RECTANGLE binding.tvCardProduct.text = display.typeLabel
cornerRadius = 100f
setColor(Color.parseColor(brand.color))
}
binding.tvCardName.text = account.accountBriefName
binding.tvCardNumber.text = account.accountNumber
binding.layoutCardBalance.visibility = View.VISIBLE binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}" binding.tvCardBalance.text = display.balance
if (display.statusLabel != null) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) binding.tvCardStatus.text = display.statusLabel
if (isActive) {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} else {
binding.tvCardStatus.text = account.statusDesc
binding.tvCardStatus.visibility = View.VISIBLE binding.tvCardStatus.visibility = View.VISIBLE
binding.root.alpha = 0.45f binding.root.alpha = 0.45f
} else {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} }
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
} }
} }
@@ -154,30 +151,5 @@ class AccountsAdapter(
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber)) cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show() 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 {
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")
}
} }
} }

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -26,9 +28,21 @@ class AccountsFragment : Fragment() {
adapter = AccountsAdapter(emptyList()) { account -> adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account)) (activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
} }
adapter.onTransferClick = { account ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) } viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
} }

View File

@@ -12,12 +12,18 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -43,7 +49,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private data class DestinationOption( private data class DestinationOption(
val label: String, val label: String,
val isBml: Boolean, val isBml: Boolean,
val mibProfile: MibProfile? = null val mibProfile: MibProfile? = null,
val mibLoginId: String? = null,
val bmlLoginId: String? = null,
val subtitle: String = ""
) )
private var destinations: List<DestinationOption> = emptyList() private var destinations: List<DestinationOption> = emptyList()
@@ -83,21 +92,49 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun buildDestinations(): List<DestinationOption> { private fun buildDestinations(): List<DestinationOption> {
val list = mutableListOf<DestinationOption>() val list = mutableListOf<DestinationOption>()
for (profile in app.mibProfiles) { for ((loginId, profiles) in app.mibProfilesMap) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile)) for (profile in profiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
}
} }
if (app.anyBmlSession() != null) { val store = CredentialStore(requireContext())
list.add(DestinationOption("BML · Personal", isBml = true)) 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 return list
} }
private fun setupDestinationDropdown() { private fun setupDestinationDropdown() {
val labels = destinations.map { it.label } val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels) 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.setAdapter(adapter)
binding.actvDestination.setOnItemClickListener { _, _, position, _ -> binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
selectedDest = destinations[position] selectedDest = destinations[position]
binding.actvDestination.setText(destinations[position].label, false)
clearLookupResult() clearLookupResult()
updateMibOnlyVisibility() updateMibOnlyVisibility()
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
@@ -129,6 +166,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() { private fun setupAccountSearch() {
binding.tilAccount.setEndIconOnClickListener { performLookup() } binding.tilAccount.setEndIconOnClickListener { performLookup() }
} }
@@ -167,7 +220,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
return return
} }
binding.tilAccount.isEnabled = false startLookupLoading()
binding.tilDestination.isEnabled = false binding.tilDestination.isEnabled = false
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
@@ -175,7 +228,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input) if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
} }
binding.tilAccount.isEnabled = true stopLookupLoading()
binding.tilDestination.isEnabled = true binding.tilDestination.isEnabled = true
if (result != null) { if (result != null) {
showLookupResult(result, input) showLookupResult(result, input)
@@ -186,7 +239,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun lookupForBml(input: String): BmlAccountValidation? { private fun lookupForBml(input: String): BmlAccountValidation? {
val bmlSess = app.anyBmlSession() ?: return null val loginId = selectedDest?.bmlLoginId ?: return null
val bmlSess = app.bmlSessions[loginId] ?: return null
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
// 1) Try BML validate // 1) Try BML validate
@@ -198,7 +252,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (mibVerified != null) return mibVerified if (mibVerified != null) return mibVerified
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML) // 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.mibSession ?: return null val mibSess = app.anyMibSession() ?: return null
return try { return try {
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
@@ -215,11 +269,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? { private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
val mibSess = app.mibSession ?: return null val loginId = dest.mibLoginId ?: return null
val mibSess = app.mibSessions[loginId] ?: return null
val profile = dest.mibProfile ?: return null val profile = dest.mibProfile ?: return null
val mibResult = try { val mibResult = try {
app.mibLoginFlow.switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL", trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
@@ -341,6 +396,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.tilAlias.error = null binding.tilAlias.error = null
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
binding.btnSave.text = "Saving..."
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
@@ -352,13 +408,15 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
dismiss() dismiss()
} else { } else {
binding.btnSave.isEnabled = true binding.btnSave.isEnabled = true
binding.btnSave.text = "Save"
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
} }
} }
} }
private fun saveToBml(alias: String): Boolean { private fun saveToBml(alias: String): Boolean {
val bmlSess = app.anyBmlSession() ?: return false val loginId = selectedDest?.bmlLoginId ?: return false
val bmlSess = app.bmlSessions[loginId] ?: return false
val lookup = bmlLookup ?: return false val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
val account = lookup.account val account = lookup.account
@@ -375,8 +433,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun saveToMib(alias: String): Boolean { private fun saveToMib(alias: String): Boolean {
val mibSess = app.mibSession ?: return false
val dest = selectedDest ?: return false val dest = selectedDest ?: return false
val loginId = dest.mibLoginId ?: return false
val mibSess = app.mibSessions[loginId] ?: return false
val profile = dest.mibProfile ?: return false val profile = dest.mibProfile ?: return false
val account = mibLookupAccount ?: return false val account = mibLookupAccount ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR" val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
@@ -388,7 +447,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val name = bmlLookup?.name ?: "" val name = bmlLookup?.name ?: ""
return try { return try {
app.mibLoginFlow.switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact( MibContactsClient().createContact(
session = mibSess, session = mibSess,
benefType = benefType, benefType = benefType,
@@ -425,8 +484,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
requireActivity().lifecycleScope.launch(Dispatchers.IO) { requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try { try {
if (dest.isBml) { if (dest.isBml) {
val bmlSess = app.anyBmlSession() ?: return@launch val loginId = dest.bmlLoginId ?: return@launch
val loginId = app.bmlSessions.entries.firstOrNull { it.value == bmlSess }?.key ?: "" val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId) val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
@@ -434,8 +493,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh) if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else { } else {
val profile = dest.mibProfile ?: return@launch val profile = dest.mibProfile ?: return@launch
val mibSess = app.mibSession ?: return@launch val mibLoginId = dest.mibLoginId ?: return@launch
app.mibLoginFlow.switchProfile(mibSess, profile) val mibSess = app.mibSessions[mibLoginId] ?: return@launch
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess) val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) } .map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()

View File

@@ -36,7 +36,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private val sharedImageCache = mutableMapOf<String, Bitmap>() private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val profileImageHashes = mutableSetOf<String>() private val profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.anyMibSession()
private var fromAccountNumber: String = "" private var fromAccountNumber: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
@@ -291,7 +291,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (hash in profileImageHashes) { val base64 = if (hash in profileImageHashes) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch

View File

@@ -12,23 +12,23 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter( class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>, private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit, private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit, private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList() private var displayed: List<ContactDisplay> = emptyList()
private var activeCategoryId: String? = null private var activeCategoryId: String? = null
private var searchQuery: String = "" private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) { fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts allContacts = contacts
applyFilter() applyFilter()
} }
@@ -36,7 +36,7 @@ class ContactsAdapter(
fun updateImage(hash: String, bitmap: Bitmap) { fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact -> displayed.forEachIndexed { index, contact ->
if (contact.customerImgHash == hash) notifyItemChanged(index) if (contact.imageHash == hash) notifyItemChanged(index)
} }
} }
@@ -48,11 +48,11 @@ class ContactsAdapter(
private fun applyFilter() { private fun applyFilter() {
displayed = allContacts.filter { contact -> displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() || val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) || contact.name.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) || contact.realName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery) contact.accountNumber.contains(searchQuery)
matchesCategory && matchesSearch matchesCategory && matchesSearch
} }
notifyDataSetChanged() notifyDataSetChanged()
@@ -76,7 +76,7 @@ class ContactsAdapter(
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
val account = displayed[pos].benefAccount val account = displayed[pos].accountNumber
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account)) clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show() Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
@@ -88,7 +88,7 @@ class ContactsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position] val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash -> val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null } imageCache[hash] ?: run { onImageNeeded(hash); null }
} }
holder.bind(contact, cachedImage) holder.bind(contact, cachedImage)
@@ -99,21 +99,24 @@ class ContactsAdapter(
inner class ViewHolder(val binding: ItemContactBinding) : inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) { fun bind(contact: ContactDisplay, photo: Bitmap?) {
val isFahipay = contact.benefType == "FAHIPAY" binding.tvContactName.text = contact.name
binding.tvContactName.text = contact.benefNickName binding.tvContactAccount.text = contact.accountNumber
binding.tvContactAccount.text = contact.benefAccount binding.tvRealName.text = contact.detail ?: ""
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}" binding.tvRealName.visibility =
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE binding.btnTransferContact.visibility =
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE binding.btnEditContact.visibility =
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility =
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
if (photo != null) { if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo) binding.ivContactPhoto.setImageBitmap(photo)
} else { } else {
binding.ivContactPhoto.setImageBitmap( binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor) makeInitialsBitmap(contact.name, contact.bankColor)
) )
} }
} }

View File

@@ -8,7 +8,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
@@ -21,13 +23,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() { class ContactsFragment : Fragment() {
@@ -38,9 +42,9 @@ class ContactsFragment : Fragment() {
private val pendingHashes = mutableSetOf<String>() private val pendingHashes = mutableSetOf<String>()
private val sharedImageCache = mutableMapOf<String, Bitmap>() private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.anyMibSession()
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var currentSearch: String = "" private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter private lateinit var pagerAdapter: ContactsPagerAdapter
@@ -53,9 +57,9 @@ class ContactsFragment : Fragment() {
private val density get() = resources.displayMetrics.density private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page -> val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter( ContactsAdapter(
imageCache = sharedImageCache, imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) }, onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) }, onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) } onTransferClick = { contact -> openTransfer(contact) }
).also { a -> ).also { a ->
a.setFilter(page.categoryId, currentSearch) a.setFilter(page.categoryId, currentSearch)
@@ -63,7 +67,7 @@ class ContactsFragment : Fragment() {
} }
} }
fun updateContacts(contacts: List<MibBeneficiary>) = fun updateContacts(contacts: List<ContactDisplay>) =
contactAdapters.forEach { it.updateContacts(contacts) } contactAdapters.forEach { it.updateContacts(contacts) }
fun updateSearch(query: String) = fun updateSearch(query: String) =
@@ -86,7 +90,7 @@ class ContactsFragment : Fragment() {
) )
clipToPadding = false clipToPadding = false
val p4 = (4 * density).toInt() val p4 = (4 * density).toInt()
val p80 = (80 * density).toInt() val p80 = (65 * density).toInt()
setPadding(0, p4, 0, p80) setPadding(0, p4, 0, p80)
adapter = contactAdapters[viewType] adapter = contactAdapters[viewType]
} }
@@ -113,6 +117,17 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateSearch(currentSearch) pagerAdapter.updateSearch(currentSearch)
} }
val fabMarginBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabAddContact) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
val lp = v.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
lp.bottomMargin = fabMarginBase + extraBottom
v.layoutParams = lp
insets
}
binding.fabAddContact.setOnClickListener { binding.fabAddContact.setOnClickListener {
AddContactSheetFragment().show(childFragmentManager, "add_contact") AddContactSheetFragment().show(childFragmentManager, "add_contact")
} }
@@ -124,8 +139,8 @@ class ContactsFragment : Fragment() {
} }
viewModel.contacts.observe(viewLifecycleOwner) { contacts -> viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
allContacts = contacts allContacts = ContactListParser.fromList(contacts)
pagerAdapter.updateContacts(contacts) pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
} }
@@ -150,31 +165,29 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false) binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
} }
private fun openTransfer(contact: MibBeneficiary) { private fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance( val fragment = TransferFragment.newInstance(
accountNumber = contact.benefAccount, accountNumber = contact.accountNumber,
displayName = contact.benefNickName, displayName = contact.name,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}", subtitle = contact.transferSubtitle,
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.customerImgHash imageHash = contact.imageHash
) )
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
} }
private fun confirmDelete(contact: MibBeneficiary) { private fun confirmDelete(contact: ContactDisplay) {
AlertDialog.Builder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.contact_delete_title) .setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName)) .setMessage(getString(R.string.contact_delete_message, contact.name))
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) } .setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
private fun deleteContact(contact: MibBeneficiary) { private fun deleteContact(contact: ContactDisplay) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
}
if (success) { if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact) removeFromViewModel(contact)
@@ -184,27 +197,10 @@ class ContactsFragment : Fragment() {
} }
} }
private fun deleteBml(contact: MibBeneficiary): Boolean { private fun removeFromViewModel(contact: ContactDisplay) {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: MibBeneficiary): Boolean {
val sess = session ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.benefNo)
} catch (_: Exception) { false }
}
private fun removeFromViewModel(contact: MibBeneficiary) {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
viewModel.contacts.value = updated viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") { if (contact.network == TransferNetwork.BML) {
updated.filter { it.benefCategoryId == "BML" } updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId } .groupBy { it.profileId }
.forEach { (loginId, contacts) -> .forEach { (loginId, contacts) ->
@@ -221,7 +217,6 @@ class ContactsFragment : Fragment() {
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return if (!pendingHashes.add(hash)) return
// Check disk cache first — if hash matches we already have the image
val cached = ContactImageCache.load(requireContext(), hash) val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) { if (cached != null) {
view?.post { pagerAdapter.updateImage(hash, cached) } view?.post { pagerAdapter.updateImage(hash, cached) }

View File

@@ -1,10 +1,13 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -30,17 +33,34 @@ class DashboardFragment : Fragment() {
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) } viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
binding.btnTransfer.setOnClickListener { val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer) ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
} val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
binding.btnPayMvQr.setOnClickListener { val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.nav_dashboard) requireActivity().title = getString(R.string.nav_dashboard)
refreshQuickActions()
}
private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val ids = NavCustomization.getQuickActions(prefs)
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
if (def != null) {
btn.setText(def.titleRes)
btn.icon = ContextCompat.getDrawable(requireContext(), def.iconRes)
}
btn.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(ids[i]) }
}
} }
private fun updateBalances(accounts: List<MibAccount>) { private fun updateBalances(accounts: List<MibAccount>) {

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -27,6 +29,15 @@ class FinancingFragment : Fragment() {
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
viewModel.financing.observe(viewLifecycleOwner) { deals -> viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals) adapter.updateDeals(deals)
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE

View File

@@ -1,6 +1,7 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import android.os.Handler import android.os.Handler
@@ -8,12 +9,15 @@ import android.os.Looper
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import com.google.android.material.navigation.NavigationBarView
import android.widget.Toast import android.widget.Toast
import sh.sar.basedbank.ui.home.NavCustomization
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -64,24 +68,37 @@ class HomeActivity : AppCompatActivity() {
private val warningRunnable = Runnable { showAutolockWarning() } private val warningRunnable = Runnable { showAutolockWarning() }
private var isLocked = false
private val autolockRunnable = Runnable { private val autolockRunnable = Runnable {
countdownTimer?.cancel(); countdownTimer = null countdownTimer?.cancel(); countdownTimer = null
warningDialog?.dismiss(); warningDialog = null warningDialog?.dismiss(); warningDialog = null
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE) val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
.getString("security_method", null) != null .getString("security_method", null) != null
if (securitySet) { if (securitySet) lock()
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java)) }
finish()
} 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater) binding = ActivityHomeBinding.inflate(layoutInflater)
if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) { if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
} }
setContentView(binding.root) 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) setSupportActionBar(binding.toolbar)
toggle = ActionBarDrawerToggle( toggle = ActionBarDrawerToggle(
@@ -93,14 +110,21 @@ class HomeActivity : AppCompatActivity() {
binding.bottomNavigation.setOnItemSelectedListener { item -> binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true if (suppressBottomNavCallback) return@setOnItemSelectedListener true
when (item.itemId) { val frag = when (item.itemId) {
R.id.nav_dashboard -> { show(DashboardFragment()); true } R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> { show(AccountsFragment()); true } R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> { show(ContactsFragment()); true } R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> { show(TransferFragment()); true } R.id.nav_transfer -> TransferFragment()
R.id.nav_more -> { show(MoreFragment()); true } R.id.nav_more -> MoreFragment()
else -> false R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
else -> null
} }
if (frag != null) show(frag)
else Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
true
} }
applyNavMode() applyNavMode()
@@ -113,31 +137,36 @@ class HomeActivity : AppCompatActivity() {
// Load data // Load data
val app = application as BasedBankApp val app = application as BasedBankApp
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) { if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background // Came from fresh manual login — accounts ready, rest fetched in background
val mibAccounts = app.accounts.filter { !it.profileType.startsWith("BML") && it.profileType != "FAHIPAY" } val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts viewModel.accounts.value = merged.filterVisibleAccounts()
viewModel.accounts.value = merged if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
if (app.bmlAccounts.isNotEmpty()) { if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") } val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) } byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
} }
if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts) if (app.fahipayAccounts.isNotEmpty()) {
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
}
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
refreshFinancing(app.mibSession, app.mibProfiles) for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
} else { } else {
// Came from lock screen — show caches immediately, refresh everything in background // Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this) val store = CredentialStore(this)
val cachedMib = AccountCache.load(this) val cachedMib = AccountCache.load(this)
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds()) val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
val cachedFahipay = AccountCache.loadFahipay(this) val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay val merged = cachedMib + cachedBml + cachedFahipay
if (merged.isNotEmpty()) viewModel.accounts.value = merged if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
@@ -145,7 +174,7 @@ class HomeActivity : AppCompatActivity() {
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store) autoRefresh(store)
} }
// Show dashboard on first create // Show dashboard on first create
@@ -154,27 +183,29 @@ class HomeActivity : AppCompatActivity() {
binding.navigationView.setCheckedItem(R.id.nav_dashboard) binding.navigationView.setCheckedItem(R.id.nav_dashboard)
} }
// Keep MIB session alive every 25 seconds while the app is in the foreground // Keep all MIB sessions alive every 25 seconds while the app is in the foreground
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) { while (true) {
delay(25_000) delay(25_000)
val session = (application as BasedBankApp).mibSession ?: continue val sessions = (application as BasedBankApp).mibSessions.values.toList()
withContext(Dispatchers.IO) { for (session in sessions) {
try { withContext(Dispatchers.IO) {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " + try {
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597" val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
val request = Request.Builder() "IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive") val request = Request.Builder()
.post(ByteArray(0).toRequestBody()) .url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
.header("Cookie", cookieHeader) .post(ByteArray(0).toRequestBody())
.header("User-Agent", "okhttp/4.11.0") .header("Cookie", cookieHeader)
.header("Accept", "application/json, text/plain, */*") .header("User-Agent", "okhttp/4.11.0")
.header("Accept-Encoding", "gzip") .header("Accept", "application/json, text/plain, */*")
.header("Connection", "Keep-Alive") .header("Accept-Encoding", "gzip")
.build() .header("Connection", "Keep-Alive")
OkHttpClient().newCall(request).execute().close() .build()
} catch (_: Exception) {} OkHttpClient().newCall(request).execute().close()
} catch (_: Exception) {}
}
} }
} }
} }
@@ -188,12 +219,15 @@ class HomeActivity : AppCompatActivity() {
} }
fun applyNavMode() { fun applyNavMode() {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) { if (isBottom) {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED) binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs)
applyNavLabelVisibility()
} else { } else {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED) binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true toggle.isDrawerIndicatorEnabled = true
@@ -202,6 +236,27 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
val slots = NavCustomization.getSlots(prefs)
val menu = binding.bottomNavigation.menu
menu.clear()
menu.add(Menu.NONE, R.id.nav_dashboard, 0, R.string.nav_dashboard)
.setIcon(R.drawable.ic_nav_dashboard)
slots.forEachIndexed { i, id ->
val item = NavCustomization.ALL_SWAPPABLE.find { it.id == id } ?: return@forEachIndexed
menu.add(Menu.NONE, item.id, i + 1, item.titleRes).setIcon(item.iconRes)
}
menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more)
.setIcon(R.drawable.ic_nav_more)
}
fun applyNavLabelVisibility() {
val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true)
binding.bottomNavigation.labelVisibilityMode =
if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED
else NavigationBarView.LABEL_VISIBILITY_AUTO
}
fun navigateTo(itemId: Int, fragment: Fragment? = null) { fun navigateTo(itemId: Int, fragment: Fragment? = null) {
val dest = fragment ?: when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
@@ -216,11 +271,22 @@ class HomeActivity : AppCompatActivity() {
} }
show(dest) show(dest)
binding.navigationView.setCheckedItem(itemId) 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) {
if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) { val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
suppressBottomNavCallback = true .map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
binding.bottomNavigation.selectedItemId = itemId val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
suppressBottomNavCallback = false if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
}
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
} }
} }
@@ -237,6 +303,13 @@ class HomeActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.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 // If we were away long enough to have hit the autolock timeout (e.g. while
// QrScannerActivity was in the foreground), lock immediately. // QrScannerActivity was in the foreground), lock immediately.
if (pauseTime > 0L) { if (pauseTime > 0L) {
@@ -244,8 +317,7 @@ class HomeActivity : AppCompatActivity() {
val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L) val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L)
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null
if (timeout > 0L && elapsed >= timeout && securitySet) { if (timeout > 0L && elapsed >= timeout && securitySet) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java)) lock()
finish()
return return
} }
} }
@@ -316,8 +388,7 @@ class HomeActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) { if (item.itemId == R.id.action_lock) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java)) lock()
finish()
return true return true
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
@@ -326,50 +397,47 @@ class HomeActivity : AppCompatActivity() {
fun relogin() { fun relogin() {
val store = CredentialStore(this) val store = CredentialStore(this)
val hasMib = store.hasMibCredentials() val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val fahipayLoginIds = store.getFahipayLoginIds()
if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) { if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
startActivity(Intent(this, LoginActivity::class.java)) startActivity(Intent(this, LoginActivity::class.java))
finish() finish()
return return
} }
// Immediately drop accounts for logged-out banks from the displayed list // Immediately drop accounts for logged-out logins from the displayed list
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
viewModel.accounts.value = current.filter { acc -> viewModel.accounts.value = current.filter { acc ->
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
if (acc.profileType.startsWith("BML")) { if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
val loginId = acc.loginTag.removePrefix("bml_") if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
return@filter loginId in bmlLoginIds
}
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
true true
} }
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store) autoRefresh(store)
} }
private fun autoRefresh( private fun autoRefresh(store: CredentialStore) {
mibCreds: CredentialStore.MibCredentials?, val mibLoginIds = store.getMibLoginIds()
fahipayCreds: CredentialStore.FahipayCredentials?,
store: CredentialStore
) {
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
if (mibCreds == null && bmlLoginIds.isEmpty() && fahipayCreds == null) return val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch { lifecycleScope.launch {
val mibJob = mibCreds?.let { // One async job per MIB login, all run in parallel
async(Dispatchers.IO) { val mibJobs = mibLoginIds.mapNotNull { loginId ->
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
try { try {
val flow = MibLoginFlow(CredentialStore(this@HomeActivity)) val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed) val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
val app = application as BasedBankApp val app = application as BasedBankApp
app.accounts = accounts app.mibSessions[loginId] = flow.lastSession!!
app.mibSession = flow.lastSession app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibProfiles = flow.lastProfiles app.mibLoginFlows[loginId] = flow
AccountCache.save(this@HomeActivity, accounts) store.saveMibProfiles(loginId, flow.lastProfiles)
accounts accounts
} catch (_: Exception) { AccountCache.load(this@HomeActivity) } } catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } }
} }
} }
@@ -407,68 +475,97 @@ class HomeActivity : AppCompatActivity() {
} }
} }
val fahipayJob = fahipayCreds?.let { creds -> // One async job per Fahipay login, all run in parallel
async(Dispatchers.IO) { val fahipayJobs = fahipayLoginIds.mapNotNull { loginId ->
val creds = store.loadFahipayCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val fahipayFlow = FahipayLoginFlow() val fahipayFlow = FahipayLoginFlow()
val deviceUuid = store.getOrCreateFahipayDeviceUuid() val deviceUuid = store.getOrCreateFahipayDeviceUuid()
val loginTag = "fahipay_$loginId"
val savedSession = store.loadFahipaySession() val savedSession = store.loadFahipaySession(loginId)
if (savedSession != null) { if (savedSession != null) {
try { try {
val session = FahipaySession(savedSession.first, savedSession.second) val session = FahipaySession(savedSession.first, savedSession.second)
fahipayFlow.setSessionCookie(session.sessionCookie) fahipayFlow.setSessionCookie(session.sessionCookie)
val balance = fahipayFlow.fetchBalance(session) val balance = fahipayFlow.fetchBalance(session)
val profile = fahipayFlow.fetchProfile(session) val profile = fahipayFlow.fetchProfile(session)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp val app = application as BasedBankApp
app.fahipaySession = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = accounts AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
AccountCache.saveFahipay(this@HomeActivity, accounts) return@async accounts
return@async Pair(session, accounts) } catch (_: Exception) { }
} catch (_: Exception) {
}
} }
try { try {
val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid) val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid)
if (step.twoFactorRequired) { if (step.twoFactorRequired) {
return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
} }
val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val authId = step.authId ?: return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
val cookieValue = fahipayFlow.getSessionCookieValue() ?: "" val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
val session = FahipaySession(authId, cookieValue) val session = FahipaySession(authId, cookieValue)
store.saveFahipaySession(authId, cookieValue) store.saveFahipaySession(loginId, authId, cookieValue)
val profile = fahipayFlow.fetchProfile(session) val profile = fahipayFlow.fetchProfile(session)
val balance = fahipayFlow.fetchBalance(session) val balance = fahipayFlow.fetchBalance(session)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp val app = application as BasedBankApp
app.fahipaySession = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = accounts AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
AccountCache.saveFahipay(this@HomeActivity, accounts) accounts
Pair(session, accounts)
} catch (_: Exception) { } catch (_: Exception) {
Pair(null, AccountCache.loadFahipay(this@HomeActivity)) AccountCache.loadFahipay(this@HomeActivity, loginId)
} }
} }
} }
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity) val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
val mibAccounts = mibResults.flatMap { it.second }
val bmlResults = bmlJobs.map { (_, job) -> job.await() } val bmlResults = bmlJobs.map { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second } val bmlAccounts = bmlResults.flatMap { it.second }
val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
val app = application as BasedBankApp val app = application as BasedBankApp
app.mibAccounts = mibAccounts
AccountCache.save(this@HomeActivity, mibAccounts)
app.bmlAccounts = bmlAccounts app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts) app.fahipayAccounts = fahipayAccounts
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE binding.refreshIndicator.visibility = View.GONE
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshFinancing(app.mibSession, app.mibProfiles) for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
} }
} }
/** Filters MIB accounts whose profileId the user has hidden in settings. */
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
val store = CredentialStore(this@HomeActivity)
return filter { acc ->
if (acc.bank != "MIB") return@filter true
val loginId = acc.loginTag.removePrefix("mib_")
val hidden = store.getHiddenMibProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden
}
}
/** Filters MIB profiles the user has hidden for a given loginId. */
private fun List<MibProfile>.filterVisibleProfiles(loginId: String): List<MibProfile> {
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds(loginId)
if (hidden.isEmpty()) return this
return filter { it.profileId !in hidden }
}
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
fun applyProfileVisibility() {
val current = viewModel.accounts.value ?: return
viewModel.accounts.value = current.filterVisibleAccounts()
}
private fun refreshBmlLimits(session: BmlSession) { private fun refreshBmlLimits(session: BmlSession) {
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
lifecycleScope.launch { lifecycleScope.launch {
@@ -523,9 +620,12 @@ class HomeActivity : AppCompatActivity() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
} }
// Refresh all banks in background // Refresh all banks in background
refreshContacts(app.mibSession, app.mibProfiles) for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlContacts(app) refreshBmlContacts(app)
if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!) for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
} }
private fun refreshFahipayContacts(session: FahipaySession) { private fun refreshFahipayContacts(session: FahipaySession) {
@@ -561,9 +661,9 @@ class HomeActivity : AppCompatActivity() {
return result return result
} }
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) { private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (profiles.isEmpty()) return
val flow = MibLoginFlow(CredentialStore(this)) val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient() val contactsClient = MibContactsClient()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
@@ -588,8 +688,8 @@ class HomeActivity : AppCompatActivity() {
} }
if (allContacts.isNotEmpty()) { if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories) ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds() val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds) val bmlContacts = ContactsCache.loadBml(this@HomeActivity, store.getBmlLoginIds())
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity) val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity) val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts)) viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
@@ -603,9 +703,9 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp val app = application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
if (src.profileType == "FAHIPAY") { if (src.bank == "FAHIPAY") {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.fahipaySession ?: return@withContext null val sess = app.fahipaySessionFor(src) ?: return@withContext null
try { try {
val flow = FahipayLoginFlow() val flow = FahipayLoginFlow()
flow.setSessionCookie(sess.sessionCookie) flow.setSessionCookie(sess.sessionCookie)
@@ -613,14 +713,15 @@ class HomeActivity : AppCompatActivity() {
val profile = flow.fetchProfile(sess) val profile = flow.fetchProfile(sess)
val loginTag = "fahipay_${profile.profileId}" val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(flow.buildAccount(profile, balance, loginTag)) val accounts = listOf(flow.buildAccount(profile, balance, loginTag))
AccountCache.saveFahipay(this@HomeActivity, accounts) val loginId = src.loginTag.removePrefix("fahipay_")
app.fahipayAccounts = accounts AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val others = current.filter { it.profileType != "FAHIPAY" } val others = current.filter { it.bank != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh) viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) { } else if (src.bank == "BML") {
val loginId = src.loginTag.removePrefix("bml_") val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null val sess = app.bmlSessionFor(src) ?: return@withContext null
@@ -632,27 +733,46 @@ class HomeActivity : AppCompatActivity() {
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag } val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
viewModel.accounts.postValue(otherAccounts + fresh) viewModel.accounts.postValue(otherAccounts + fresh)
} else { } else {
val loginId = src.loginTag.removePrefix("mib_")
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null val store = CredentialStore(this@HomeActivity)
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null val hidden = store.getHiddenMibProfileIds(loginId)
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) } val profiles = app.mibProfilesMap[loginId] ?: emptyList()
catch (_: Exception) { null } val allVisible = profiles.filter { hidden.isEmpty() || it.profileId !in hidden }
val sess = app.mibSessions[loginId]
if (sess != null && allVisible.isNotEmpty()) {
try {
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(sess, allVisible, src.loginTag)
if (accounts.isNotEmpty()) return@withContext accounts
} catch (_: Exception) { }
}
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
try {
val flow = MibLoginFlow(store)
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
accounts.takeIf { it.isNotEmpty() }
} catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
// Replace accounts from this profile only, keep everything else // Replace accounts for this MIB login
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") } val others = current.filter { it.loginTag != src.loginTag }
val merged = others + fresh val newMibAccounts = app.mibAccounts.filter { it.loginTag != src.loginTag } + fresh
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") }) app.mibAccounts = newMibAccounts
viewModel.accounts.postValue(merged) AccountCache.save(this@HomeActivity, newMibAccounts)
viewModel.accounts.postValue(others + fresh)
} }
} }
} }
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) { private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (profiles.isEmpty()) return
val flow = MibLoginFlow(CredentialStore(this)) val flow = (application as BasedBankApp).mibFlowFor(loginId)
val client = MibFinancingClient() val client = MibFinancingClient()
lifecycleScope.launch { lifecycleScope.launch {
try { try {

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -7,35 +8,23 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.R import sh.sar.basedbank.R
class MoreFragment : Fragment() { class MoreFragment : Fragment() {
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
private val items = listOf(
NavItem(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItem(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItem(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItem(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_more, container, false) inflater.inflate(R.layout.fragment_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val list = view.findViewById<LinearLayout>(R.id.moreList) val list = view.findViewById<LinearLayout>(R.id.moreList)
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
for (item in items) { for (item in items) {
val row = inflater.inflate(R.layout.item_more_nav, list, false) val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon) row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title) row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) } row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
list.addView(row) list.addView(row)
} }

View File

@@ -0,0 +1,61 @@
package sh.sar.basedbank.ui.home
import android.content.SharedPreferences
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import sh.sar.basedbank.R
object NavCustomization {
data class NavItemDef(
val id: Int,
@DrawableRes val iconRes: Int,
@StringRes val titleRes: Int
)
/** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts),
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts),
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer),
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
)
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putInt("bottom_nav_slot_1", slots[0])
.putInt("bottom_nav_slot_2", slots[1])
.putInt("bottom_nav_slot_3", slots[2])
.apply()
}
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("quick_action_1", R.id.nav_transfer),
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
)
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
prefs.edit()
.putInt("quick_action_1", ids[0])
.putInt("quick_action_2", ids[1])
.apply()
}
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
val slots = getSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slots }
}
}

View File

@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@@ -16,7 +18,14 @@ class NavMoreSheetFragment : BottomSheetDialogFragment() {
inflater.inflate(R.layout.sheet_nav_more, container, false) inflater.inflate(R.layout.sheet_nav_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<NavigationView>(R.id.navMoreView).setNavigationItemSelectedListener { item -> val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val navView = view.findViewById<NavigationView>(R.id.navMoreView)
navView.menu.clear()
items.forEachIndexed { i, item ->
navView.menu.add(Menu.NONE, item.id, i, item.titleRes).setIcon(item.iconRes)
}
navView.setNavigationItemSelectedListener { item ->
dismiss() dismiss()
onNavigate?.invoke(item.itemId) onNavigate?.invoke(item.itemId)
true true

View File

@@ -71,8 +71,9 @@ class OtpFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val entries = mutableListOf<OtpEntry>() val entries = mutableListOf<OtpEntry>()
store.loadMibCredentials()?.let { creds -> for (loginId in store.getMibLoginIds()) {
val name = store.loadMibFullName() val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
} }
for (loginId in store.getBmlLoginIds()) { for (loginId in store.getBmlLoginIds()) {
@@ -88,20 +89,23 @@ class OtpFragment : Fragment() {
// Fetch real names in background if not yet cached, then refresh labels // Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
var changed = false var changed = false
if (store.loadMibFullName() == null) { for (loginId in store.getMibLoginIds()) {
app.mibSession?.let { session -> if (store.loadMibFullName(loginId) == null) {
val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) { val profile = withContext(Dispatchers.IO) {
try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null } try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null }
} }
if (profile != null) { if (profile != null) {
store.saveMibUserProfile(CredentialStore.MibUserProfile( store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = profile.username,
email = profile.email, email = profile.email,
mobile = profile.mobile, mobile = profile.mobile,
enrolled = profile.enrolled enrolled = profile.enrolled
)) ))
val idx = entries.indexOfFirst { it.seed == store.loadMibCredentials()?.otpSeed } val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
} }
} }

View File

@@ -14,6 +14,9 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector 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.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.AspectRatioStrategy
@@ -84,8 +87,23 @@ class QrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityQrScannerBinding.inflate(layoutInflater) binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root) 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.btnCancel.setOnClickListener { finish() }
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") } binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }

View File

@@ -1,29 +1,45 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import java.util.Collections
class SettingsAppearanceFragment : Fragment() { class SettingsAppearanceFragment : Fragment() {
private var _binding: FragmentSettingsAppearanceBinding? = null private var _binding: FragmentSettingsAppearanceBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: NavItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false) _binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation mode // Navigation mode
val isBottom = prefs.getBoolean("bottom_nav", false) val isBottom = prefs.getBoolean("bottom_nav", false)
@@ -32,8 +48,35 @@ class SettingsAppearanceFragment : Fragment() {
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply() prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
(activity as? HomeActivity)?.applyNavMode() (activity as? HomeActivity)?.applyNavMode()
updateShortcutsVisibility()
} }
// Quick actions
quickActions.clear()
quickActions.addAll(NavCustomization.getQuickActions(prefs))
quickActionAdapter = NavItemAdapter(quickActions) {
NavCustomization.saveQuickActions(prefs, quickActions)
}
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
// Bottom bar shortcuts
slots.clear()
slots.addAll(NavCustomization.getSlots(prefs))
slotAdapter = NavItemAdapter(slots) {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
}
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
binding.switchShowLabels.isChecked = showLabels
binding.switchShowLabels.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("bottom_nav_show_labels", checked).apply()
(activity as? HomeActivity)?.applyNavLabelVisibility()
}
updateShortcutsVisibility()
// Theme // Theme
val saved = prefs.getString("theme", "system") val saved = prefs.getString("theme", "system")
binding.themeToggle.check(when (saved) { binding.themeToggle.check(when (saved) {
@@ -63,6 +106,95 @@ class SettingsAppearanceFragment : Fragment() {
} }
} }
private fun setupNavItemRecyclerView(
rv: RecyclerView,
adapter: NavItemAdapter,
items: MutableList<Int>
) {
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
rv.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.START or ItemTouchHelper.END, 0
) {
override fun onMove(
rv: RecyclerView,
from: RecyclerView.ViewHolder,
to: RecyclerView.ViewHolder
): Boolean {
val fromPos = from.adapterPosition
val toPos = to.adapterPosition
Collections.swap(items, fromPos, toPos)
adapter.notifyItemMoved(fromPos, toPos)
adapter.onSave()
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
}).attachToRecyclerView(rv)
}
private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
val listLayout = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
val rows = available.map { item ->
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
listLayout.addView(row)
}
}
val scroll = ScrollView(ctx).apply { addView(listLayout) }
var dialog: androidx.appcompat.app.AlertDialog? = null
dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.settings_bottom_bar_select)
.setView(scroll)
.setNegativeButton(R.string.cancel, null)
.show()
available.forEachIndexed { i, item ->
rows[i].setOnClickListener {
items[slotIndex] = item.id
adapter.onSave()
adapter.notifyItemChanged(slotIndex)
dialog?.dismiss()
}
}
}
private inner class NavItemAdapter(
val items: MutableList<Int>,
val onSave: () -> Unit
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val ivNavIcon: ImageView = view.findViewById(R.id.ivNavIcon)
val tvNavLabel: TextView = view.findViewById(R.id.tvNavLabel)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_nav_slot, parent, false)
val itemWidth = if (parent.measuredWidth > 0) parent.measuredWidth / items.size
else RecyclerView.LayoutParams.WRAP_CONTENT
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
return VH(view)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: VH, position: Int) {
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
holder.ivNavIcon.setImageResource(def.iconRes)
holder.tvNavLabel.setText(def.titleRes)
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.settings_appearance) requireActivity().title = getString(R.string.settings_appearance)

View File

@@ -7,6 +7,7 @@ import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
@@ -14,6 +15,7 @@ import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
@@ -58,31 +60,18 @@ class SettingsLoginsFragment : Fragment() {
val container = binding.loginsContainer val container = binding.loginsContainer
container.removeAllViews() container.removeAllViews()
val hasMib = store.hasMibCredentials() val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val fahipayLoginIds = store.getFahipayLoginIds()
binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) View.VISIBLE else View.GONE binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
if (hasMib) { for (loginId in mibLoginIds) {
val profile = store.loadMibUserProfile() val profile = store.loadMibUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
val profileNames = AccountCache.load(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct() val mibProfiles = store.loadMibProfiles(loginId)
addLoginRow(container, R.drawable.mib_logo, displayName) { addLoginRow(container, R.drawable.mib_logo, displayName) {
showLoginDetails( showMibLoginDetails(store, loginId, profile, mibProfiles)
title = getString(R.string.mib_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (profileNames.isNotEmpty()) {
appendLine()
appendLine(getString(R.string.login_detail_profiles))
profileNames.forEach { appendLine("$it") }
}
}.trim(),
onLogout = { confirmLogout(getString(R.string.mib_name)) { logoutMib(store) } }
)
} }
} }
@@ -110,8 +99,8 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
if (hasFahipay) { for (loginId in fahipayLoginIds) {
val profile = store.loadFahipayUserProfile() val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) { addLoginRow(container, R.drawable.fahipay_logo, displayName) {
showLoginDetails( showLoginDetails(
@@ -122,7 +111,7 @@ class SettingsLoginsFragment : Fragment() {
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}") if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}") if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}")
}.trim(), }.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store) } } onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
) )
} }
} }
@@ -154,6 +143,118 @@ class SettingsLoginsFragment : Fragment() {
container.addView(row) container.addView(row)
} }
private fun showMibLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.MibUserProfile?,
mibProfiles: List<MibProfile>
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenMibProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
scroll.addView(container)
// Account info lines
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
})
}
if (mibProfiles.isNotEmpty()) {
if (profile != null) {
container.addView(View(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
}
setBackgroundColor(0x1F000000)
})
}
container.addView(TextView(ctx).apply {
text = getString(R.string.login_detail_profiles)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (8 * dp).toInt()
}
})
}
// Build checkbox rows — wired up after dialog.show() so we can reference the Save button
val checkboxRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
textCol.addView(TextView(ctx).apply {
text = p.name
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
})
if (p.cifType.isNotBlank()) {
textCol.addView(TextView(ctx).apply {
text = p.cifType
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
}
val cb = CheckBox(ctx).apply { isChecked = p.profileId !in hidden }
row.addView(textCol)
row.addView(cb)
container.addView(row)
p to cb
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.mib_name))
.setView(scroll)
.setPositiveButton(R.string.save, null) // null — set manually after show()
.setNeutralButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.mib_name)) { logoutMib(store, loginId) }
}
.show()
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
checkboxRows.forEach { (p, cb) ->
cb.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
val atLeastOneVisible = mibProfiles.any { it.profileId !in hidden }
saveBtn.isEnabled = hidden != originalHidden && atLeastOneVisible
}
}
saveBtn.setOnClickListener {
store.setHiddenMibProfileIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
}
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) { private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(title) .setTitle(title)
@@ -172,12 +273,16 @@ class SettingsLoginsFragment : Fragment() {
.show() .show()
} }
private fun logoutMib(store: CredentialStore) { private fun logoutMib(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearMibCredentials() store.clearMibCredentials(loginId)
ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply() ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply()
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.accounts = emptyList(); app.mibSession = null; app.mibProfiles = emptyList() app.mibSessions.remove(loginId)
app.mibProfilesMap.remove(loginId)
app.mibLoginFlows.remove(loginId)
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()
@@ -194,11 +299,12 @@ class SettingsLoginsFragment : Fragment() {
buildLoginsSection() buildLoginsSection()
} }
private fun logoutFahipay(store: CredentialStore) { private fun logoutFahipay(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearFahipayCredentials(); store.clearFahipaySession() store.clearFahipayCredentials(loginId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.fahipaySession = null; app.fahipayAccounts = emptyList() app.fahipaySessions.remove(loginId)
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != "fahipay_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()

View File

@@ -35,8 +35,6 @@ class SettingsSecurityFragment : Fragment() {
val canUseBiometrics = BiometricManager.from(requireContext()) val canUseBiometrics = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (canUseBiometrics) { if (canUseBiometrics) {
binding.rowBiometrics.visibility = View.VISIBLE
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false) val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
binding.switchBiometrics.isChecked = unlockEnabled binding.switchBiometrics.isChecked = unlockEnabled
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false) binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
@@ -54,6 +52,10 @@ class SettingsSecurityFragment : Fragment() {
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked -> binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply() prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
} }
} else {
binding.tvBiometricsHint.visibility = View.VISIBLE
binding.switchBiometrics.isEnabled = false
binding.switchBiometricsTransfer.isEnabled = false
} }
// Auto-lock // Auto-lock

View File

@@ -19,10 +19,12 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -48,6 +50,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
@@ -62,7 +65,9 @@ class TransferFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession private val session get() = selectedAccount
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyMibSession()
private fun bmlSessionFor(account: MibAccount?) = private fun bmlSessionFor(account: MibAccount?) =
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) } account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession() ?: (requireActivity().application as BasedBankApp).anyBmlSession()
@@ -145,8 +150,11 @@ class TransferFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
binding.btnTransfer.isEnabled = false
binding.btnTransfer.setOnClickListener { initiateTransfer() } binding.btnTransfer.setOnClickListener { initiateTransfer() }
binding.etAmount.addTextChangedListener { updateTransferButton() }
// Pre-select contact if navigated from contacts page // Pre-select contact if navigated from contacts page
arguments?.getString(ARG_ACCOUNT)?.let { account -> arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly( prefillToDirectly(
@@ -159,6 +167,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() { private fun setupFromDropdown() {
binding.btnClearFromInfo.setOnClickListener { binding.btnClearFromInfo.setOnClickListener {
selectedAccount = null selectedAccount = null
@@ -166,6 +190,7 @@ class TransferFragment : Fragment() {
binding.cardFromInfo.visibility = View.GONE binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false) binding.actvFrom.setText("", false)
updateTransferButton()
} }
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
@@ -177,6 +202,7 @@ class TransferFragment : Fragment() {
selectedAccount = picked selectedAccount = picked
updateAmountPrefix(picked) updateAmountPrefix(picked)
showFromCard(picked) showFromCard(picked)
updateTransferButton()
} }
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT) val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
@@ -186,22 +212,22 @@ class TransferFragment : Fragment() {
selectedAccount = match selectedAccount = match
updateAmountPrefix(match) updateAmountPrefix(match)
showFromCard(match) showFromCard(match)
updateTransferButton()
} }
} }
} }
} }
private fun showFromCard(account: MibAccount) { private fun showFromCard(account: MibAccount) {
val isBml = account.profileType.startsWith("BML") val colorHex = when (account.bank) {
val colorHex = when { "BML" -> "#0066A1"
isBml -> "#0066A1" "FAHIPAY" -> "#15BEA7"
account.profileType == "FAHIPAY" -> "#15BEA7" else -> "#FE860E"
else -> "#FE860E"
} }
val bankLabel = when { val bankLabel = when (account.bank) {
isBml -> "BML" "BML" -> "BML"
account.profileType == "FAHIPAY" -> "FP" "FAHIPAY" -> "FP"
else -> null else -> null
} }
val typeLabel = when { val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card" account.profileType == "BML_PREPAID" -> "Prepaid Card"
@@ -217,7 +243,7 @@ class TransferFragment : Fragment() {
binding.tilFrom.visibility = View.GONE binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE binding.cardFromInfo.visibility = View.VISIBLE
if (!isBml && account.profileImageHash != null) { if (account.bank != "BML" && account.profileImageHash != null) {
loadFromPhoto(account.profileImageHash) loadFromPhoto(account.profileImageHash)
} }
} }
@@ -227,7 +253,7 @@ class TransferFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = app.mibLoginFlow.fetchProfileImage(sess, hash) ?: return@launch val base64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -254,6 +280,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null binding.tilTo.error = null
updateTransferButton()
} }
binding.etTo.addTextChangedListener { binding.etTo.addTextChangedListener {
@@ -265,6 +292,7 @@ class TransferFragment : Fragment() {
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE
updateTransferButton()
} }
} }
} }
@@ -295,7 +323,7 @@ class TransferFragment : Fragment() {
} }
// Fahipay source: only phone numbers are supported // Fahipay source: only phone numbers are supported
if (selectedAccount?.profileType == "FAHIPAY") { if (selectedAccount?.bank == "FAHIPAY") {
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) { if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupFahipayTarget(accountNumber) lookupFahipayTarget(accountNumber)
} else { } else {
@@ -311,9 +339,9 @@ class TransferFragment : Fragment() {
return return
} }
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true val isBmlSource = selectedAccount?.bank == "BML"
binding.tilTo.isEnabled = false startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null var errorMsg: String? = null
@@ -357,7 +385,7 @@ class TransferFragment : Fragment() {
} }
} }
} }
binding.tilTo.isEnabled = true stopLookupLoading()
if (info != null) { if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList() val accounts = viewModel.accounts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber } val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
@@ -377,6 +405,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
saveToRecents(info) saveToRecents(info)
when { when {
@@ -392,7 +421,7 @@ class TransferFragment : Fragment() {
} }
private fun lookupFahipayTarget(number: String) { private fun lookupFahipayTarget(number: String) {
binding.tilTo.isEnabled = false startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
data class LookupResult( data class LookupResult(
val dhiraagu: DhiraaguClient.Result, val dhiraagu: DhiraaguClient.Result,
@@ -419,7 +448,7 @@ class TransferFragment : Fragment() {
LookupResult(d, o) LookupResult(d, o)
} }
} }
binding.tilTo.isEnabled = true stopLookupLoading()
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() } val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
@@ -503,6 +532,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber } val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
if (contact != null) { if (contact != null) {
@@ -550,7 +580,7 @@ class TransferFragment : Fragment() {
binding.tilAmount.error = null binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.profileType.startsWith("BML") val isSrcBml = src.bank == "BML"
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" } val currency = src.currencyName.ifBlank { "MVR" }
@@ -561,10 +591,10 @@ class TransferFragment : Fragment() {
if (isSrcBml && isDestMib && currency == "USD") { if (isSrcBml && isDestMib && currency == "USD") {
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber } val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
if (!hasBmlContact) { if (!hasBmlContact) {
AlertDialog.Builder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer_bml_contact_required_title) .setTitle(R.string.transfer_bml_contact_required_title)
.setMessage(R.string.transfer_bml_contact_required_msg) .setMessage(R.string.transfer_bml_contact_required_msg)
.setPositiveButton("OK", null) .setPositiveButton(R.string.close, null)
.show() .show()
return return
} }
@@ -586,6 +616,7 @@ class TransferFragment : Fragment() {
val doTransfer: () -> Unit = { val doTransfer: () -> Unit = {
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) { val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) { if (!isSrcBml) {
@@ -595,6 +626,7 @@ class TransferFragment : Fragment() {
} }
} }
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) { if (ok && receipt != null) {
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
@@ -606,7 +638,7 @@ class TransferFragment : Fragment() {
} }
} }
val dialogBuilder = AlertDialog.Builder(requireContext()) val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer) .setTitle(R.string.transfer)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() } .setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@@ -687,12 +719,14 @@ class TransferFragment : Fragment() {
): Triple<Boolean, String, TransferReceiptData?> { ): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("mib_")
// Switch to the profile that owns the source account // Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) { if (src.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } val profiles = app.mibProfilesMap[loginId] ?: emptyList()
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile) val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
} }
val otp = CredentialStore(requireContext()).loadMibCredentials()?.otpSeed val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462" val currencyCode = if (src.currencyName == "USD") "840" else "462"
@@ -829,6 +863,11 @@ class TransferFragment : Fragment() {
} }
} }
private fun updateTransferButton() {
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
}
private fun clearForm() { private fun clearForm() {
selectedAccount = null selectedAccount = null
binding.actvFrom.setText("", false) binding.actvFrom.setText("", false)
@@ -859,7 +898,7 @@ class TransferFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
@@ -990,9 +1029,11 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true) val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
b.tvDropdownAccountName.text = acc.accountBriefName val isBmlAccount = acc.bank == "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.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}" b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: ""
b.root.alpha = if (inactive) 0.4f else 1f b.root.alpha = if (inactive) 0.4f else 1f
b.root b.root
} }

View File

@@ -10,6 +10,8 @@ import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -61,10 +63,10 @@ class TransferHistoryFragment : Fragment() {
var fahipayTotal: Int = -1 var fahipayTotal: Int = -1
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
} }
@@ -87,6 +89,15 @@ class TransferHistoryFragment : Fragment() {
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
@@ -142,14 +153,13 @@ class TransferHistoryFragment : Fragment() {
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession
lifecycleScope.launch { lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) { val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<Transaction>() val results = mutableListOf<Transaction>()
// BML accounts: fetch in parallel // BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") } val bmlStates = activeStates.filter { it.account.bank == "BML" }
results.addAll(bmlStates.map { state -> results.addAll(bmlStates.map { state ->
async { async {
try { try {
@@ -187,9 +197,9 @@ class TransferHistoryFragment : Fragment() {
}.awaitAll().flatten()) }.awaitAll().flatten())
// Fahipay accounts // Fahipay accounts
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" } val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
for (state in fahipayStates) { for (state in fahipayStates) {
val session = app.fahipaySession ?: continue val session = app.fahipaySessionFor(state.account) ?: continue
try { try {
val flow = FahipayLoginFlow() val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie) flow.setSessionCookie(session.sessionCookie)
@@ -206,27 +216,28 @@ class TransferHistoryFragment : Fragment() {
} }
// MIB accounts: serialized per profile, protected by mutex to prevent session race // MIB accounts: serialized per profile, protected by mutex to prevent session race
val mibStates = activeStates.filter { val mibStates = activeStates.filter { it.account.bank == "MIB" }
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY" for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) {
} val session = app.mibSessions[loginId] ?: continue
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) { for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break app.mibMutex.withLock {
app.mibMutex.withLock { val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId } val profile = profiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) { for (state in states) {
try { try {
val (list, total) = MibHistoryClient().fetchHistory( val (list, total) = MibHistoryClient().fetchHistory(
session = session, session = session,
accountNo = state.account.accountNumber, accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart, start = state.mibNextStart,
pageSize = pageSize pageSize = pageSize
) )
if (total > 0) state.mibTotalCount = total if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize) state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list) results.addAll(list)
} catch (_: Exception) {} } catch (_: Exception) {}
}
} }
} }
} }
@@ -280,7 +291,7 @@ class TransferHistoryFragment : Fragment() {
return return
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch

View File

@@ -154,11 +154,11 @@ class TransferReceiptFragment : Fragment() {
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
@@ -313,6 +313,12 @@ class TransferReceiptFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = "Receipt" requireActivity().title = "Receipt"
(activity as? HomeActivity)?.setBottomNavVisible(false)
}
override fun onPause() {
super.onPause()
(activity as? HomeActivity)?.setBottomNavVisible(true)
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@@ -54,7 +54,7 @@ class CredentialsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
when (bankType) { when (bankType) {
"BML" -> { "BML" -> {
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector) binding.ivBankLogo.setImageResource(R.drawable.bml_logo_long)
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc) binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
} }
"FAHIPAY" -> { "FAHIPAY" -> {
@@ -67,11 +67,27 @@ class CredentialsFragment : Fragment() {
} }
} }
binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() } 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") { if (bankType != "FAHIPAY") {
binding.etOtpSeed.addTextChangedListener(object : TextWatcher { 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 beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}) })
@@ -88,10 +104,35 @@ class CredentialsFragment : Fragment() {
otpHandler.removeCallbacks(otpRunnable) 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() { private fun updateOtpDisplay() {
val seed = binding.etOtpSeed.text.toString().trim() val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val seed = resolveOtpSeed(otpSeedRaw)
if (seed.isEmpty()) { 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 return
} }
try { try {
@@ -104,7 +145,7 @@ class CredentialsFragment : Fragment() {
binding.otpTimer.progress = remaining binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE binding.cardOtp.visibility = View.VISIBLE
} catch (e: Exception) { } 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 username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString() 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()) { if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
binding.tvError.text = "Please fill in all fields" binding.tvError.text = "Please fill in all fields"
@@ -129,6 +170,7 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password) val passwordHash = MibLoginFlow.hashPassword(password)
val loginId = username
val flow = MibLoginFlow(CredentialStore(requireContext())) val flow = MibLoginFlow(CredentialStore(requireContext()))
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -137,11 +179,12 @@ class CredentialsFragment : Fragment() {
flow.login(username, passwordHash, otpSeed) flow.login(username, passwordHash, otpSeed)
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveMibCredentials(username, passwordHash, otpSeed) store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
flow.lastSession?.let { s -> flow.lastSession?.let { s ->
val profile = flow.fetchPersonalProfile(s) val profile = flow.fetchPersonalProfile(s)
if (profile != null) store.saveMibUserProfile( if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile( CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = profile.username,
@@ -152,11 +195,15 @@ class CredentialsFragment : Fragment() {
) )
} }
} }
AccountCache.save(requireContext(), accounts) store.saveMibProfiles(loginId, flow.lastProfiles)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.accounts = accounts // Merge with any existing MIB accounts from other logins
app.mibSession = flow.lastSession app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts
app.mibProfiles = flow.lastProfiles app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } + accounts
AccountCache.save(requireContext(), app.mibAccounts)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -173,7 +220,7 @@ class CredentialsFragment : Fragment() {
private fun attemptBmlLogin() { private fun attemptBmlLogin() {
val username = binding.etUsername.text.toString().trim() val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString() 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()) { if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
binding.tvError.text = "Please fill in all fields" binding.tvError.text = "Please fill in all fields"
@@ -333,11 +380,13 @@ class CredentialsFragment : Fragment() {
val b = flow.fetchBalance(session) val b = flow.fetchBalance(session)
Pair(p, b) Pair(p, b)
} }
val loginTag = "fahipay_${profile.profileId}" val loginId = profile.profileId
val loginTag = "fahipay_$loginId"
val account = flow.buildAccount(profile, balance, loginTag) val account = flow.buildAccount(profile, balance, loginTag)
store.saveFahipayCredentials(idCard, password) store.saveFahipayCredentials(loginId, idCard, password)
store.saveFahipaySession(session.authId, session.sessionCookie) store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
store.saveFahipayUserProfile( store.saveFahipayUserProfile(
loginId,
CredentialStore.FahipayUserProfile( CredentialStore.FahipayUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
email = profile.email, email = profile.email,
@@ -348,11 +397,11 @@ class CredentialsFragment : Fragment() {
linkedAccounts = profile.linkedAccounts linkedAccounts = profile.linkedAccounts
) )
) )
AccountCache.saveFahipay(requireContext(), listOf(account)) AccountCache.saveFahipay(requireContext(), loginId, listOf(account))
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.fahipaySession = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = listOf(account) app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
app.accounts = app.accounts + listOf(account) app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)

View File

@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.login package sh.sar.basedbank.ui.login
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import sh.sar.basedbank.databinding.ActivityLoginBinding import sh.sar.basedbank.databinding.ActivityLoginBinding
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
@@ -10,7 +12,13 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater) binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root) 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
}
} }
} }

View File

@@ -3,10 +3,14 @@ package sh.sar.basedbank.ui.onboarding
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat 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 androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -17,46 +21,73 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private lateinit var binding: ActivityOnboardingBinding private lateinit var binding: ActivityOnboardingBinding
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityOnboardingBinding.inflate(layoutInflater) binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root) 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) 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
}
ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { view, insets ->
val statusBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(view.paddingLeft, statusBar.top, view.paddingRight, view.paddingBottom)
insets
}
val adapter = OnboardingPagerAdapter(this) val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter binding.viewPager.adapter = adapter
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach() TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
// Disable tap-to-navigate on dots: touch listener must be on the individual
// Pre-select language chip without triggering the listener // tab views inside SlidingTabStrip (child 0), because they consume ACTION_DOWN
val savedLang = prefs.getString("language", null) // before the TabLayout's own touch listener ever fires.
binding.languageChipGroup.setOnCheckedStateChangeListener(null) val tabStrip = binding.dotsIndicator.getChildAt(0) as? android.view.ViewGroup
when (savedLang) { tabStrip?.let {
"en" -> binding.chipEnglish.isChecked = true for (i in 0 until it.childCount) {
"dv" -> binding.chipDhivehi.isChecked = true it.getChildAt(i).setOnTouchListener { _, _ -> true }
}
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isNotEmpty()) {
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
} }
} }
// Pre-select language button without triggering the listener
val savedLang = prefs.getString("language", null) ?: "en".also { selectLanguage(it) }
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() { binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE binding.languageSection.visibility = if (position == 0) View.VISIBLE else View.GONE
// Block forward swipe on slide 1 until security is set up binding.viewPager.isUserInputEnabled = when {
if (position == 1) { position > 2 -> false
binding.viewPager.isUserInputEnabled = position == 1 -> prefs.getString("security_method", null) != null
prefs.getString("security_method", null) != null else -> true
} else {
binding.viewPager.isUserInputEnabled = true
} }
updateButtons(position, adapter.itemCount) updateButtons(position, adapter.itemCount)
} }
}) })
binding.languageChipGroup.visibility = View.VISIBLE binding.languageSection.visibility = View.VISIBLE
updateButtons(0, adapter.itemCount) updateButtons(0, adapter.itemCount)
binding.btnNext.setOnClickListener { binding.btnNext.setOnClickListener {
@@ -71,10 +102,21 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
} }
} }
override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
}
// Called by SecuritySetupFragment when setup is complete // Called by SecuritySetupFragment when setup is complete
override fun onSecuritySetupComplete() { override fun onSecuritySetupComplete() {
binding.viewPager.isUserInputEnabled = true 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) { private fun selectLanguage(lang: String) {
@@ -83,23 +125,34 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3) 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) { private fun updateButtons(position: Int, count: Int) {
val langSelected = prefs.getString("language", null) != null val langSelected = prefs.getString("language", null) != null
val securityDone = prefs.getString("security_method", null) != null val securityDone = prefs.getString("security_method", null) != null
val isLast = position == count - 1 val isLast = position == count - 1
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE 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 = if (isLast) View.GONE else View.VISIBLE
binding.btnNext.visibility = when {
isLast -> View.GONE
position == 1 && !securityDone -> View.GONE
else -> View.VISIBLE
}
binding.btnNext.isEnabled = when (position) { binding.btnNext.isEnabled = when (position) {
0 -> langSelected 0 -> langSelected
1 -> securityDone 1 -> securityDone
else -> true else -> true // position 2 (configure) has no gate
} }
} }
} }

View File

@@ -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
}
}

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.ScrollView
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
@@ -12,6 +14,7 @@ class OnboardingFragment : Fragment() {
private var _binding: FragmentOnboardingSlideBinding? = null private var _binding: FragmentOnboardingSlideBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var scrolledToBottom = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false) _binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
@@ -19,17 +22,45 @@ class OnboardingFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val title = requireArguments().getString(ARG_TITLE, "") val titleRes = requireArguments().getInt(ARG_TITLE)
val desc = requireArguments().getString(ARG_DESC, "") val descRes = requireArguments().getInt(ARG_DESC)
val icon = requireArguments().getInt(ARG_ICON, 0) val icon = requireArguments().getInt(ARG_ICON, 0)
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false) 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.icon.setImageResource(icon)
binding.title.text = title binding.title.text = getString(titleRes)
binding.description.text = desc 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 // On the first slide, show the two placeholder cards for upcoming banks
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE 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() { override fun onDestroyView() {
@@ -38,17 +69,20 @@ class OnboardingFragment : Fragment() {
} }
companion object { companion object {
const val RESULT_SCROLLED_TO_BOTTOM = "scroll_to_bottom"
private const val ARG_TITLE = "title" private const val ARG_TITLE = "title"
private const val ARG_DESC = "desc" private const val ARG_DESC = "desc"
private const val ARG_ICON = "icon" private const val ARG_ICON = "icon"
private const val ARG_IS_FIRST = "is_first" private const val ARG_IS_FIRST = "is_first"
private const val ARG_IS_LAST = "is_last"
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply { fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
arguments = bundleOf( arguments = bundleOf(
ARG_TITLE to slide.title, ARG_TITLE to slide.titleRes,
ARG_DESC to slide.description, ARG_DESC to slide.descRes,
ARG_ICON to slide.iconRes, ARG_ICON to slide.iconRes,
ARG_IS_FIRST to slide.isFirst ARG_IS_FIRST to slide.isFirst,
ARG_IS_LAST to slide.isLast
) )
} }
} }

View File

@@ -9,36 +9,39 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
private val slides = listOf( private val slides = listOf(
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_1), titleRes = R.string.onboarding_title_1,
description = activity.getString(R.string.onboarding_desc_1), descRes = R.string.onboarding_desc_1,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = true isFirst = true
), ),
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_2), titleRes = R.string.onboarding_title_2,
description = activity.getString(R.string.onboarding_desc_2), descRes = R.string.onboarding_desc_2,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = false isFirst = false
), ),
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_3), titleRes = R.string.onboarding_title_3,
description = activity.getString(R.string.onboarding_desc_3), descRes = R.string.onboarding_desc_3,
iconRes = R.drawable.ic_launcher_foreground, 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) { override fun createFragment(position: Int): Fragment = when (position) {
1 -> SecuritySetupFragment() 1 -> SecuritySetupFragment()
else -> OnboardingFragment.newInstance(slides[position]) 2 -> OnboardingConfigureFragment()
else -> OnboardingFragment.newInstance(slides[position - if (position > 2) 1 else 0])
} }
} }
data class OnboardingSlide( data class OnboardingSlide(
val title: String, val titleRes: Int,
val description: String, val descRes: Int,
val iconRes: Int, val iconRes: Int,
val isFirst: Boolean val isFirst: Boolean,
val isLast: Boolean = false
) )

View File

@@ -86,17 +86,21 @@ class PatternView @JvmOverloads constructor(
if (errorState) return false if (errorState) return false
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
parent?.requestDisallowInterceptTouchEvent(true)
recording = true; selected.clear() recording = true; selected.clear()
hit(event.x, event.y) hit(event.x, event.y)
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
parent?.requestDisallowInterceptTouchEvent(true)
touchX = event.x; touchY = event.y touchX = event.x; touchY = event.y
hit(event.x, event.y) hit(event.x, event.y)
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent?.requestDisallowInterceptTouchEvent(false)
recording = false recording = false
invalidate() invalidate()
onPatternComplete?.invoke(selected.map { it.index }) if (event.action == MotionEvent.ACTION_UP)
onPatternComplete?.invoke(selected.map { it.index })
} }
} }
invalidate() invalidate()

View File

@@ -7,7 +7,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -21,6 +20,7 @@ class SecuritySetupFragment : Fragment() {
interface Callback { interface Callback {
fun onSecuritySetupComplete() fun onSecuritySetupComplete()
fun onSecuritySetupReset()
} }
companion object { companion object {
@@ -33,7 +33,7 @@ class SecuritySetupFragment : Fragment() {
private var _b: FragmentSecuritySetupBinding? = null private var _b: FragmentSecuritySetupBinding? = null
private val b get() = _b!! 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 var step = Step.CHOOSE
private val pinDigits = mutableListOf<Int>() private val pinDigits = mutableListOf<Int>()
@@ -48,9 +48,6 @@ class SecuritySetupFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false 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.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) } b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
@@ -62,20 +59,21 @@ class SecuritySetupFragment : Fragment() {
} }
} }
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) } 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.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() buildNumpad()
goTo(Step.CHOOSE)
if (!changeMode && prefs.getString("security_method", null) != null) {
goTo(Step.CONFIGURED)
} else {
goTo(Step.CHOOSE)
}
} }
private fun buildNumpad() { private fun buildNumpad() {
@@ -144,7 +142,7 @@ class SecuritySetupFragment : Fragment() {
Step.PIN_CONFIRM -> { Step.PIN_CONFIRM -> {
if (entered == firstPin) { if (entered == firstPin) {
saveCredential("pin", entered) saveCredential("pin", entered)
goToBiometricOrFinish() finishSetup()
} else { } else {
b.tvPinDots.text = getString(R.string.pin_no_match) b.tvPinDots.text = getString(R.string.pin_no_match)
pinDigits.clear() pinDigits.clear()
@@ -172,7 +170,7 @@ class SecuritySetupFragment : Fragment() {
Step.PATTERN_CONFIRM -> { Step.PATTERN_CONFIRM -> {
if (pattern == firstPattern) { if (pattern == firstPattern) {
saveCredential("pattern", pattern.joinToString("")) saveCredential("pattern", pattern.joinToString(""))
goToBiometricOrFinish() finishSetup()
} else { } else {
b.patternView.showError() b.patternView.showError()
b.tvPatternStatus.text = getString(R.string.pattern_no_match) 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) { private fun goTo(s: Step) {
step = s 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.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.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.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) { when (s) {
Step.PIN_ENTER -> { Step.PIN_ENTER -> {
@@ -236,9 +217,6 @@ class SecuritySetupFragment : Fragment() {
val hash = pbkdf2(input, salt) val hash = pbkdf2(input, salt)
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit() requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
.putString("security_method", method) .putString("security_method", method)
// Remove legacy plaintext fields if they exist from an old install
.remove("security_salt")
.remove("security_hash")
.apply() .apply()
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash) CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
} }
@@ -256,6 +234,7 @@ class SecuritySetupFragment : Fragment() {
private fun finishSetup() { private fun finishSetup() {
val cb = activity as? Callback val cb = activity as? Callback
if (cb != null) { if (cb != null) {
goTo(Step.CONFIGURED)
cb.onSecuritySetupComplete() cb.onSecuritySetupComplete()
} else { } else {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()

View File

@@ -9,16 +9,17 @@ object AccountCache {
private const val PREFS = "account_cache" private const val PREFS = "account_cache"
private const val KEY_MIB = "mib_accounts" private const val KEY_MIB = "mib_accounts"
private const val KEY_FAHIPAY = "fahipay_accounts"
private fun bmlKey(loginId: String) = "bml_accounts_$loginId" private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
fun save(context: Context, accounts: List<MibAccount>) { fun save(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
put("bank", acc.bank)
put("profileName", acc.profileName) put("profileName", acc.profileName)
put("profileType", acc.profileType) put("profileType", acc.profileType)
put("cifType", acc.cifType)
put("accountNumber", acc.accountNumber) put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName) put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName) put("currencyName", acc.currencyName)
@@ -68,20 +69,21 @@ object AccountCache {
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = "BML",
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
@@ -90,7 +92,7 @@ object AccountCache {
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> = fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadBml(context, it) } loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, accounts: List<MibAccount>) { fun saveFahipay(context: Context, loginId: String, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -110,36 +112,40 @@ object AccountCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_FAHIPAY, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
} }
fun loadFahipay(context: Context): List<MibAccount> { fun loadFahipay(context: Context, loginId: String): List<MibAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_FAHIPAY, null) ?: return emptyList() .getString(fahipayKey(loginId), null) ?: return emptyList()
return try { return try {
val arr = JSONArray(CacheEncryption.decrypt(raw)) val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = "FAHIPAY",
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
fun loadFahipay(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadFahipay(context, it) }
fun clear(context: Context) { fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
} }
@@ -153,20 +159,22 @@ object AccountCache {
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
profileName = o.optString("profileName"), bank = o.optString("bank", "MIB"),
profileType = o.optString("profileType"), profileName = o.optString("profileName"),
accountNumber = o.optString("accountNumber"), profileType = o.optString("profileType"),
cifType = o.optString("cifType", ""),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"), accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"), availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"), currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"), blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() }, profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "") profileId = o.optString("profileId", "")
) )
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -0,0 +1,15 @@
package sh.sar.basedbank.util
/**
* Standard display model for the account history header card — produced by
* per-bank parsers, consumed by AccountHistoryAdapter with no bank logic.
*/
data class AccountHistoryDisplay(
val name: String,
val number: String,
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
val availableBalance: String, // formatted "CCY amount"
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
val blockedBalance: String? // null if zero or not applicable
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
import sh.sar.basedbank.util.mibapi.MibHistoryParser
object AccountHistoryParser {
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) {
"BML" -> BmlHistoryParser.displayData(account)
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
"MIB" -> MibHistoryParser.displayData(account)
else -> null
}
}

View File

@@ -0,0 +1,11 @@
package sh.sar.basedbank.util
data class AccountListDisplay(
val name: String,
val number: String,
val typeLabel: String,
val balance: String,
val isCard: Boolean = false,
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
val statusLabel: String? = null // null = active; shown as status pill if set
)

View File

@@ -0,0 +1,16 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
import sh.sar.basedbank.util.mibapi.MibAccountParser
object AccountListParser {
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
"BML" -> BmlDashboardParser.displayData(account)
"FAHIPAY" -> FahipayAccountParser.displayData(account)
"MIB" -> MibAccountParser.displayData(account)
else -> null
}
}

View File

@@ -0,0 +1,22 @@
package sh.sar.basedbank.util
/**
* Standard display model for a contact row — produced by per-bank parsers,
* consumed by UI. No bank-specific logic in adapters or fragments.
*/
data class ContactDisplay(
val id: String, // internal contact ID (benefNo)
val name: String, // display nickname
val realName: String, // legal name — used for search
val accountNumber: String,
val categoryId: String, // for tab filtering
val network: TransferNetwork,
val bankColor: String,
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
val imageHash: String?,
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
val canTransfer: Boolean,
val canEdit: Boolean,
val canDelete: Boolean
)

View File

@@ -0,0 +1,18 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.bmlapi.BmlContactParser
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
import sh.sar.basedbank.util.mibapi.MibContactParser
object ContactListParser {
fun from(contact: MibBeneficiary): ContactDisplay? = when {
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
else -> null
}
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
}

View File

@@ -0,0 +1,40 @@
package sh.sar.basedbank.util
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibContactsClient
/**
* Behaviour dispatcher for contact operations.
* Routes add/delete to the correct bank API based on TransferNetwork.
* UI code never inspects the network or bank type directly.
*/
object ContactManager {
/** Deletes [contact] via the appropriate bank API. Returns true on success. */
suspend fun delete(contact: ContactDisplay, app: BasedBankApp): Boolean = when (contact.network) {
TransferNetwork.BML -> deleteBml(contact, app)
TransferNetwork.FAHIPAY -> false // Fahipay contacts are read-only
TransferNetwork.MIB, TransferNetwork.LOCAL, TransferNetwork.SWIFT -> deleteMib(contact, app)
}
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.id.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.anyMibSession() ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val (loginId, profile) = app.mibProfilesMap.entries
.firstNotNullOfOrNull { (id, profiles) ->
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
} ?: (null to null)
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.id)
} catch (_: Exception) { false }
}
}

View File

@@ -21,104 +21,138 @@ class CredentialStore(context: Context) {
data class BmlCredentials(val username: String, val password: String, val otpSeed: String) data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
data class FahipayCredentials(val idCard: String, val password: String) data class FahipayCredentials(val idCard: String, val password: String)
// ── MIB login credentials ───────────────────────────────────────────────── // ── MIB login credentials (multi-login, keyed by loginId = username) ─────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username") fun getMibLoginIds(): List<String> {
fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card") maybeMigrateLegacyMib()
val json = prefs.getString("mib_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 saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
private fun addMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveMibCredentials(loginId: String, username: String, passwordHash: String, otpSeed: String) {
addMibLoginId(loginId)
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("mib_enc_username", encrypt(username, key)) .putString("mib_${loginId}_enc_password_hash", encrypt(passwordHash, key))
.putString("mib_enc_password_hash", encrypt(passwordHash, key)) .putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
.putString("mib_enc_otp_seed", encrypt(otpSeed, key))
.apply() .apply()
} }
fun loadMibCredentials(): MibCredentials? { fun loadMibCredentials(loginId: String): MibCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encUsername = prefs.getString("mib_enc_username", null) ?: return null val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null val encSeed = prefs.getString("mib_${loginId}_enc_otp_seed", null) ?: return null
val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null
return try { return try {
MibCredentials( MibCredentials(loginId, decrypt(encHash, key), decrypt(encSeed, key))
decrypt(encUsername, key),
decrypt(encHash, key),
decrypt(encSeed, key)
)
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearMibCredentials() { fun clearMibCredentials(loginId: String) {
removeMibLoginId(loginId)
prefs.edit() prefs.edit()
.remove("mib_enc_username") .remove("mib_${loginId}_enc_password_hash")
.remove("mib_enc_password_hash") .remove("mib_${loginId}_enc_otp_seed")
.remove("mib_enc_otp_seed") .remove("mib_${loginId}_enc_key1")
.remove("mib_enc_key1") .remove("mib_${loginId}_enc_key2")
.remove("mib_enc_key2") .remove("mib_${loginId}_enc_app_id")
.remove("mib_enc_app_id") .remove("mib_${loginId}_all_profiles")
.remove("mib_${loginId}_enc_profile")
.remove("mib_${loginId}_enc_full_name")
.remove("mib_${loginId}_hidden_profile_ids")
.apply() .apply()
} }
// ── MIB session keys (key1/key2) and app ID ─────────────────────────────── // ── MIB session keys (key1/key2) and app ID (per loginId) ────────────────
fun saveMibKeys(key1: String, key2: String) { fun saveMibKeys(loginId: String, key1: String, key2: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("mib_enc_key1", encrypt(key1, key)) .putString("mib_${loginId}_enc_key1", encrypt(key1, key))
.putString("mib_enc_key2", encrypt(key2, key)) .putString("mib_${loginId}_enc_key2", encrypt(key2, key))
.apply() .apply()
} }
fun loadMibKeys(): Pair<String, String>? { fun loadMibKeys(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null val encKey1 = prefs.getString("mib_${loginId}_enc_key1", null) ?: return null
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null val encKey2 = prefs.getString("mib_${loginId}_enc_key2", null) ?: return null
return try { return try {
Pair(decrypt(encKey1, key), decrypt(encKey2, key)) Pair(decrypt(encKey1, key), decrypt(encKey2, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun saveMibAppId(id: String) { fun saveMibAppId(loginId: String, id: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply() prefs.edit().putString("mib_${loginId}_enc_app_id", encrypt(id, key)).apply()
} }
fun loadMibAppId(): String? { fun loadMibAppId(loginId: String): String? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_app_id", null) ?: return null val enc = prefs.getString("mib_${loginId}_enc_app_id", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
/** One-time migration: if old single-login MIB data exists, move it to per-loginId storage. */
private var migrationChecked = false
private fun maybeMigrateLegacyMib() {
if (migrationChecked) return
migrationChecked = true
if (prefs.contains("mib_login_ids")) return // already migrated
val encUsername = prefs.getString("mib_enc_username", null) ?: return
val key = try { getOrCreateKey() } catch (_: Exception) { return }
val loginId = try { decrypt(encUsername, key) } catch (_: Exception) { return }
val editor = prefs.edit()
// Migrate credentials
prefs.getString("mib_enc_password_hash", null)?.let { editor.putString("mib_${loginId}_enc_password_hash", it) }
prefs.getString("mib_enc_otp_seed", null)?.let { editor.putString("mib_${loginId}_enc_otp_seed", it) }
prefs.getString("mib_enc_key1", null)?.let { editor.putString("mib_${loginId}_enc_key1", it) }
prefs.getString("mib_enc_key2", null)?.let { editor.putString("mib_${loginId}_enc_key2", it) }
prefs.getString("mib_enc_app_id", null)?.let { editor.putString("mib_${loginId}_enc_app_id", it) }
prefs.getString("mib_all_profiles", null)?.let { editor.putString("mib_${loginId}_all_profiles", it) }
prefs.getString("mib_enc_profile", null)?.let { editor.putString("mib_${loginId}_enc_profile", it) }
prefs.getString("mib_enc_full_name", null)?.let { editor.putString("mib_${loginId}_enc_full_name", it) }
prefs.getStringSet("mib_hidden_profile_ids", null)?.let { editor.putStringSet("mib_${loginId}_hidden_profile_ids", it) }
// Register the login ID and clear legacy keys
editor.putString("mib_login_ids", org.json.JSONArray(listOf(loginId)).toString())
editor.remove("mib_enc_username")
editor.remove("mib_enc_password_hash")
editor.remove("mib_enc_otp_seed")
editor.remove("mib_enc_key1")
editor.remove("mib_enc_key2")
editor.remove("mib_enc_app_id")
editor.remove("mib_all_profiles")
editor.remove("mib_enc_profile")
editor.remove("mib_enc_full_name")
editor.remove("mib_hidden_profile_ids")
editor.apply()
}
// ── BML login credentials (multi-login, keyed by loginId = username) ──────── // ── BML login credentials (multi-login, keyed by loginId = username) ────────
fun getBmlLoginIds(): List<String> { fun getBmlLoginIds(): List<String> {
val json = prefs.getString("bml_login_ids", null) val json = prefs.getString("bml_login_ids", null) ?: return emptyList()
if (json != null) {
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
// One-time migration from single-slot BML storage
val oldEncUsername = prefs.getString("bml_enc_username", null) ?: return emptyList()
return try { return try {
val key = getOrCreateKey() val arr = org.json.JSONArray(json)
val loginId = decrypt(oldEncUsername, key) (0 until arr.length()).map { arr.getString(it) }
val edit = prefs.edit()
prefs.getString("bml_enc_password", null)?.let { edit.putString("bml_${loginId}_enc_password", it) }
prefs.getString("bml_enc_otp_seed", null)?.let { edit.putString("bml_${loginId}_enc_otp_seed", it) }
prefs.getString("bml_enc_token", null)?.let { edit.putString("bml_${loginId}_enc_token", it) }
prefs.getString("bml_enc_device_id", null)?.let { edit.putString("bml_${loginId}_enc_device_id", it) }
prefs.getString("bml_enc_profile", null)?.let { edit.putString("bml_${loginId}_enc_profile", it) }
edit.putString("bml_${loginId}_enc_username", oldEncUsername)
edit.remove("bml_enc_username").remove("bml_enc_password").remove("bml_enc_otp_seed")
.remove("bml_enc_token").remove("bml_enc_device_id")
.remove("bml_enc_profile").remove("bml_enc_full_name")
val ids = org.json.JSONArray(listOf(loginId)).toString()
edit.putString("bml_login_ids", ids)
edit.apply()
listOf(loginId)
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
@@ -193,58 +227,81 @@ class CredentialStore(context: Context) {
.apply() .apply()
} }
// ── Fahipay login credentials ───────────────────────────────────────────── // ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
fun saveFahipayCredentials(idCard: String, password: String) { fun getFahipayLoginIds(): List<String> {
maybeMigrateLegacyFahipay()
val json = prefs.getString("fahipay_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 hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
private fun addFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveFahipayCredentials(loginId: String, idCard: String, password: String) {
addFahipayLoginId(loginId)
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("fahipay_enc_id_card", encrypt(idCard, key)) .putString("fahipay_${loginId}_enc_id_card", encrypt(idCard, key))
.putString("fahipay_enc_password", encrypt(password, key)) .putString("fahipay_${loginId}_enc_password", encrypt(password, key))
.apply() .apply()
} }
fun loadFahipayCredentials(): FahipayCredentials? { fun loadFahipayCredentials(loginId: String): FahipayCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null val encId = prefs.getString("fahipay_${loginId}_enc_id_card", null) ?: return null
val encPw = prefs.getString("fahipay_enc_password", null) ?: return null val encPw = prefs.getString("fahipay_${loginId}_enc_password", null) ?: return null
return try { return try {
FahipayCredentials(decrypt(encId, key), decrypt(encPw, key)) FahipayCredentials(decrypt(encId, key), decrypt(encPw, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearFahipayCredentials() { fun clearFahipayCredentials(loginId: String) {
removeFahipayLoginId(loginId)
prefs.edit() prefs.edit()
.remove("fahipay_enc_id_card") .remove("fahipay_${loginId}_enc_id_card")
.remove("fahipay_enc_password") .remove("fahipay_${loginId}_enc_password")
.remove("fahipay_${loginId}_enc_auth_id")
.remove("fahipay_${loginId}_enc_session_cookie")
.remove("fahipay_${loginId}_enc_profile")
.apply() .apply()
} }
// ── Fahipay session (authId + __Secure-sess cookie) ─────────────────────── // ── Fahipay session (authId + __Secure-sess cookie) (per loginId) ─────────
fun saveFahipaySession(authId: String, sessionCookie: String) { fun saveFahipaySession(loginId: String, authId: String, sessionCookie: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("fahipay_enc_auth_id", encrypt(authId, key)) .putString("fahipay_${loginId}_enc_auth_id", encrypt(authId, key))
.putString("fahipay_enc_session_cookie", encrypt(sessionCookie, key)) .putString("fahipay_${loginId}_enc_session_cookie", encrypt(sessionCookie, key))
.apply() .apply()
} }
fun loadFahipaySession(): Pair<String, String>? { fun loadFahipaySession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encAuth = prefs.getString("fahipay_enc_auth_id", null) ?: return null val encAuth = prefs.getString("fahipay_${loginId}_enc_auth_id", null) ?: return null
val encCookie = prefs.getString("fahipay_enc_session_cookie", null) ?: return null val encCookie = prefs.getString("fahipay_${loginId}_enc_session_cookie", null) ?: return null
return try { return try {
Pair(decrypt(encAuth, key), decrypt(encCookie, key)) Pair(decrypt(encAuth, key), decrypt(encCookie, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearFahipaySession() {
prefs.edit()
.remove("fahipay_enc_auth_id")
.remove("fahipay_enc_session_cookie")
.apply()
}
// ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─ // ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─
fun getOrCreateFahipayDeviceUuid(): String { fun getOrCreateFahipayDeviceUuid(): String {
@@ -258,7 +315,7 @@ class CredentialStore(context: Context) {
return uuid return uuid
} }
// ── Fahipay user profile ────────────────────────────────────────────────── // ── Fahipay user profile (per loginId) ────────────────────────────────────
data class FahipayUserProfile( data class FahipayUserProfile(
val fullName: String, val fullName: String,
@@ -270,7 +327,7 @@ class CredentialStore(context: Context) {
val linkedAccounts: String val linkedAccounts: String
) )
fun saveFahipayUserProfile(p: FahipayUserProfile) { fun saveFahipayUserProfile(loginId: String, p: FahipayUserProfile) {
val json = org.json.JSONObject().apply { val json = org.json.JSONObject().apply {
put("fullName", p.fullName) put("fullName", p.fullName)
put("email", p.email) put("email", p.email)
@@ -281,12 +338,12 @@ class CredentialStore(context: Context) {
put("linkedAccounts", p.linkedAccounts) put("linkedAccounts", p.linkedAccounts)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("fahipay_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("fahipay_${loginId}_enc_profile", encrypt(json, key)).apply()
} }
fun loadFahipayUserProfile(): FahipayUserProfile? { fun loadFahipayUserProfile(loginId: String): FahipayUserProfile? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("fahipay_enc_profile", null) ?: return null val enc = prefs.getString("fahipay_${loginId}_enc_profile", null) ?: return null
return try { return try {
val o = org.json.JSONObject(decrypt(enc, key)) val o = org.json.JSONObject(decrypt(enc, key))
FahipayUserProfile( FahipayUserProfile(
@@ -301,6 +358,33 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
/** One-time migration: if old single-login Fahipay data exists, move it to per-loginId storage. */
private var fahipayMigrationChecked = false
private fun maybeMigrateLegacyFahipay() {
if (fahipayMigrationChecked) return
fahipayMigrationChecked = true
if (prefs.contains("fahipay_login_ids")) return // already migrated
val encProfile = prefs.getString("fahipay_enc_profile", null) ?: return
val key = try { getOrCreateKey() } catch (_: Exception) { return }
val loginId = try {
val o = org.json.JSONObject(decrypt(encProfile, key))
o.optString("profileId").takeIf { it.isNotBlank() }
} catch (_: Exception) { null } ?: return
val editor = prefs.edit()
prefs.getString("fahipay_enc_id_card", null)?.let { editor.putString("fahipay_${loginId}_enc_id_card", it) }
prefs.getString("fahipay_enc_password", null)?.let { editor.putString("fahipay_${loginId}_enc_password", it) }
prefs.getString("fahipay_enc_auth_id", null)?.let { editor.putString("fahipay_${loginId}_enc_auth_id", it) }
prefs.getString("fahipay_enc_session_cookie", null)?.let { editor.putString("fahipay_${loginId}_enc_session_cookie", it) }
editor.putString("fahipay_${loginId}_enc_profile", encProfile)
editor.putString("fahipay_login_ids", org.json.JSONArray(listOf(loginId)).toString())
editor.remove("fahipay_enc_id_card")
editor.remove("fahipay_enc_password")
editor.remove("fahipay_enc_auth_id")
editor.remove("fahipay_enc_session_cookie")
editor.remove("fahipay_enc_profile")
editor.apply()
}
// ── Security credential (PIN / pattern hash) ────────────────────────────── // ── Security credential (PIN / pattern hash) ──────────────────────────────
/** /**
@@ -351,19 +435,55 @@ class CredentialStore(context: Context) {
val birthdate: String val birthdate: String
) )
fun saveMibFullName(name: String) { // ── MIB operating profiles (per loginId) ─────────────────────────────────
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply() fun saveMibProfiles(loginId: String, profiles: List<sh.sar.basedbank.api.mib.MibProfile>) {
val arr = org.json.JSONArray()
for (p in profiles) {
arr.put(org.json.JSONObject().apply {
put("profileId", p.profileId)
put("name", p.name)
put("cifType", p.cifType)
put("profileType", p.profileType)
put("color", p.color)
})
}
prefs.edit().putString("mib_${loginId}_all_profiles", arr.toString()).apply()
} }
fun loadMibFullName(): String? { fun loadMibProfiles(loginId: String): List<sh.sar.basedbank.api.mib.MibProfile> {
val raw = prefs.getString("mib_${loginId}_all_profiles", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
sh.sar.basedbank.api.mib.MibProfile(
profileId = o.optString("profileId"),
customerProfileId = o.optString("profileId"),
annexId = "",
customerId = "",
name = o.optString("name"),
cifType = o.optString("cifType"),
profileType = o.optString("profileType"),
color = o.optString("color"),
customerImage = null
)
}
} catch (_: Exception) { emptyList() }
}
fun saveMibFullName(loginId: String, name: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_full_name", null) ?: return null prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(name, key)).apply()
}
fun loadMibFullName(loginId: String): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_${loginId}_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
fun saveMibUserProfile(loginId: String, p: MibUserProfile) {
fun saveMibUserProfile(p: MibUserProfile) {
val json = JSONObject().apply { val json = JSONObject().apply {
put("fullName", p.fullName) put("fullName", p.fullName)
put("username", p.username) put("username", p.username)
@@ -372,14 +492,13 @@ class CredentialStore(context: Context) {
put("enrolled", p.enrolled) put("enrolled", p.enrolled)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
// Keep the name in sync with the fast-path field prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(p.fullName, key)).apply()
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
} }
fun loadMibUserProfile(): MibUserProfile? { fun loadMibUserProfile(loginId: String): MibUserProfile? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_profile", null) ?: return null val enc = prefs.getString("mib_${loginId}_enc_profile", null) ?: return null
return try { return try {
val o = JSONObject(decrypt(enc, key)) val o = JSONObject(decrypt(enc, key))
MibUserProfile( MibUserProfile(
@@ -421,6 +540,15 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
// ── MIB profile visibility (per loginId) ─────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
fun getHiddenMibProfileIds(loginId: String): Set<String> =
prefs.getStringSet("mib_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
// ── Crypto primitives ───────────────────────────────────────────────────── // ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey { private fun getOrCreateKey(): SecretKey {

View File

@@ -0,0 +1,117 @@
package sh.sar.basedbank.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/**
* Encapsulates all bank-specific pagination state and fetch logic for account history.
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
* without knowing which bank it is talking to.
*/
class HistoryFetcher(private val account: MibAccount) {
private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private val isFahipay get() = account.bank == "FAHIPAY"
// MIB pagination
private var mibNextStart = 1
private var mibTotalCount = -1
// BML CASA pagination
private var bmlNextPage = 1
private var bmlTotalPages = -1
// BML card pagination (month-based)
private var cardMonthOffset = 0
// Fahipay pagination
private var fahipayNextStart = 0
private var fahipayTotal = -1
fun hasMore(): Boolean = when {
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
}
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
val session = app.fahipaySessionFor(account) ?: return emptyList()
val flow = FahipayLoginFlow()
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
return list
}
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
val loginId = account.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId] ?: return emptyList()
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
return list
}
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
return BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
return list
}
}

View File

@@ -0,0 +1,10 @@
package sh.sar.basedbank.util
/** App-unified term for the transfer routing network a contact uses. */
enum class TransferNetwork {
MIB, // MIB internal (both parties on MIB)
LOCAL, // local inter-bank via IPS (e.g. BML from MIB's side)
SWIFT, // international SWIFT
BML, // Bank of Maldives
FAHIPAY // Fahipay wallet
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object BmlContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.BML,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}

View File

@@ -0,0 +1,69 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object BmlDashboardParser {
/**
* Returns all display fields for an account/card row in the accounts list.
* Handles both BML CASA accounts and BML prepaid/credit cards.
*/
fun displayData(account: MibAccount): AccountListDisplay {
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}",
isCard = true,
cardBrandIcon = cardBrandIcon(account.accountTypeName),
statusLabel = if (isActive) null else account.statusDesc
)
} else {
AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = listBalance(account)
)
}
}
/**
* Returns a display-ready product label for a BML dashboard account or card.
*/
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)
}
}
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
fun listBalance(account: MibAccount): String =
"${account.currencyName} ${account.currentBalance}"
fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
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
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View File

@@ -0,0 +1,20 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object BmlHistoryParser {
fun displayData(account: MibAccount): AccountHistoryDisplay {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
return AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "BML",
typeLabel = BmlDashboardParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
}

View File

@@ -0,0 +1,14 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object FahipayAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = account.accountTypeName,
balance = "${account.currencyName} ${account.availableBalance}"
)
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object FahipayContactParser {
fun displayData(contact: MibBeneficiary) = ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = TransferNetwork.FAHIPAY,
bankColor = contact.bankColor,
detail = null, // Fahipay contacts show no detail line
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
canTransfer = false,
canEdit = false,
canDelete = false
)
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object FahipayHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = "FP",
typeLabel = account.accountTypeName,
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

View File

@@ -0,0 +1,26 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser {
fun displayData(account: MibAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}"
)
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

View File

@@ -0,0 +1,32 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
object MibContactParser {
fun displayData(contact: MibBeneficiary): ContactDisplay {
val network = when (contact.benefType) {
"I" -> TransferNetwork.MIB
"S" -> TransferNetwork.SWIFT
else -> TransferNetwork.LOCAL // "L" and anything else
}
return ContactDisplay(
id = contact.benefNo,
name = contact.benefNickName,
realName = contact.benefName,
accountNumber = contact.benefAccount,
categoryId = contact.benefCategoryId,
network = network,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
canTransfer = true,
canEdit = true,
canDelete = true
)
}
}

View File

@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.mibapi
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object MibHistoryParser {
fun displayData(account: MibAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M9,3h2v2H9V3zm4,0h2v2h-2V3zM9,7h2v2H9V7zm4,0h2v2h-2V7zM9,11h2v2H9v-2zm4,0h2v2h-2v-2zM9,15h2v2H9v-2zm4,0h2v2h-2v-2zM9,19h2v2H9v-2zm4,0h2v2h-2v-2z"/>
</vector>

View File

@@ -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>

View File

@@ -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>

View File

@@ -4,17 +4,20 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawerLayout" android:id="@+id/drawerLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:fitsSystemWindows="true">
<!-- Main content --> <!-- Main content -->
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
@@ -49,6 +52,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" /> app:menu="@menu/bottom_nav_menu" />
</LinearLayout> </LinearLayout>
@@ -61,6 +65,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="start" android:layout_gravity="start"
android:fitsSystemWindows="true"
app:menu="@menu/drawer_menu" /> app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout> </androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -4,7 +4,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/navHostFragment" android:id="@+id/navHostFragment"

View File

@@ -27,31 +27,49 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"> app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.chip.ChipGroup <LinearLayout
android:id="@+id/languageChipGroup" android:id="@+id/languageSection"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:orientation="vertical"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:visibility="gone" android:visibility="gone">
app:singleSelection="true"
app:selectionRequired="false">
<com.google.android.material.chip.Chip <TextView
android:id="@+id/chipEnglish"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="English" android:layout_gravity="center_horizontal"
style="@style/Widget.Material3.Chip.Filter" /> android:text="@string/select_language"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.chip.Chip <com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/chipDhivehi" android:id="@+id/languageToggle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="ދިވެހި" app:singleSelection="true"
style="@style/Widget.Material3.Chip.Filter" /> 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 <com.google.android.material.tabs.TabLayout
android:id="@+id/dotsIndicator" android:id="@+id/dotsIndicator"
@@ -61,6 +79,8 @@
android:background="@null" android:background="@null"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="24dp" android:layout_marginBottom="24dp"
android:clickable="false"
android:focusable="false"
app:tabBackground="@drawable/tab_indicator_selector" app:tabBackground="@drawable/tab_indicator_selector"
app:tabGravity="center" app:tabGravity="center"
app:tabIndicatorHeight="0dp" app:tabIndicatorHeight="0dp"

View File

@@ -10,6 +10,7 @@
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<LinearLayout <LinearLayout
android:id="@+id/btnContainer"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal" android:layout_gravity="bottom|center_horizontal"

View File

@@ -94,7 +94,7 @@
<ImageView <ImageView
android:layout_width="138dp" android:layout_width="138dp"
android:layout_height="40dp" android:layout_height="40dp"
android:src="@drawable/bml_logo_vector" android:src="@drawable/bml_logo_long"
android:contentDescription="@string/bml_name" android:contentDescription="@string/bml_name"
android:scaleType="fitStart" android:scaleType="fitStart"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="12dp" />

View File

@@ -80,7 +80,6 @@
android:hint="@string/otp_seed" android:hint="@string/otp_seed"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:endIconMode="password_toggle" app:endIconMode="password_toggle"
app:helperText="@string/otp_seed_hint"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/etOtpSeed" android:id="@+id/etOtpSeed"
@@ -116,7 +115,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:visibility="gone" android:visibility="invisible"
app:cardBackgroundColor="?attr/colorSecondaryContainer" app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp" app:cardCornerRadius="12dp"
app:cardElevation="0dp"> app:cardElevation="0dp">

View File

@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -162,33 +168,57 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Action buttons --> </LinearLayout>
</androidx.core.widget.NestedScrollView>
<!-- Quick actions fixed at bottom -->
<LinearLayout
android:id="@+id/buttonBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="horizontal"> android:orientation="horizontal">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnTransfer" android:id="@+id/btnQuickAction1"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:text="@string/transfer" /> app:iconGravity="textStart"
app:iconSize="18dp"
app:iconPadding="8dp" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnPayMvQr" android:id="@+id/btnQuickAction2"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:text="@string/pay_mv_qr" /> app:iconGravity="textStart"
app:iconSize="18dp"
app:iconPadding="8dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </LinearLayout>

View File

@@ -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>

View File

@@ -1,94 +1,96 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:gravity="center_horizontal" android:textColor="?attr/colorOnSurface"
android:paddingHorizontal="32dp" android:gravity="center"
android:paddingTop="64dp" android:layout_marginBottom="16dp" />
android:paddingBottom="16dp">
<ImageView <ScrollView
android:id="@+id/icon" android:id="@+id/scrollView"
android:layout_width="120dp" android:layout_width="match_parent"
android:layout_height="120dp" android:layout_height="0dp"
android:layout_marginBottom="40dp" android:layout_weight="1"
android:contentDescription="@string/app_name" /> 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 <LinearLayout
android:id="@+id/placeholderCards"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="vertical">
android:layout_marginTop="40dp"
android:weightSum="2"
android:visibility="gone">
<com.google.android.material.card.MaterialCardView <TextView
android:layout_width="0dp" android:id="@+id/description"
android:layout_height="80dp" android:layout_width="match_parent"
android:layout_weight="1" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:textAppearance="?attr/textAppearanceBodyLarge"
app:cardCornerRadius="12dp" android:textColor="?attr/colorOnSurfaceVariant"
app:strokeWidth="1dp" android:gravity="center"
app:strokeColor="?attr/colorOutline"> 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 <ImageView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="40dp"
android:src="@drawable/mib_faisanet_logo" android:src="@drawable/mib_faisanet_logo"
android:scaleType="centerInside" android:scaleType="centerInside"
android:padding="12dp"
android:contentDescription="@string/mib_name" /> 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 <ImageView
android:layout_width="match_parent" 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:src="@drawable/bml_logo_vector"
android:scaleType="centerInside" android:scaleType="centerInside"
android:padding="8dp"
android:contentDescription="@string/bml_name" /> 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> </LinearLayout>
</ScrollView>
</LinearLayout> </LinearLayout>
</ScrollView>

View File

@@ -4,7 +4,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:background="#FFFFFF"> android:background="?attr/colorSurface">
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Renderable receipt card --> <!-- Renderable receipt card -->
@@ -206,12 +206,6 @@
</LinearLayout> </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 --> <!-- Action buttons — outside renderable area -->
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
@@ -219,7 +213,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:background="#FFFFFF" android:background="?attr/colorSurface"
android:paddingHorizontal="12dp" android:paddingHorizontal="12dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="12dp"> android:paddingBottom="12dp">

View File

@@ -15,15 +15,16 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingHorizontal="24dp" android:paddingHorizontal="24dp"
android:paddingTop="40dp" android:paddingTop="64dp"
android:paddingBottom="24dp" android:paddingBottom="24dp"
android:gravity="center_horizontal"> android:gravity="center_horizontal">
<TextView <ImageView
android:layout_width="wrap_content" android:layout_width="80dp"
android:layout_height="wrap_content" android:layout_height="80dp"
android:text="🔒" android:src="@drawable/ic_lock"
android:textSize="56sp" android:tint="?attr/colorPrimary"
android:contentDescription="@null"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="16dp" />
<TextView <TextView
@@ -255,73 +256,49 @@
</LinearLayout> </LinearLayout>
<!-- Step: Biometric prompt -->
<!-- Step: Already configured -->
<LinearLayout <LinearLayout
android:id="@+id/viewBiometric" android:id="@+id/viewConfigured"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center"
android:paddingHorizontal="24dp" android:paddingHorizontal="24dp"
android:paddingTop="40dp"
android:paddingBottom="24dp"
android:gravity="center_horizontal"
android:visibility="gone"> 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 <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🔐" android:text="@string/security_already_configured"
android:textSize="72sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/biometric_title"
android:textAppearance="?attr/textAppearanceHeadlineSmall" android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="8dp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/biometric_desc" android:text="@string/security_already_configured_desc"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="32dp" />
<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>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnEnableBiometrics" android:id="@+id/btnChangeLock"
android:layout_width="match_parent" style="@style/Widget.Material3.Button.OutlinedButton"
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:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/skip_biometrics" /> android:text="@string/settings_change_lock" />
</LinearLayout> </LinearLayout>

View File

@@ -45,6 +45,69 @@
</com.google.android.material.button.MaterialButtonToggleGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Quick actions (always active) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvQuickActions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never"
android:layout_marginBottom="16dp" />
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
<LinearLayout
android:id="@+id/sectionBottomBarShortcuts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_bottom_bar_shortcuts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvNavSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_bottom_bar_show_labels"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchShowLabels"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -31,8 +31,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginTop="16dp" android:layout_marginTop="16dp">
android:visibility="gone">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -40,7 +39,17 @@
android:text="@string/settings_biometrics" android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceTitleSmall" android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurfaceVariant" 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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -37,9 +37,17 @@
android:fontFamily="monospace" android:fontFamily="monospace"
android:layout_marginTop="2dp" /> android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvAccountType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout> </LinearLayout>
<!-- Right: segmented pill (bank | type) + balance --> <!-- Right: balance + transfer button -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -47,31 +55,22 @@
android:gravity="end" android:gravity="end"
android:layout_marginStart="16dp"> android:layout_marginStart="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/pill_segment_bg">
<TextView
android:id="@+id/tvPillType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<TextView <TextView
android:id="@+id/tvBalance" android:id="@+id/tvBalance"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall" android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface" />
android:layout_marginTop="6dp" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout> </LinearLayout>

View File

@@ -14,18 +14,14 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground"> android:foreground="?attr/selectableItemBackground">
<!-- Brand chip --> <!-- Brand logo -->
<TextView <ImageView
android:id="@+id/tvCardBrand" android:id="@+id/ivCardBrand"
android:layout_width="wrap_content" android:layout_width="48dp"
android:layout_height="wrap_content" android:layout_height="32dp"
android:paddingHorizontal="10dp"
android:paddingVertical="6dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:textAppearance="?attr/textAppearanceLabelSmall" android:scaleType="fitCenter"
android:textColor="@android:color/white" android:contentDescription="Card brand" />
android:fontFamily="monospace"
android:background="@drawable/chip_background" />
<!-- Card name + number --> <!-- Card name + number -->
<LinearLayout <LinearLayout
@@ -50,9 +46,17 @@
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:fontFamily="monospace" /> 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> </LinearLayout>
<!-- Status pill + balance (prepaid only) --> <!-- Transfer button + balance -->
<LinearLayout <LinearLayout
android:id="@+id/layoutCardBalance" android:id="@+id/layoutCardBalance"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -81,6 +85,16 @@
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" /> android:layout_marginTop="6dp" />
<ImageButton
android:id="@+id/btnTransfer"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:tint="?attr/colorPrimary"
android:contentDescription="Transfer" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingVertical="12dp"
android:paddingHorizontal="8dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/ivNavIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginBottom="6dp" />
<TextView
android:id="@+id/tvNavLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>

View File

@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.navigation.NavigationView <com.google.android.material.navigation.NavigationView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/navMoreView" android:id="@+id/navMoreView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content" />
app:menu="@menu/more_nav_menu" />

View File

@@ -3,12 +3,14 @@
<string name="app_name">BasedBank</string> <string name="app_name">BasedBank</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="onboarding_supported_services">ހިދުމަތްތައް</string>
<string name="select_language">ބަސް ހިޔާލު ކުރޭ</string>
<string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string> <string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string>
<string name="onboarding_desc_1">BasedBank ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string> <string name="onboarding_desc_1">BasedBank ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string>
<string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string> <string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string>
<string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string> <string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string>
<string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</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="coming_soon">ފަހުން ލިބޭ</string>
<string name="next">ދެން</string> <string name="next">ދެން</string>
<string name="get_started">ފަށާ</string> <string name="get_started">ފަށާ</string>
@@ -77,6 +79,7 @@
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string> <string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string>
<string name="balance_mvr">ޖުމްލަ MVR</string> <string name="balance_mvr">ޖުމްލަ MVR</string>
<string name="balance_usd">ޖުމްލަ USD</string> <string name="balance_usd">ޖުމްލަ USD</string>
<string name="card_support_wip">ކާޑް ސަޕޯޓް</string> <string name="card_support_wip">ކާޑް ސަޕޯޓް</string>
@@ -84,6 +87,11 @@
<string name="pay_mv_qr">PayMV QR</string> <string name="pay_mv_qr">PayMV QR</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_bottom_bar_shortcuts">ތިރި ބާ ޝޯޓްކަޓްތައް</string>
<string name="settings_bottom_bar_select">ބަޓަން ހިޔާރު ކުރޭ</string>
<string name="settings_bottom_bar_slot_1">ތަން 1</string>
<string name="settings_bottom_bar_slot_2">ތަން 2</string>
<string name="settings_bottom_bar_slot_3">ތަން 3</string>
<string name="theme">ތީމް</string> <string name="theme">ތީމް</string>
<string name="theme_system">ސިސްޓަމް</string> <string name="theme_system">ސިސްޓަމް</string>
<string name="theme_light">ލައިޓް</string> <string name="theme_light">ލައިޓް</string>
@@ -111,6 +119,8 @@
<string name="close">ބަންދު</string> <string name="close">ބަންދު</string>
<string name="cancel">ކެންސަލް</string> <string name="cancel">ކެންސަލް</string>
<string name="settings_bottom_bar_show_labels">ބޮޓަމް ބާ ލޭބަލް އަބަދުވެސް ދެއްކުން</string>
<!-- Home --> <!-- Home -->
<string name="accounts">އެކައުންޓްތައް</string> <string name="accounts">އެކައުންޓްތައް</string>
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string> <string name="available_balance">ލިބެން ހުރި ބެލެންސް</string>

View File

@@ -2,12 +2,14 @@
<string name="app_name">BasedBank</string> <string name="app_name">BasedBank</string>
<!-- Onboarding --> <!-- 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_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_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_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_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_title_3">Before You Begin</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_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="coming_soon">Coming Soon</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="get_started">Get Started</string> <string name="get_started">Get Started</string>
@@ -49,6 +51,9 @@
<!-- Security setup --> <!-- Security setup -->
<string name="security_setup">Secure Your App</string> <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_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">PIN Code</string>
<string name="method_pin_desc">48 digit numeric PIN</string> <string name="method_pin_desc">48 digit numeric PIN</string>
<string name="method_pattern">Draw Pattern</string> <string name="method_pattern">Draw Pattern</string>
@@ -71,7 +76,7 @@
<!-- Navigation --> <!-- Navigation -->
<string name="nav_dashboard">Dashboard</string> <string name="nav_dashboard">Dashboard</string>
<string name="nav_add_account">Add Account</string> <string name="nav_add_account">Add Login</string>
<string name="nav_accounts">Accounts</string> <string name="nav_accounts">Accounts</string>
<string name="nav_contacts">Contacts</string> <string name="nav_contacts">Contacts</string>
<string name="nav_activities">Activities</string> <string name="nav_activities">Activities</string>
@@ -87,6 +92,7 @@
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_pending_finances">Pending Finances</string> <string name="dashboard_pending_finances">Pending Finances</string>
<string name="dashboard_quick_actions">Quick Actions</string>
<string name="balance_mvr">MVR Total</string> <string name="balance_mvr">MVR Total</string>
<string name="balance_usd">USD Total</string> <string name="balance_usd">USD Total</string>
<string name="card_support_wip">Card Support</string> <string name="card_support_wip">Card Support</string>
@@ -104,6 +110,7 @@
<string name="settings_security">Security</string> <string name="settings_security">Security</string>
<string name="settings_change_lock">Change PIN / Pattern</string> <string name="settings_change_lock">Change PIN / Pattern</string>
<string name="settings_biometrics">Use biometrics</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_unlock">To unlock app</string>
<string name="settings_biometrics_transfer">Confirm transfer</string> <string name="settings_biometrics_transfer">Confirm transfer</string>
<string name="biometric_transfer_title">Confirm Transfer</string> <string name="biometric_transfer_title">Confirm Transfer</string>
@@ -130,6 +137,12 @@
<string name="settings_nav_drawer">Drawer</string> <string name="settings_nav_drawer">Drawer</string>
<string name="settings_nav_bottom">Bottom Bar</string> <string name="settings_nav_bottom">Bottom Bar</string>
<string name="settings_appearance">Appearance</string> <string name="settings_appearance">Appearance</string>
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
<string name="settings_bottom_bar_select">Choose button</string>
<string name="settings_bottom_bar_slot_1">Slot 1</string>
<string name="settings_bottom_bar_slot_2">Slot 2</string>
<string name="settings_bottom_bar_slot_3">Slot 3</string>
<string name="settings_privacy_security">Privacy &amp; Security</string> <string name="settings_privacy_security">Privacy &amp; Security</string>
<string name="settings_storage">Storage</string> <string name="settings_storage">Storage</string>
<string name="settings_logins">Logins</string> <string name="settings_logins">Logins</string>
@@ -144,6 +157,7 @@
<string name="login_detail_id_card">ID Card</string> <string name="login_detail_id_card">ID Card</string>
<string name="login_detail_profiles">Profiles</string> <string name="login_detail_profiles">Profiles</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<!-- Home --> <!-- Home -->

BIN
bml_logo_long.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

85
docs/PARSERS.md Normal file
View File

@@ -0,0 +1,85 @@
# Account Display Parser Architecture
## Overview
Each bank's API returns account data in different formats and uses different field names for balances, product types, and status. To keep screens bank-agnostic, each bank has a dedicated parser that translates raw `MibAccount` model data into a standard `AccountListDisplay` object. Screens consume only `AccountListDisplay` — they never inspect `bank` or `profileType` or apply bank-specific logic.
## Bank Discriminator — `MibAccount.bank`
All dispatchers route by `account.bank`, a string set explicitly by each login flow at account creation time:
| `bank` value | Set by |
|--------------|-------------------|
| `"MIB"` | `MibLoginFlow` |
| `"BML"` | `BmlLoginFlow` |
| `"FAHIPAY"` | `FahipayLoginFlow`|
`profileType` is a bank-internal value (e.g. MIB's numeric profile ID, or BML's `"BML_PREPAID"`) and is **never** used for bank routing. Card-type checks within BML still use `profileType` (`"BML_PREPAID"` / `"BML_CREDIT"`).
`cifType` (MIB only) is the human-readable profile category name returned by the `operatingProfiles` API (e.g. `"Individual"`, `"Sole Propr"`). It is stored on `MibAccount` and surfaced in the accounts list section header and settings. It is **never hardcoded** in the app.
## Standard Output Model
```kotlin
// util/AccountListDisplay.kt
data class AccountListDisplay(
val name: String, // account or card display name
val number: String, // account/card number
val typeLabel: String, // human-friendly product type (e.g. "Savings", "Visa Platinum")
val balance: String, // formatted balance string (e.g. "MVR 1,234.56")
val isCard: Boolean, // true → use card layout; false → use account layout
val cardBrandIcon: Int, // drawable res for card brand logo (Visa / Mastercard / Amex)
val statusLabel: String? // null = active; non-null = shown as status label (e.g. "Inactive")
)
```
## Dispatcher
```kotlin
// util/AccountListParser.kt
AccountListParser.from(account: MibAccount): AccountListDisplay?
```
Routes to the correct parser based on `account.bank`. Returns `null` for unknown banks — never falls back to a specific bank.
| `account.bank` | Parser |
|----------------|-------------------------|
| `"BML"` | `BmlDashboardParser` |
| `"FAHIPAY"` | `FahipayAccountParser` |
| `"MIB"` | `MibAccountParser` |
| anything else | `null` |
## Bank Parsers
### BML — `util/bmlapi/BmlDashboardParser`
Handles both CASA accounts and prepaid/credit cards.
- **CASA balance**: uses `ledgerBalance` (working balance) — mapped to `account.currentBalance`
- **Card balance**: uses `availableBalance` (available limit from `cardBalance.AvailableLimit`)
- **Card brand**: resolved from product name (`VISA` / `MASTERCARD` / `AMEX`)
- **Status**: cards with `statusDesc != "Active"` surface `statusLabel`; active cards return `null`
### MIB — `util/mibapi/MibAccountParser`
- **Balance**: `availableBalance` from the MIB API directly
- Known product names (`SAVING ACCOUNT`, `CURRENT ACCOUNT`) mapped to short labels
- `cifType` (e.g. `"Individual"`, `"Sole Propr"`) comes from `MibProfile.cifType`, stored on `MibAccount`, displayed in section headers
### Fahipay — `util/fahipayapi/FahipayAccountParser`
- Single wallet account per user; `accountTypeName` is always `"Digital Wallet"`
- **Balance**: `availableBalance`
## Adding a New Bank
1. Create `util/<bankname>api/<Bank>AccountParser.kt` with a `displayData(account: MibAccount): AccountListDisplay` function
2. Set `bank = "<BANKNAME>"` in the new login flow when creating `MibAccount` objects
3. Add a `when` branch in `AccountListParser.from()` (and other dispatchers) for the new bank value
4. No changes needed in any screen or adapter
## Usage in Screens
`AccountsAdapter` calls `AccountListParser.from(account)` once per item (skipping `null` results) and binds the resulting `AccountListDisplay` directly. The adapter has zero bank-specific logic.
The transfer screen dropdown (`TransferFragment`) also uses `AccountListParser.from(acc)?.balance` for the source account balance display.

View File

@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.9.1" agp = "8.7.3"
kotlin = "2.1.21" kotlin = "2.1.21"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"